Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -8,9 +8,66 @@ import {
|
||||
} from '@/components/chat/message/selectionMarkdown';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { shortcutRegistry } from '@/lib/shortcuts';
|
||||
|
||||
const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]';
|
||||
|
||||
interface ActiveSelectionToolbarActions {
|
||||
addToChat: () => void;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
interface ActiveSelectionToolbarRegistration extends ActiveSelectionToolbarActions {
|
||||
resumeGlobalShortcuts: () => void;
|
||||
}
|
||||
|
||||
const activeSelectionToolbarRegistrations: ActiveSelectionToolbarRegistration[] = [];
|
||||
let activeSelectionToolbarVersion = 0;
|
||||
|
||||
const releaseSelectionToolbar = (registration: ActiveSelectionToolbarRegistration): void => {
|
||||
const index = activeSelectionToolbarRegistrations.indexOf(registration);
|
||||
if (index === -1) return;
|
||||
|
||||
activeSelectionToolbarRegistrations.splice(index, 1);
|
||||
registration.resumeGlobalShortcuts();
|
||||
activeSelectionToolbarVersion += 1;
|
||||
};
|
||||
|
||||
export const registerActiveSelectionToolbar = (
|
||||
actions: ActiveSelectionToolbarActions,
|
||||
): (() => void) => {
|
||||
const registration: ActiveSelectionToolbarRegistration = {
|
||||
...actions,
|
||||
resumeGlobalShortcuts: shortcutRegistry.suspend(),
|
||||
};
|
||||
activeSelectionToolbarRegistrations.push(registration);
|
||||
activeSelectionToolbarVersion += 1;
|
||||
|
||||
return () => releaseSelectionToolbar(registration);
|
||||
};
|
||||
|
||||
export const hasActiveSelectionToolbar = (): boolean => activeSelectionToolbarRegistrations.length > 0;
|
||||
|
||||
export const getActiveSelectionToolbarVersion = (): number => activeSelectionToolbarVersion;
|
||||
|
||||
export const invokeActiveSelectionAddToChat = (): boolean => {
|
||||
const registration = activeSelectionToolbarRegistrations.at(-1);
|
||||
if (!registration) return false;
|
||||
|
||||
releaseSelectionToolbar(registration);
|
||||
registration.addToChat();
|
||||
return true;
|
||||
};
|
||||
|
||||
export const dismissActiveSelectionToolbar = (): boolean => {
|
||||
const registration = activeSelectionToolbarRegistrations.at(-1);
|
||||
if (!registration) return false;
|
||||
|
||||
releaseSelectionToolbar(registration);
|
||||
registration.dismiss();
|
||||
return true;
|
||||
};
|
||||
|
||||
const isInsideChatComposer = (node: Node | null): boolean => {
|
||||
if (!node) {
|
||||
return false;
|
||||
|
||||
@@ -1080,18 +1080,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Sitzungs-Tab wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Vorherige Sitzung',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Nächste Sitzung',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Aktuelle Sitzung umbenennen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Auto-Genehmigung umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Plan-Kontextpanel umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Dienstemenü umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Dienste-Tab durchschalten',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thema wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent wechseln',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Favorites Modell vorwärts durchschalten',
|
||||
@@ -1100,6 +1102,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Diese Sequenz teilt ein kontextabhängiges Präfix mit {action}. Wenn dessen Kontext aktiv ist, hat diese Aktion Vorrang.',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'Sitzungssteuerung',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'Modelle und Agenten',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'Panels und Werkzeuge',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Anwendung',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen mit jeweils höchstens drei Tasten. Warten Sie nach der ersten bis zu 3 Sekunden auf eine zweite Kombination. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Nicht zugewiesen',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Dies kollidiert mit der von {action} verwendeten Sequenz. Wählen Sie eine andere Kombination.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Diese Kombination wird bereits von {action} verwendet.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Diese Kombination kollidiert mit einem integrierten Tastenkürzel, das nicht ersetzt werden kann.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Projektauswahl für Entwurf öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Worktree-Auswahl für Entwurf öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Letzte Sitzungen öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Spracheingabe',
|
||||
'settings.projects.sidebar.total': 'Gesamt {count}',
|
||||
'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen',
|
||||
'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.',
|
||||
|
||||
@@ -1498,7 +1498,7 @@ export const dict = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Fehler beim Lesen der Plan-Datei',
|
||||
'inlineComment.range.lines': 'Zeilen {start}-{end}',
|
||||
'inlineComment.input.placeholder': 'Kommentar hinzufügen... (Cmd+Enter zum Speichern)',
|
||||
'inlineComment.input.placeholder': 'Kommentar hinzufügen... ({shortcut} zum Speichern)',
|
||||
'inlineComment.input.placeholderShort': 'Kommentar hinzufügen...',
|
||||
'inlineComment.actions.cancel': 'Abbrechen',
|
||||
'inlineComment.actions.save': 'Speichern',
|
||||
@@ -1684,22 +1684,18 @@ export const dict = {
|
||||
'helpDialog.item.focusChatInput': 'Chat-Eingabe fokussieren',
|
||||
'helpDialog.item.togglePromptNavigator': 'Aufforderungs-Navigator umschalten',
|
||||
'helpDialog.item.abortActiveRun': 'Aktuelle Ausführung abbrechen (Doppeltaste)',
|
||||
'helpDialog.item.toggleRightSidebar': 'Rechte Seitenleiste umschalten',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Git-Registerkarte der rechten Seitenleiste öffnen',
|
||||
'helpDialog.item.openRightSidebarFilesTab': 'Datei-Registerkarte der rechten Seitenleiste öffnen',
|
||||
'helpDialog.item.toggleTerminalDock': 'Terminal-Dock umschalten',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
|
||||
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
|
||||
'helpDialog.item.switchSessionTab': 'Sitzungs-Tab wechseln',
|
||||
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
|
||||
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
|
||||
'helpDialog.item.openSettings': 'Einstellungen öffnen',
|
||||
'helpDialog.keyCombiner.or': 'oder',
|
||||
'helpDialog.proTips.title': 'Pro-Tipps:',
|
||||
'helpDialog.proTips.commandPalette': 'Verwenden Sie die Befehlspalette ({shortcut}), um schnell auf alle Aktionen zuzugreifen',
|
||||
'helpDialog.proTips.recentSessions': 'Die 5 zuletzt verwendeten Sitzungen erscheinen in der Befehlspalette',
|
||||
'helpDialog.proTips.themeCycling': 'Themenwechsel merken sich Ihre Einstellung über Sitzungen hinweg',
|
||||
'helpDialog.proTips.leaderSequences': 'Zweistufige Kürzel: erst die Kombination, dann die zweite Taste — Esc bricht ab',
|
||||
'header.actions.rightSidebarWithShortcut': 'Rechte Seitenleiste ({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': 'Rechte Seitenleiste umschalten',
|
||||
'header.actions.openAppMenu': 'OpenChamber-Menü',
|
||||
@@ -1907,7 +1903,6 @@ export const dict = {
|
||||
'chat.statusRow.actions.stopGeneratingAria': 'Generierung stoppen',
|
||||
'chat.statusRow.tasksTitle': 'Aufgaben',
|
||||
'chat.statusRow.summary.activeLeft': '{active} aktiv · {left} übrig',
|
||||
'chat.statusRow.aborted': 'Abgebrochen',
|
||||
'chat.revertIndicator.redo': 'Wiederholen',
|
||||
'chat.revertIndicator.redoAria': 'Wiederholen — wiederhergestellte Nachrichten',
|
||||
'chat.revertPopover.title': 'Zurückgesetzt',
|
||||
@@ -2018,10 +2013,8 @@ export const dict = {
|
||||
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
|
||||
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
|
||||
'chat.textSelection.comment.attach': 'Anhängen',
|
||||
'chat.textSelection.actions.newSession': 'Neue Sitzung',
|
||||
'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen',
|
||||
'chat.textSelection.title.saveInsightToNotes': 'Ausgewählten Text zu Notizen speichern',
|
||||
'chat.messageBody.actions.revertAria': 'Zu dieser Nachricht zurückkehren',
|
||||
'chat.messageBody.actions.revert': 'Von hier zurückkehren',
|
||||
@@ -2293,6 +2286,15 @@ export const dict = {
|
||||
'commandPalette.item.toggleSidebar': 'Seitenleiste umschalten',
|
||||
'commandPalette.item.showContextUsage': 'Kontextnutzung anzeigen',
|
||||
'commandPalette.item.toggleTerminal': 'Terminal umschalten',
|
||||
'commandPalette.item.cycleTheme': 'Thema wechseln',
|
||||
'commandPalette.item.showOpenCodeStatus': 'OpenCode-Status anzeigen',
|
||||
'commandPalette.item.toggleMemoryDebug': 'Memory-Debug-Panel umschalten',
|
||||
'commandPalette.item.pinSession': 'Sitzung anheften oder lösen',
|
||||
'commandPalette.item.copySessionId': 'Sitzungs-ID kopieren',
|
||||
'commandPalette.item.openMultiRun': 'Multi-Run-Launcher öffnen',
|
||||
'commandPalette.item.openArchive': 'Archivierte Sitzungen öffnen',
|
||||
'commandPalette.item.openNotes': 'Notizbereich öffnen',
|
||||
'commandPalette.item.openTodos': 'To-do-Bereich öffnen',
|
||||
'commandPalette.item.openSettings': 'Einstellungen öffnen...',
|
||||
'commandPalette.session.untitled': 'Unbenannte Sitzung',
|
||||
'openCodeStatusDialog.title': 'OpenCode-Status',
|
||||
@@ -2506,6 +2508,9 @@ export const dict = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.',
|
||||
'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.',
|
||||
'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich',
|
||||
'sessionAuth.expired.banner': 'Deine Sitzung ist abgelaufen — melde dich an, um fortzufahren.',
|
||||
'sessionAuth.expired.loginAction': 'Anmelden',
|
||||
'sessionAuth.expired.sendBlocked': 'Sitzung abgelaufen — melde dich an, um Nachrichten zu senden.',
|
||||
'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren',
|
||||
'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.',
|
||||
'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.',
|
||||
@@ -2990,6 +2995,11 @@ export const dict = {
|
||||
'gitView.pr.segment.comments': 'Kommentare',
|
||||
'gitView.pr.comments.addAll': 'Alle hinzufügen',
|
||||
'contextPanel.mode.pr': 'PR',
|
||||
'contextRail.configure.open': 'Panels konfigurieren',
|
||||
'contextRail.configure.dialogTitle': 'Leisten-Panels',
|
||||
'contextRail.configure.dialogDescription': 'Wähle, welche Panels die Leiste zeigt. Ausgeblendete Panels behalten ihre Daten und bleiben über die Befehlspalette erreichbar.',
|
||||
'contextRail.configure.showAll': 'Alle anzeigen',
|
||||
'contextRail.configure.noneWarning': 'Alle Panels sind ausgeblendet.',
|
||||
'contextRail.aria.rail': 'Kontextleiste',
|
||||
'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt',
|
||||
'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.',
|
||||
@@ -3081,7 +3091,8 @@ export const dict = {
|
||||
'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen',
|
||||
'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden',
|
||||
'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden',
|
||||
'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.',
|
||||
'chat.container.sessionLoadError.description': 'Die Unterhaltung konnte nicht geladen werden — der Server ist womöglich offline oder nicht erreichbar. Nichts ist verloren; versuche es erneut, sobald er wieder da ist.',
|
||||
'chat.container.sessionLoadError.authDescription': 'Deine Sitzung ist abgelaufen, daher hat der Server die Anfrage abgelehnt. Melde dich an, dann wird die Unterhaltung geladen.',
|
||||
'chat.container.sessionLoadError.retry': 'Erneut versuchen',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
|
||||
|
||||
@@ -1133,7 +1133,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'This combo is already used by another shortcut. Overwrite and clear that other mapping?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Press keys...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. It is still saved.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. You can still save it.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Go to line (files editor)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input',
|
||||
@@ -1142,18 +1142,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Switch session tab',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Previous session',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Next session',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Rename current session',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Toggle permission auto-accept',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Toggle plan context panel',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Toggle services menu',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Cycle services tab',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Cycle theme',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Cycle agent',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Cycle favorite model forward',
|
||||
@@ -1162,6 +1164,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Expand input',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Open conversation timeline',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Toggle prompt navigator',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'This sequence shares a contextual prefix with {action}. That action takes priority while its context is active.',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'Session Controls',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'Models & Agents',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'Panels & Tools',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations, with at most three keys each. After the first, wait up to 3 seconds for a second combination. Use Confirm to apply or Cancel to discard. Backspace removes the last one.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Unassigned',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'This conflicts with the sequence used by {action}. Choose a different combination.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'This combination conflicts with a built-in shortcut, which cannot be replaced.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Open draft project picker',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Open draft worktree picker',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input',
|
||||
'settings.projects.sidebar.total': 'Total {count}',
|
||||
'settings.projects.sidebar.actions.addProject': 'Add project',
|
||||
'settings.projects.page.empty.noProjects': 'No projects available.',
|
||||
|
||||
@@ -1139,6 +1139,11 @@ export const dict = {
|
||||
'contextPanel.mode.context': 'Context',
|
||||
'contextPanel.mode.preview': 'Preview',
|
||||
'contextPanel.mode.browser': 'Browser',
|
||||
'contextRail.configure.open': 'Configure panels',
|
||||
'contextRail.configure.dialogTitle': 'Rail panels',
|
||||
'contextRail.configure.dialogDescription': 'Choose which panels the rail shows. Hidden panels keep their data and stay reachable from the command palette.',
|
||||
'contextRail.configure.showAll': 'Show all',
|
||||
'contextRail.configure.noneWarning': 'All panels are hidden.',
|
||||
'contextRail.aria.rail': 'Panel surfaces',
|
||||
'contextPanel.editorEmpty.title': 'No file open',
|
||||
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
|
||||
@@ -1668,7 +1673,7 @@ export const dict = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Failed to read plan file',
|
||||
'inlineComment.range.lines': 'Lines {start}-{end}',
|
||||
'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)',
|
||||
'inlineComment.input.placeholder': 'Add a comment... ({shortcut} to save)',
|
||||
'inlineComment.input.placeholderShort': 'Add a comment...',
|
||||
'inlineComment.actions.cancel': 'Cancel',
|
||||
'inlineComment.actions.save': 'Save',
|
||||
@@ -1858,22 +1863,18 @@ export const dict = {
|
||||
'helpDialog.item.focusChatInput': 'Focus Chat Input',
|
||||
'helpDialog.item.togglePromptNavigator': 'Toggle Prompt Navigator',
|
||||
'helpDialog.item.abortActiveRun': 'Abort active run (double press)',
|
||||
'helpDialog.item.toggleRightSidebar': 'Toggle context panel',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Open Git surface',
|
||||
'helpDialog.item.openRightSidebarFilesTab': 'Open Files surface',
|
||||
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
|
||||
'helpDialog.item.switchSessionTab': 'Switch Session Tab',
|
||||
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
|
||||
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
|
||||
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
|
||||
'helpDialog.item.openSettings': 'Open Settings',
|
||||
'helpDialog.keyCombiner.or': 'or',
|
||||
'helpDialog.proTips.title': 'Pro Tips:',
|
||||
'helpDialog.proTips.commandPalette': 'Use Command Palette ({shortcut}) to quickly access all actions',
|
||||
'helpDialog.proTips.recentSessions': 'The 5 most recent sessions appear in the Command Palette',
|
||||
'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions',
|
||||
'helpDialog.proTips.leaderSequences': 'Two-step shortcuts: press the first combo, then the second key — Esc cancels',
|
||||
'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': 'Toggle right sidebar',
|
||||
'header.actions.openAppMenu': 'OpenChamber menu',
|
||||
@@ -2083,7 +2084,6 @@ export const dict = {
|
||||
'chat.statusRow.tasksTitle': 'Tasks',
|
||||
'chat.statusRow.modelStatus': '{model} is {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active} active · {left} left',
|
||||
'chat.statusRow.aborted': 'Aborted',
|
||||
'chat.revertIndicator.redo': 'Redo',
|
||||
'chat.revertIndicator.redoAria': 'Redo — restore reverted messages',
|
||||
'chat.revertPopover.title': 'Reverted',
|
||||
@@ -2161,7 +2161,8 @@ export const dict = {
|
||||
'chat.btw.promoteAria': 'Keep as a separate session',
|
||||
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
|
||||
'chat.container.sessionLoadError.title': 'Session could not be loaded',
|
||||
'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.',
|
||||
'chat.container.sessionLoadError.description': 'The conversation could not be fetched — the server may be offline or unreachable. Nothing is lost; retry once it is back.',
|
||||
'chat.container.sessionLoadError.authDescription': 'Your session expired, so the server refused the request. Log in and the conversation will load.',
|
||||
'chat.container.sessionLoadError.retry': 'Try again',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
|
||||
@@ -2204,10 +2205,8 @@ export const dict = {
|
||||
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
|
||||
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
|
||||
'chat.textSelection.comment.attach': 'Attach',
|
||||
'chat.textSelection.actions.newSession': 'New session',
|
||||
'chat.textSelection.actions.addToNotes': 'Add to notes',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Add to current chat',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection',
|
||||
'chat.textSelection.title.saveInsightToNotes': 'Save selected text to notes',
|
||||
'chat.messageBody.actions.revertAria': 'Revert to this message',
|
||||
'chat.messageBody.actions.revert': 'Revert from here',
|
||||
@@ -2483,6 +2482,15 @@ export const dict = {
|
||||
'commandPalette.item.toggleSidebar': 'Toggle Sidebar',
|
||||
'commandPalette.item.showContextUsage': 'Show Context Usage',
|
||||
'commandPalette.item.toggleTerminal': 'Toggle Terminal',
|
||||
'commandPalette.item.cycleTheme': 'Cycle theme',
|
||||
'commandPalette.item.showOpenCodeStatus': 'Show OpenCode status',
|
||||
'commandPalette.item.toggleMemoryDebug': 'Toggle memory debug panel',
|
||||
'commandPalette.item.pinSession': 'Pin or unpin session',
|
||||
'commandPalette.item.copySessionId': 'Copy session ID',
|
||||
'commandPalette.item.openMultiRun': 'Open multi-run launcher',
|
||||
'commandPalette.item.openArchive': 'Open archived sessions',
|
||||
'commandPalette.item.openNotes': 'Open notes surface',
|
||||
'commandPalette.item.openTodos': 'Open todos surface',
|
||||
'commandPalette.item.openSettings': 'Open Settings...',
|
||||
'commandPalette.session.untitled': 'Untitled Session',
|
||||
'openCodeStatusDialog.title': 'OpenCode Status',
|
||||
@@ -2697,6 +2705,9 @@ export const dict = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'Passkey sign-in was canceled.',
|
||||
'sessionAuth.error.enterPasswordForPasskey': 'Enter your password to add a passkey.',
|
||||
'sessionAuth.locked.tunnelTitle': 'Tunnel access required',
|
||||
'sessionAuth.expired.banner': 'Your session expired — log in to continue.',
|
||||
'sessionAuth.expired.loginAction': 'Log in',
|
||||
'sessionAuth.expired.sendBlocked': 'Session expired — log in to send messages.',
|
||||
'sessionAuth.locked.unlockTitle': 'Unlock OpenChamber',
|
||||
'sessionAuth.locked.tunnelDescription': 'Open this tunnel using the one-time connect link from the desktop app.',
|
||||
'sessionAuth.locked.passwordDescription': 'This session is password-protected.',
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinación ya está usada por otro atajo. ¿Sobrescribir y limpiar esa otra asignación?",
|
||||
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pulsa las teclas...",
|
||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Todavía se guarda.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Aun así, puedes guardarlo.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir a línea (editor de archivos)",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
|
||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada",
|
||||
@@ -1110,18 +1110,20 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal",
|
||||
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Cambiar pestaña de sesión",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sesión anterior",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Sesión siguiente",
|
||||
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renombrar sesión actual",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprobación automática",
|
||||
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de sesión",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar panel de plan de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar u ocultar menú de servicios",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Cambiar pestaña de servicios",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Cambiar tema",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Cambiar agente",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Siguiente modelo favorito",
|
||||
@@ -1130,6 +1132,27 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir línea de tiempo de conversación",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar u ocultar navegador de prompts",
|
||||
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta secuencia comparte un prefijo contextual con {action}. Cuando su contexto está activo, esa acción tiene prioridad.",
|
||||
"settings.openchamber.keyboardShortcuts.category.session": "Controles de sesión",
|
||||
"settings.openchamber.keyboardShortcuts.category.models": "Modelos y agentes",
|
||||
"settings.openchamber.keyboardShortcuts.category.panels": "Paneles y herramientas",
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegación",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Aplicación",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas, con un máximo de tres teclas cada una. Tras la primera, espere hasta 3 segundos por una segunda combinación. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Sin asignar",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Esto entra en conflicto con la secuencia usada por {action}. Elija otra combinación.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinación entra en conflicto con un atajo integrado, que no se puede reemplazar.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz",
|
||||
"settings.projects.sidebar.total": "Total {count}",
|
||||
"settings.projects.sidebar.actions.addProject": "Añadir proyecto",
|
||||
"settings.projects.page.empty.noProjects": "No hay proyectos disponibles.",
|
||||
|
||||
@@ -1140,6 +1140,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Vista previa",
|
||||
"contextPanel.mode.browser": "Navegador",
|
||||
"contextRail.configure.open": "Configurar paneles",
|
||||
"contextRail.configure.dialogTitle": "Paneles de la barra",
|
||||
"contextRail.configure.dialogDescription": "Elige qué paneles muestra la barra. Los paneles ocultos conservan sus datos y siguen accesibles desde la paleta de comandos.",
|
||||
"contextRail.configure.showAll": "Mostrar todos",
|
||||
"contextRail.configure.noneWarning": "Todos los paneles están ocultos.",
|
||||
"contextRail.aria.rail": "Superficies del panel",
|
||||
"contextPanel.editorEmpty.title": "Ningún archivo abierto",
|
||||
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
|
||||
@@ -1646,7 +1651,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"rightSidebar.contextNotesTodo.toast.planImported": "Plan importado",
|
||||
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "No se pudo leer el archivo del plan",
|
||||
"inlineComment.range.lines": "Líneas {start}-{end}",
|
||||
"inlineComment.input.placeholder": "Añadir un comentario... (Cmd+Enter para guardar)",
|
||||
"inlineComment.input.placeholder": "Añadir un comentario... ({shortcut} para guardar)",
|
||||
"inlineComment.input.placeholderShort": "Añadir un comentario...",
|
||||
"inlineComment.actions.cancel": "Cancelar",
|
||||
"inlineComment.actions.save": "Guardar",
|
||||
@@ -1836,22 +1841,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.focusChatInput": "Enfocar entrada de chat",
|
||||
"helpDialog.item.togglePromptNavigator": "Mostrar u ocultar navegador de prompts",
|
||||
"helpDialog.item.abortActiveRun": "Detener ejecución activa (doble presionar)",
|
||||
"helpDialog.item.toggleRightSidebar": 'Alternar panel de contexto',
|
||||
"helpDialog.item.openRightSidebarGitTab": 'Abrir superficie de Git',
|
||||
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superficie de archivos',
|
||||
"helpDialog.item.toggleTerminalDock": "Mostrar u ocultar dock de terminal",
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
|
||||
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
|
||||
"helpDialog.item.switchSessionTab": "Cambiar pestaña de sesión",
|
||||
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
|
||||
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
|
||||
"helpDialog.item.openSettings": "Abrir configuración",
|
||||
"helpDialog.keyCombiner.or": "o",
|
||||
"helpDialog.proTips.title": "Consejos:",
|
||||
"helpDialog.proTips.commandPalette": "Usa la paleta de comandos ({shortcut}) para acceder rápidamente a todas las acciones",
|
||||
"helpDialog.proTips.recentSessions": "Las cinco sesiones más recientes aparecen en la paleta de comandos",
|
||||
"helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones",
|
||||
"helpDialog.proTips.leaderSequences": "Atajos en dos pasos: pulsa la combinación y luego la segunda tecla; Esc cancela",
|
||||
"header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})",
|
||||
"header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha",
|
||||
"header.actions.openAppMenu": "Menú de OpenChamber",
|
||||
@@ -2061,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.statusRow.tasksTitle": "Tareas",
|
||||
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||
"chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes",
|
||||
"chat.statusRow.aborted": "Interrumpido",
|
||||
"chat.revertIndicator.redo": "Rehacer",
|
||||
"chat.revertIndicator.redoAria": "Rehacer — restaurar mensajes revertidos",
|
||||
"chat.revertPopover.title": "Revertidos",
|
||||
@@ -2139,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
|
||||
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
|
||||
"chat.container.sessionLoadError.title": "No se pudo cargar la sesión",
|
||||
"chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.",
|
||||
"chat.container.sessionLoadError.description": "No se pudo obtener la conversación: puede que el servidor esté apagado o inaccesible. No se perdió nada; reintenta cuando vuelva.",
|
||||
"chat.container.sessionLoadError.authDescription": "Tu sesión expiró, por lo que el servidor rechazó la solicitud. Inicia sesión y la conversación se cargará.",
|
||||
"chat.container.sessionLoadError.retry": "Reintentar",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
|
||||
@@ -2182,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
|
||||
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
|
||||
"chat.textSelection.comment.attach": "Adjuntar",
|
||||
"chat.textSelection.actions.newSession": "Nueva sesión",
|
||||
"chat.textSelection.actions.addToNotes": "Añadir a las notas",
|
||||
"chat.textSelection.title.addToCurrentChat": "Añadir al chat actual",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección",
|
||||
"chat.textSelection.title.saveInsightToNotes": "Guardar texto seleccionado en notas",
|
||||
"chat.messageBody.actions.revertAria": "Volver a este mensaje",
|
||||
"chat.messageBody.actions.revert": "Volver desde aquí",
|
||||
@@ -2449,6 +2448,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"commandPalette.item.toggleSidebar": "Mostrar u ocultar barra lateral",
|
||||
"commandPalette.item.showContextUsage": "Mostrar uso del contexto",
|
||||
"commandPalette.item.toggleTerminal": "Mostrar u ocultar terminal",
|
||||
"commandPalette.item.cycleTheme": "Cambiar tema",
|
||||
"commandPalette.item.showOpenCodeStatus": "Mostrar estado de OpenCode",
|
||||
"commandPalette.item.toggleMemoryDebug": "Alternar panel de depuración de memoria",
|
||||
"commandPalette.item.pinSession": "Anclar o desanclar sesión",
|
||||
"commandPalette.item.copySessionId": "Copiar ID de sesión",
|
||||
"commandPalette.item.openMultiRun": "Abrir lanzador multi-run",
|
||||
"commandPalette.item.openArchive": "Abrir sesiones archivadas",
|
||||
"commandPalette.item.openNotes": "Abrir panel de notas",
|
||||
"commandPalette.item.openTodos": "Abrir panel de tareas",
|
||||
"commandPalette.item.openSettings": "Abrir configuración...",
|
||||
"commandPalette.session.untitled": "Sesión sin título",
|
||||
"openCodeStatusDialog.title": "Estado de OpenCode",
|
||||
@@ -2663,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessionAuth.error.passkeySignInCanceled": "El inicio de sesión con clave de paso se canceló.",
|
||||
"sessionAuth.error.enterPasswordForPasskey": "Introduce tu contraseña para añadir una clave de paso.",
|
||||
"sessionAuth.locked.tunnelTitle": "Se requiere acceso por túnel",
|
||||
"sessionAuth.expired.banner": "Tu sesión expiró: inicia sesión para continuar.",
|
||||
"sessionAuth.expired.loginAction": "Iniciar sesión",
|
||||
"sessionAuth.expired.sendBlocked": "Sesión expirada: inicia sesión para enviar mensajes.",
|
||||
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
|
||||
"sessionAuth.locked.tunnelDescription": "Abre este túnel usando el enlace de conexión única desde la aplicación de escritorio.",
|
||||
"sessionAuth.locked.passwordDescription": "Esta sesión está protegida con contraseña.",
|
||||
|
||||
@@ -1019,7 +1019,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ce combo est déjà utilisé par un autre raccourci. Écraser et effacer cet autre mappage ?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Appuyez sur les touches...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Il est toujours sauvegardé.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Vous pouvez tout de même l’enregistrer.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Aller à la ligne (éditeur de fichiers)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Ouvrir la palette de commandes',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point',
|
||||
@@ -1028,18 +1028,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Basculer l’onglet de session',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Session précédente',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Session suivante',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Renommer la session actuelle',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Basculer l’approbation automatique',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer l’onglet de session',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Ouvrir les raccourcis clavier',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Basculer le panneau contextuel du plan',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Basculer le menu des services',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Onglet Services vélo',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thème du cycle',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent de cycle',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Faire avancer le modèle favori',
|
||||
@@ -1048,6 +1050,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Développer l\'entrée',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Chronologie de la conversation ouverte',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Afficher ou masquer le navigateur de prompts',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Cette séquence partage un préfixe contextuel avec {action}. Lorsque son contexte est actif, cette action est prioritaire.',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'Commandes de session',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'Modèles et agents',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'Panneaux et outils',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum, avec trois touches au plus chacune. Après la première, attendez jusqu’à 3 secondes une seconde combinaison. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Non attribué',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Cela entre en conflit avec la séquence utilisée par {action}. Choisissez une autre combinaison.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Cette combinaison entre en conflit avec un raccourci intégré qui ne peut pas être remplacé.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale',
|
||||
'settings.projects.sidebar.total': 'Total {count}',
|
||||
'settings.projects.sidebar.actions.addProject': 'Ajouter un projet',
|
||||
'settings.projects.page.empty.noProjects': 'Aucun projet disponible.',
|
||||
|
||||
@@ -959,6 +959,11 @@ export const dict = {
|
||||
'contextPanel.mode.context': 'Contexte',
|
||||
'contextPanel.mode.preview': 'Aperçu',
|
||||
'contextPanel.mode.browser': 'Navigateur',
|
||||
'contextRail.configure.open': 'Configurer les panneaux',
|
||||
'contextRail.configure.dialogTitle': 'Panneaux de la barre',
|
||||
'contextRail.configure.dialogDescription': 'Choisissez les panneaux affichés par la barre. Les panneaux masqués conservent leurs données et restent accessibles via la palette de commandes.',
|
||||
'contextRail.configure.showAll': 'Tout afficher',
|
||||
'contextRail.configure.noneWarning': 'Tous les panneaux sont masqués.',
|
||||
'contextRail.aria.rail': 'Surfaces du panneau',
|
||||
'contextPanel.editorEmpty.title': 'Aucun fichier ouvert',
|
||||
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.',
|
||||
@@ -1433,7 +1438,7 @@ export const dict = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Échec de la lecture du fichier de plan',
|
||||
'inlineComment.range.lines': 'Lignes {start}-{end}',
|
||||
'inlineComment.input.placeholder': 'Ajouter un commentaire... (Cmd+Entrée pour enregistrer)',
|
||||
'inlineComment.input.placeholder': 'Ajouter un commentaire... ({shortcut} pour enregistrer)',
|
||||
'inlineComment.actions.cancel': 'Annuler',
|
||||
'inlineComment.actions.save': 'Sauvegarder',
|
||||
'inlineComment.actions.comment': 'Commentaire',
|
||||
@@ -1616,22 +1621,18 @@ export const dict = {
|
||||
'helpDialog.item.focusChatInput': 'Concentration sur la saisie du chat',
|
||||
'helpDialog.item.togglePromptNavigator': 'Afficher ou masquer le navigateur de prompts',
|
||||
'helpDialog.item.abortActiveRun': 'Abandonner l’exécution active (double pression)',
|
||||
'helpDialog.item.toggleRightSidebar': 'Afficher/masquer le panneau de contexte',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Ouvrir la surface Git',
|
||||
'helpDialog.item.openRightSidebarFilesTab': 'Ouvrir la surface Fichiers',
|
||||
'helpDialog.item.toggleTerminalDock': 'Basculer la station d\'accueil du terminal',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
|
||||
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
|
||||
'helpDialog.item.switchSessionTab': 'Basculer l’onglet de session',
|
||||
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
|
||||
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
|
||||
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
|
||||
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
|
||||
'helpDialog.keyCombiner.or': 'ou',
|
||||
'helpDialog.proTips.title': 'Conseils de pro :',
|
||||
'helpDialog.proTips.commandPalette': 'Utilisez la palette de commandes ({shortcut}) pour accéder rapidement à toutes les actions',
|
||||
'helpDialog.proTips.recentSessions': 'Les 5 sessions les plus récentes apparaissent dans la palette de commandes',
|
||||
'helpDialog.proTips.themeCycling': 'Le cyclisme thématique mémorise vos préférences au fil des sessions',
|
||||
'helpDialog.proTips.leaderSequences': 'Raccourcis en deux temps : appuyez sur la combinaison, puis sur la seconde touche — Échap annule',
|
||||
'header.actions.rightSidebarWithShortcut': 'Barre latérale droite ({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': 'Basculer la barre latérale droite',
|
||||
'header.actions.openAppMenu': 'Menu de OpenChamber',
|
||||
@@ -1825,7 +1826,6 @@ export const dict = {
|
||||
'chat.statusRow.tasksTitle': 'Tâches',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche',
|
||||
'chat.statusRow.aborted': 'Avorté',
|
||||
'chat.revertIndicator.redo': 'Refaire',
|
||||
'chat.revertIndicator.redoAria': 'Rétablir : restaurer les messages annulés',
|
||||
'chat.revertPopover.title': 'Rétabli',
|
||||
@@ -1892,7 +1892,8 @@ export const dict = {
|
||||
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.',
|
||||
'chat.container.sessionLoadError.title': 'Impossible de charger la session',
|
||||
'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.',
|
||||
'chat.container.sessionLoadError.description': 'Impossible de récupérer la conversation — le serveur est peut-être hors ligne ou injoignable. Rien n\'est perdu ; réessayez quand il sera de retour.',
|
||||
'chat.container.sessionLoadError.authDescription': 'Votre session a expiré, le serveur a donc refusé la requête. Connectez-vous et la conversation se chargera.',
|
||||
'chat.container.sessionLoadError.retry': 'Réessayer',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
|
||||
@@ -1931,10 +1932,8 @@ export const dict = {
|
||||
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
|
||||
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
|
||||
'chat.textSelection.comment.attach': 'Joindre',
|
||||
'chat.textSelection.actions.newSession': 'Nouvelle session',
|
||||
'chat.textSelection.actions.addToNotes': 'Ajouter aux notes',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection',
|
||||
'chat.textSelection.title.saveInsightToNotes': 'Enregistrer le texte sélectionné dans les notes',
|
||||
'chat.messageBody.actions.revertAria': 'Revenir à ce message',
|
||||
'chat.messageBody.actions.revert': 'Revenir à partir d\'ici',
|
||||
@@ -2187,6 +2186,15 @@ export const dict = {
|
||||
'commandPalette.item.toggleSidebar': 'Basculer la barre latérale',
|
||||
'commandPalette.item.showContextUsage': 'Afficher l\'utilisation du contexte',
|
||||
'commandPalette.item.toggleTerminal': 'Basculer le terminal',
|
||||
'commandPalette.item.cycleTheme': 'Changer de thème',
|
||||
'commandPalette.item.showOpenCodeStatus': 'Afficher le statut OpenCode',
|
||||
'commandPalette.item.toggleMemoryDebug': 'Basculer le panneau de débogage mémoire',
|
||||
'commandPalette.item.pinSession': 'Épingler ou désépingler la session',
|
||||
'commandPalette.item.copySessionId': 'Copier l\'ID de session',
|
||||
'commandPalette.item.openMultiRun': 'Ouvrir le lanceur multi-run',
|
||||
'commandPalette.item.openArchive': 'Ouvrir les sessions archivées',
|
||||
'commandPalette.item.openNotes': 'Ouvrir le panneau de notes',
|
||||
'commandPalette.item.openTodos': 'Ouvrir le panneau de tâches',
|
||||
'commandPalette.item.openSettings': 'Ouvrez les paramètres...',
|
||||
'commandPalette.session.untitled': 'Session sans titre',
|
||||
'openCodeStatusDialog.title': 'Statut OpenCode',
|
||||
@@ -2401,6 +2409,9 @@ export const dict = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'La connexion par mot de passe a été annulée.',
|
||||
'sessionAuth.error.enterPasswordForPasskey': 'Entrez votre mot de passe pour ajouter un mot de passe.',
|
||||
'sessionAuth.locked.tunnelTitle': 'Accès au tunnel requis',
|
||||
'sessionAuth.expired.banner': 'Votre session a expiré — connectez-vous pour continuer.',
|
||||
'sessionAuth.expired.loginAction': 'Se connecter',
|
||||
'sessionAuth.expired.sendBlocked': 'Session expirée — connectez-vous pour envoyer des messages.',
|
||||
'sessionAuth.locked.unlockTitle': 'Débloquez OpenChamber',
|
||||
'sessionAuth.locked.tunnelDescription': 'Ouvrez ce tunnel à l\'aide du lien de connexion unique depuis l\'application de bureau.',
|
||||
'sessionAuth.locked.passwordDescription': 'Cette session est protégée par mot de passe.',
|
||||
|
||||
@@ -1134,7 +1134,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'このキーコンボは別のショートカットで既に使用されています。上書きしてそのマッピングをクリアしますか?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性があります。それでも保存されます。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス',
|
||||
@@ -1143,18 +1143,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'セッションタブを切り替え',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '前のセッション',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '次のセッション',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '現在のセッション名を変更',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '権限の自動承認を切り替え',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '計画コンテキストパネルの切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'サービスメニューの切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'サービスタブを順に切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'テーマを順に切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent を順に切替',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'お気に入りモデルを次へ',
|
||||
@@ -1163,6 +1165,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'モデルセレクターを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '会話タイムラインを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'プロンプトナビゲーターの表示切替',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'このシーケンスは {action} とコンテキスト依存のプレフィックスを共有しています。そのコンテキストが有効な間は、この操作が優先されます。',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'セッション操作',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'モデルとエージェント',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'パネルとツール',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'ナビゲーション',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'アプリケーション',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力でき、各組み合わせは最大3キーです。最初の組み合わせの後、2つ目の組み合わせを最大3秒待ちます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未割り当て',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action} のシーケンスと競合しています。別の組み合わせを選択してください。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'この組み合わせは組み込みショートカットと競合しています。組み込みショートカットは置き換えられません。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力',
|
||||
'settings.projects.sidebar.total': '合計 {count}',
|
||||
'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加',
|
||||
'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。',
|
||||
|
||||
@@ -1136,6 +1136,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.context': 'コンテキスト',
|
||||
'contextPanel.mode.preview': 'プレビュー',
|
||||
'contextPanel.mode.browser': 'ブラウザ',
|
||||
'contextRail.configure.open': 'パネルを設定',
|
||||
'contextRail.configure.dialogTitle': 'レールのパネル',
|
||||
'contextRail.configure.dialogDescription': 'レールに表示するパネルを選択します。非表示のパネルもデータは保持され、コマンドパレットから引き続き開けます。',
|
||||
'contextRail.configure.showAll': 'すべて表示',
|
||||
'contextRail.configure.noneWarning': 'すべてのパネルが非表示です。',
|
||||
'contextRail.aria.rail': 'パネルサーフェス',
|
||||
'contextPanel.editorEmpty.title': 'ファイルが開かれていません',
|
||||
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
|
||||
@@ -1664,7 +1669,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '計画ファイルの読み込みに失敗しました',
|
||||
'inlineComment.range.lines': '{start}行目~{end}行目',
|
||||
'inlineComment.input.placeholder': 'コメントを追加...(Cmd+Enterで保存)',
|
||||
'inlineComment.input.placeholder': 'コメントを追加...({shortcut}で保存)',
|
||||
'inlineComment.input.placeholderShort': 'コメントを追加...',
|
||||
'inlineComment.actions.cancel': 'キャンセル',
|
||||
'inlineComment.actions.save': '保存',
|
||||
@@ -1854,22 +1859,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.focusChatInput': 'チャット入力にフォーカス',
|
||||
'helpDialog.item.togglePromptNavigator': 'プロンプトナビゲーターの表示切替',
|
||||
'helpDialog.item.abortActiveRun': 'アクティブな実行を中止(ダブルプレス)',
|
||||
'helpDialog.item.toggleRightSidebar': 'コンテキストパネルの表示切替',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Git サーフェスを開く',
|
||||
'helpDialog.item.openRightSidebarFilesTab': 'ファイルサーフェスを開く',
|
||||
'helpDialog.item.toggleTerminalDock': 'ターミナルドックの切り替え',
|
||||
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
|
||||
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
|
||||
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
|
||||
'helpDialog.item.switchSessionTab': 'セッションタブを切り替え',
|
||||
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
|
||||
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
|
||||
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
|
||||
'helpDialog.item.openSettings': '設定を開く',
|
||||
'helpDialog.keyCombiner.or': 'または',
|
||||
'helpDialog.proTips.title': 'プロのヒント:',
|
||||
'helpDialog.proTips.commandPalette': 'コマンドパレット({shortcut})を使うとすべての操作にすばやくアクセスできます',
|
||||
'helpDialog.proTips.recentSessions': '最近の5つのセッションがコマンドパレットに表示されます',
|
||||
'helpDialog.proTips.themeCycling': 'テーマの切り替えはセッション間で設定が記憶されます',
|
||||
'helpDialog.proTips.leaderSequences': '2段階ショートカット:組み合わせを押してから2つ目のキーを押します(Escで取消)',
|
||||
'header.actions.rightSidebarWithShortcut': '右サイドバー({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': '右サイドバーの切り替え',
|
||||
'header.actions.openAppMenu': 'OpenChamberメニュー',
|
||||
@@ -2079,7 +2080,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.statusRow.tasksTitle': 'タスク',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り',
|
||||
'chat.statusRow.aborted': '中止されました',
|
||||
'chat.revertIndicator.redo': 'やり直し',
|
||||
'chat.revertIndicator.redoAria': 'やり直し — 元に戻したメッセージを復元',
|
||||
'chat.revertPopover.title': '元に戻しました',
|
||||
@@ -2157,7 +2157,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。',
|
||||
'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした',
|
||||
'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。',
|
||||
'chat.container.sessionLoadError.description': '会話を取得できませんでした。サーバーが停止中か到達できない可能性があります。データは失われていません。復旧後に再試行してください。',
|
||||
'chat.container.sessionLoadError.authDescription': 'セッションの有効期限が切れたため、サーバーがリクエストを拒否しました。ログインすると会話が読み込まれます。',
|
||||
'chat.container.sessionLoadError.retry': '再試行',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
|
||||
@@ -2200,10 +2201,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
|
||||
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
|
||||
'chat.textSelection.comment.attach': '添付',
|
||||
'chat.textSelection.actions.newSession': '新しいセッション',
|
||||
'chat.textSelection.actions.addToNotes': 'メモに追加',
|
||||
'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加',
|
||||
'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成',
|
||||
'chat.textSelection.title.saveInsightToNotes': '選択テキストをメモに保存',
|
||||
'chat.messageBody.actions.revertAria': 'このメッセージに戻す',
|
||||
'chat.messageBody.actions.revert': 'ここから元に戻す',
|
||||
@@ -2482,6 +2481,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'commandPalette.item.toggleSidebar': 'サイドバーの切り替え',
|
||||
'commandPalette.item.showContextUsage': 'コンテキスト使用量を表示',
|
||||
'commandPalette.item.toggleTerminal': 'ターミナルの切り替え',
|
||||
'commandPalette.item.cycleTheme': 'テーマを順に切替',
|
||||
'commandPalette.item.showOpenCodeStatus': 'OpenCode のステータスを表示',
|
||||
'commandPalette.item.toggleMemoryDebug': 'メモリデバッグパネルの切替',
|
||||
'commandPalette.item.pinSession': 'セッションをピン留め/解除',
|
||||
'commandPalette.item.copySessionId': 'セッションIDをコピー',
|
||||
'commandPalette.item.openMultiRun': 'マルチラン起動画面を開く',
|
||||
'commandPalette.item.openArchive': 'アーカイブ済みセッションを開く',
|
||||
'commandPalette.item.openNotes': 'ノートパネルを開く',
|
||||
'commandPalette.item.openTodos': 'ToDoパネルを開く',
|
||||
'commandPalette.item.openSettings': '設定を開く...',
|
||||
'commandPalette.session.untitled': '無題のセッション',
|
||||
'openCodeStatusDialog.title': 'OpenCodeステータス',
|
||||
@@ -2696,6 +2704,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。',
|
||||
'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。',
|
||||
'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要',
|
||||
'sessionAuth.expired.banner': 'セッションの有効期限が切れました。続行するにはログインしてください。',
|
||||
'sessionAuth.expired.loginAction': 'ログイン',
|
||||
'sessionAuth.expired.sendBlocked': 'セッションが切れています。メッセージを送るにはログインしてください。',
|
||||
'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除',
|
||||
'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。',
|
||||
'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。',
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': '이 조합은 이미 다른 단축키에서 사용 중입니다. 덮어쓰고 기존 매핑을 지울까요?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있습니다. 그래도 저장됩니다.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '줄로 이동(파일 편집기)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스',
|
||||
@@ -1110,18 +1110,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '세션 탭 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '이전 세션',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '다음 세션',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '현재 세션 이름 바꾸기',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '권한 자동 승인 전환',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '계획 컨텍스트 패널 토글',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '서비스 메뉴 토글',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '서비스 탭 순환',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '테마 순환',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '에이전트 순환',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '즐겨찾기 모델 앞으로 순환',
|
||||
@@ -1130,6 +1132,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '입력 확장',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '대화 타임라인 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '프롬프트 탐색기 표시/숨기기',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '이 시퀀스는 {action}과 컨텍스트 접두사를 공유합니다. 해당 컨텍스트가 활성화된 동안에는 그 동작이 우선합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': '세션 제어',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': '모델 및 에이전트',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': '패널 및 도구',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '탐색',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '애플리케이션',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 각 조합에는 최대 세 개의 키를 사용할 수 있습니다. 첫 번째 조합 뒤에는 두 번째 조합을 위해 최대 3초 동안 기다립니다. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '할당되지 않음',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action}에서 사용하는 시퀀스와 충돌합니다. 다른 조합을 선택하세요.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '이 조합은 바꿀 수 없는 기본 제공 단축키와 충돌합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력',
|
||||
'settings.projects.sidebar.total': '총 {count}개',
|
||||
'settings.projects.sidebar.actions.addProject': '프로젝트 추가',
|
||||
'settings.projects.page.empty.noProjects': '사용 가능한 프로젝트가 없습니다.',
|
||||
|
||||
@@ -1140,6 +1140,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.context': '컨텍스트',
|
||||
'contextPanel.mode.preview': '미리보기',
|
||||
'contextPanel.mode.browser': '브라우저',
|
||||
'contextRail.configure.open': '패널 구성',
|
||||
'contextRail.configure.dialogTitle': '레일 패널',
|
||||
'contextRail.configure.dialogDescription': '레일에 표시할 패널을 선택하세요. 숨긴 패널의 데이터는 유지되며 명령 팔레트에서 계속 열 수 있습니다.',
|
||||
'contextRail.configure.showAll': '모두 표시',
|
||||
'contextRail.configure.noneWarning': '모든 패널이 숨겨져 있습니다.',
|
||||
'contextRail.aria.rail': '패널 서피스',
|
||||
'contextPanel.editorEmpty.title': '열린 파일 없음',
|
||||
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
|
||||
@@ -1670,7 +1675,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '플랜 파일 읽기 실패',
|
||||
'inlineComment.range.lines': '줄 {start}-{end}',
|
||||
'inlineComment.input.placeholder': '댓글 추가… (Cmd+Enter로 저장)',
|
||||
'inlineComment.input.placeholder': '댓글 추가… ({shortcut}로 저장)',
|
||||
'inlineComment.input.placeholderShort': '댓글 추가…',
|
||||
'inlineComment.actions.cancel': '취소',
|
||||
'inlineComment.actions.save': '저장',
|
||||
@@ -1860,22 +1865,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.focusChatInput': '채팅 입력창으로 포커스 이동',
|
||||
'helpDialog.item.togglePromptNavigator': '프롬프트 탐색기 표시/숨기기',
|
||||
'helpDialog.item.abortActiveRun': '활성 실행 중단(두 번 누르기)',
|
||||
'helpDialog.item.toggleRightSidebar': '컨텍스트 패널 표시 전환',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Git 서피스 열기',
|
||||
'helpDialog.item.openRightSidebarFilesTab': '파일 서피스 열기',
|
||||
'helpDialog.item.toggleTerminalDock': '터미널 독 전환',
|
||||
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
|
||||
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
|
||||
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
|
||||
'helpDialog.item.switchSessionTab': '세션 탭 전환',
|
||||
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
|
||||
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
|
||||
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
|
||||
'helpDialog.item.openSettings': '설정 열기',
|
||||
'helpDialog.keyCombiner.or': '또는',
|
||||
'helpDialog.proTips.title': '팁:',
|
||||
'helpDialog.proTips.commandPalette': '명령 팔레트({shortcut})로 모든 작업에 빠르게 접근하세요',
|
||||
'helpDialog.proTips.recentSessions': '최근 세션 5개가 명령 팔레트에 표시됩니다',
|
||||
'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다',
|
||||
'helpDialog.proTips.leaderSequences': '2단계 단축키: 조합을 누른 뒤 두 번째 키를 누르세요 (Esc로 취소)',
|
||||
'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글',
|
||||
'header.actions.openAppMenu': 'OpenChamber 메뉴',
|
||||
@@ -2085,7 +2086,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.statusRow.tasksTitle': '작업',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음',
|
||||
'chat.statusRow.aborted': '중단됨',
|
||||
'chat.revertIndicator.redo': '다시 실행',
|
||||
'chat.revertIndicator.redoAria': '다시 실행 — 되돌린 메시지 복원',
|
||||
'chat.revertPopover.title': '되돌림',
|
||||
@@ -2163,7 +2163,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
|
||||
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
|
||||
'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다',
|
||||
'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.',
|
||||
'chat.container.sessionLoadError.description': '대화를 가져오지 못했습니다. 서버가 꺼져 있거나 연결할 수 없는 상태일 수 있습니다. 데이터는 사라지지 않았으니 복구되면 다시 시도하세요.',
|
||||
'chat.container.sessionLoadError.authDescription': '세션이 만료되어 서버가 요청을 거부했습니다. 로그인하면 대화가 로드됩니다.',
|
||||
'chat.container.sessionLoadError.retry': '다시 시도',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
|
||||
@@ -2206,10 +2207,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
|
||||
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
|
||||
'chat.textSelection.comment.attach': '첨부',
|
||||
'chat.textSelection.actions.newSession': '새 세션',
|
||||
'chat.textSelection.actions.addToNotes': '메모에 추가',
|
||||
'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가',
|
||||
'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성',
|
||||
'chat.textSelection.title.saveInsightToNotes': '선택한 텍스트를 메모에 저장',
|
||||
'chat.messageBody.actions.revertAria': '이 메시지로 되돌리기',
|
||||
'chat.messageBody.actions.revert': '여기부터 되돌리기',
|
||||
@@ -2483,6 +2482,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'commandPalette.item.toggleSidebar': '토글 사이드바',
|
||||
'commandPalette.item.showContextUsage': '컨텍스트 사용량 표시',
|
||||
'commandPalette.item.toggleTerminal': '토글 터미널',
|
||||
'commandPalette.item.cycleTheme': '테마 순환',
|
||||
'commandPalette.item.showOpenCodeStatus': 'OpenCode 상태 표시',
|
||||
'commandPalette.item.toggleMemoryDebug': '메모리 디버그 패널 토글',
|
||||
'commandPalette.item.pinSession': '세션 고정 또는 고정 해제',
|
||||
'commandPalette.item.copySessionId': '세션 ID 복사',
|
||||
'commandPalette.item.openMultiRun': '멀티 런 런처 열기',
|
||||
'commandPalette.item.openArchive': '보관된 세션 열기',
|
||||
'commandPalette.item.openNotes': '노트 패널 열기',
|
||||
'commandPalette.item.openTodos': '할 일 패널 열기',
|
||||
'commandPalette.item.openSettings': '설정... 열기',
|
||||
'commandPalette.session.untitled': '제목 없는 세션',
|
||||
'openCodeStatusDialog.title': 'OpenCode 상태',
|
||||
@@ -2697,6 +2705,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessionAuth.error.passkeySignInCanceled': '패스키 로그인이 취소되었습니다.',
|
||||
'sessionAuth.error.enterPasswordForPasskey': '패스키를 추가하려면 비밀번호를 입력하세요.',
|
||||
'sessionAuth.locked.tunnelTitle': '터널 접근 필요',
|
||||
'sessionAuth.expired.banner': '세션이 만료되었습니다. 계속하려면 로그인하세요.',
|
||||
'sessionAuth.expired.loginAction': '로그인',
|
||||
'sessionAuth.expired.sendBlocked': '세션이 만료되었습니다. 메시지를 보내려면 로그인하세요.',
|
||||
'sessionAuth.locked.unlockTitle': 'OpenChamber 잠금 해제',
|
||||
'sessionAuth.locked.tunnelDescription': '데스크톱 앱의 일회용 연결 링크로 이 터널을 여세요.',
|
||||
'sessionAuth.locked.passwordDescription': '이 세션은 비밀번호로 보호됩니다.',
|
||||
|
||||
@@ -818,25 +818,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Przełącz ulubiony model wstecz',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Otwórz wybór modelu',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Przełącz ulubiony model w przód',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Przełącz zakładkę usług',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Przełącz motyw',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Przełącz agenta',
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Rozwiń pole wprowadzania',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Poprzednia sesja',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Następna sesja',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Zmień nazwę bieżącej sesji',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Przełącz automatyczne zatwierdzanie',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Przełącz kartę sesji',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny',
|
||||
@@ -849,7 +851,29 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ta kombinacja jest już używana przez inny skrót. Nadpisać i wyczyścić to inne przypisanie?',
|
||||
'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe',
|
||||
'settings.openchamber.keyboardShortcuts.tooltip': 'Przechwyć nową kombinację klawiszy, zapisz ją, a przypisania zostaną natychmiast zaktualizowane.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Został jednak zapisany.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Nadal możesz go zapisać.',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Ta sekwencja współdzieli prefiks kontekstowy z działaniem {action}. Gdy jego kontekst jest aktywny, to działanie ma pierwszeństwo.',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'Sterowanie sesją',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'Modele i agenci',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'Panele i narzędzia',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Nawigacja',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Aplikacja',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy, po najwyżej trzy klawisze każda. Po pierwszej odczekaj do 3 sekund na drugą kombinację. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Nieprzypisany',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'To koliduje z sekwencją używaną przez {action}. Wybierz inną kombinację.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Ta kombinacja koliduje z wbudowanym skrótem, którego nie można zastąpić.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe',
|
||||
'settings.openchamber.opencodeCli.actions.browse': 'Przeglądaj',
|
||||
'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode',
|
||||
'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie OpenCode...',
|
||||
|
||||
@@ -776,7 +776,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.statusRow.tasksTitle': 'Zadania',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało',
|
||||
'chat.statusRow.aborted': 'Przerwane',
|
||||
'chat.revertIndicator.redo': 'Ponów',
|
||||
'chat.revertIndicator.redoAria': 'Ponów — przywróć cofnięte wiadomości',
|
||||
'chat.revertPopover.title': 'Cofnięte',
|
||||
@@ -853,7 +852,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
|
||||
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
|
||||
'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji',
|
||||
'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.',
|
||||
'chat.container.sessionLoadError.description': 'Nie udało się pobrać rozmowy — serwer może być wyłączony lub nieosiągalny. Nic nie przepadło; spróbuj ponownie, gdy wróci.',
|
||||
'chat.container.sessionLoadError.authDescription': 'Sesja wygasła, więc serwer odrzucił żądanie. Zaloguj się, a rozmowa się wczyta.',
|
||||
'chat.container.sessionLoadError.retry': 'Spróbuj ponownie',
|
||||
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
|
||||
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
|
||||
@@ -896,10 +896,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
|
||||
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
|
||||
'chat.textSelection.comment.attach': 'Załącz',
|
||||
'chat.textSelection.actions.newSession': 'Nowa sesja',
|
||||
'chat.textSelection.actions.addToNotes': 'Dodaj do notatek',
|
||||
'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu',
|
||||
'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem',
|
||||
'chat.textSelection.title.saveInsightToNotes': 'Zapisz zaznaczony tekst do notatek',
|
||||
'chat.messageBody.actions.revertAria': 'Cofnij do tej wiadomości',
|
||||
'chat.messageBody.actions.revert': 'Cofnij od tego miejsca',
|
||||
@@ -1452,6 +1450,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'commandPalette.item.showSessionSwitcher': 'Pokaż przełącznik sesji',
|
||||
'commandPalette.item.toggleSidebar': 'Przełącz panel boczny',
|
||||
'commandPalette.item.toggleTerminal': 'Przełącz terminal',
|
||||
'commandPalette.item.cycleTheme': 'Przełącz motyw',
|
||||
'commandPalette.item.showOpenCodeStatus': 'Pokaż status OpenCode',
|
||||
'commandPalette.item.toggleMemoryDebug': 'Przełącz panel debugowania pamięci',
|
||||
'commandPalette.item.pinSession': 'Przypnij lub odepnij sesję',
|
||||
'commandPalette.item.copySessionId': 'Kopiuj ID sesji',
|
||||
'commandPalette.item.openMultiRun': 'Otwórz panel multi-run',
|
||||
'commandPalette.item.openArchive': 'Otwórz zarchiwizowane sesje',
|
||||
'commandPalette.item.openNotes': 'Otwórz panel notatek',
|
||||
'commandPalette.item.openTodos': 'Otwórz panel zadań',
|
||||
'commandPalette.session.untitled': 'Nienazwana sesja',
|
||||
'commandPalette.title': 'Paleta poleceń',
|
||||
'contextPanel.actions.closePanel': 'Zamknij panel',
|
||||
@@ -1469,6 +1476,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.pr': 'Pull Request',
|
||||
'contextPanel.mode.preview': 'Podgląd',
|
||||
'contextPanel.mode.browser': 'Przeglądarka',
|
||||
'contextRail.configure.open': 'Konfiguruj panele',
|
||||
'contextRail.configure.dialogTitle': 'Panele paska',
|
||||
'contextRail.configure.dialogDescription': 'Wybierz, które panele pokazuje pasek. Ukryte panele zachowują dane i pozostają dostępne z palety poleceń.',
|
||||
'contextRail.configure.showAll': 'Pokaż wszystkie',
|
||||
'contextRail.configure.noneWarning': 'Wszystkie panele są ukryte.',
|
||||
'contextRail.aria.rail': 'Powierzchnie panelu',
|
||||
'contextPanel.editorEmpty.title': 'Brak otwartego pliku',
|
||||
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
|
||||
@@ -2452,7 +2464,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.createNewSession': 'Utwórz nową sesję',
|
||||
'helpDialog.item.createNewWorktreeDraft': 'Utwórz nowy szkic drzewa pracy',
|
||||
'helpDialog.item.cycleAgent': 'Przełącz agenta (w polu czatu)',
|
||||
'helpDialog.item.cycleServicesTab': 'Przełącz kartę usług',
|
||||
'helpDialog.item.cycleTheme': 'Przełącz motyw (Jasny → Ciemny → Systemowy)',
|
||||
'helpDialog.item.cycleThinkingVariant': 'Przełącz wariant myślenia (skrót globalny)',
|
||||
'helpDialog.item.focusChatInput': 'Ustaw fokus na polu czatu',
|
||||
@@ -2461,13 +2472,10 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.newWindow': 'Nowe okno (tylko desktop)',
|
||||
'helpDialog.item.openCommandPalette': 'Otwórz paletę poleceń',
|
||||
'helpDialog.item.openModelSelector': 'Otwórz selektor modeli',
|
||||
'helpDialog.item.openRightSidebarFilesTab': 'Otwórz powierzchnię plików',
|
||||
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
|
||||
'helpDialog.item.openSettings': 'Otwórz ustawienia',
|
||||
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
|
||||
'helpDialog.item.switchSessionTab': 'Przełącz kartę sesji',
|
||||
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
|
||||
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
|
||||
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
|
||||
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
|
||||
'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji',
|
||||
'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu',
|
||||
@@ -2476,7 +2484,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.keyCombiner.or': 'lub',
|
||||
'helpDialog.proTips.commandPalette': 'Użyj Palety poleceń ({shortcut}), aby szybko uzyskać dostęp do wszystkich akcji',
|
||||
'helpDialog.proTips.recentSessions': '5 ostatnich sesji pojawia się w Palecie poleceń',
|
||||
'helpDialog.proTips.themeCycling': 'Przełączanie motywów zapamiętuje twoje preferencje między sesjami',
|
||||
'helpDialog.proTips.leaderSequences': 'Skróty dwustopniowe: naciśnij kombinację, potem drugi klawisz — Esc anuluje',
|
||||
'helpDialog.proTips.title': 'Wskazówki:',
|
||||
'helpDialog.section.interface': 'Interfejs',
|
||||
'helpDialog.section.navigationCommands': 'Nawigacja i polecenia',
|
||||
@@ -2490,7 +2498,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'inlineComment.actions.save': 'Zapisz',
|
||||
'inlineComment.actions.showLess': 'Show less',
|
||||
'inlineComment.actions.showMore': 'Show more',
|
||||
'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)',
|
||||
'inlineComment.input.placeholder': 'Dodaj komentarz... ({shortcut}, aby zapisać)',
|
||||
'inlineComment.input.placeholderShort': 'Dodaj komentarz...',
|
||||
'inlineComment.range.lines': 'Lines {start}-{end}',
|
||||
'inlineComment.toast.selectSessionToSave': 'Select a session to save comment',
|
||||
@@ -2855,6 +2863,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessionAuth.locked.passwordDescription': 'Ta sesja jest chroniona hasłem.',
|
||||
'sessionAuth.locked.tunnelDescription': 'Otwórz ten tunel za pomocą jednorazowego linku połączenia z aplikacji desktopowej.',
|
||||
'sessionAuth.locked.tunnelTitle': 'Wymagany dostęp przez tunel',
|
||||
'sessionAuth.expired.banner': 'Sesja wygasła — zaloguj się, aby kontynuować.',
|
||||
'sessionAuth.expired.loginAction': 'Zaloguj się',
|
||||
'sessionAuth.expired.sendBlocked': 'Sesja wygasła — zaloguj się, aby wysyłać wiadomości.',
|
||||
'sessionAuth.locked.unlockTitle': 'Odblokuj OpenChamber',
|
||||
'sessionAuth.password.placeholder': 'Wpisz hasło',
|
||||
'sessionAuth.toast.passkeyAdded': 'Dodano klucz dostępu',
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinação já está sendo usada por outro atalho. Sobrescrever e limpar essa outra atribuição?",
|
||||
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Pressione as teclas...",
|
||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, ele será salvo.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, você pode salvá-lo.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir para linha (editor de arquivos)",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos",
|
||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada",
|
||||
@@ -1110,18 +1110,20 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal",
|
||||
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Alternar aba de sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sessão anterior",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Próxima sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renomear sessão atual",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprovação automática",
|
||||
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da sessão",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar painel de plano de contexto",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar ou ocultar menu de serviços",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Alternar aba de serviços",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Alternar tema",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Alternar agente",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Próximo modelo favorito",
|
||||
@@ -1130,6 +1132,27 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir linha do tempo da conversa",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar ou ocultar navegador de prompts",
|
||||
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta sequência compartilha um prefixo contextual com {action}. Quando esse contexto está ativo, essa ação tem prioridade.",
|
||||
"settings.openchamber.keyboardShortcuts.category.session": "Controles de sessão",
|
||||
"settings.openchamber.keyboardShortcuts.category.models": "Modelos e agentes",
|
||||
"settings.openchamber.keyboardShortcuts.category.panels": "Painéis e ferramentas",
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegação",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Aplicação",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas, com no máximo três teclas em cada uma. Após a primeira, aguarde até 3 segundos por uma segunda combinação. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Não atribuído",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Isto entra em conflito com a sequência usada por {action}. Escolha outra combinação.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinação entra em conflito com um atalho integrado, que não pode ser substituído.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz",
|
||||
"settings.projects.sidebar.total": "Total {count}",
|
||||
"settings.projects.sidebar.actions.addProject": "Adicionar projeto",
|
||||
"settings.projects.page.empty.noProjects": "Não há projetos disponíveis.",
|
||||
|
||||
@@ -1140,6 +1140,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.context": "Contexto",
|
||||
"contextPanel.mode.preview": "Prévia",
|
||||
"contextPanel.mode.browser": "Navegador",
|
||||
"contextRail.configure.open": "Configurar painéis",
|
||||
"contextRail.configure.dialogTitle": "Painéis da barra",
|
||||
"contextRail.configure.dialogDescription": "Escolha quais painéis a barra mostra. Painéis ocultos mantêm seus dados e continuam acessíveis pela paleta de comandos.",
|
||||
"contextRail.configure.showAll": "Mostrar todos",
|
||||
"contextRail.configure.noneWarning": "Todos os painéis estão ocultos.",
|
||||
"contextRail.aria.rail": "Superfícies do painel",
|
||||
"contextPanel.editorEmpty.title": "Nenhum arquivo aberto",
|
||||
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
|
||||
@@ -1646,7 +1651,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"rightSidebar.contextNotesTodo.toast.planImported": "Plano importado",
|
||||
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Não foi possível ler o arquivo do plano",
|
||||
"inlineComment.range.lines": "Linhas {start}-{end}",
|
||||
"inlineComment.input.placeholder": "Adicionar um comentário... (Cmd+Enter para salvar)",
|
||||
"inlineComment.input.placeholder": "Adicionar um comentário... ({shortcut} para salvar)",
|
||||
"inlineComment.input.placeholderShort": "Adicionar um comentário...",
|
||||
"inlineComment.actions.cancel": "Cancelar",
|
||||
"inlineComment.actions.save": "Salvar",
|
||||
@@ -1836,22 +1841,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.focusChatInput": "Focar entrada do chat",
|
||||
"helpDialog.item.togglePromptNavigator": "Mostrar ou ocultar navegador de prompts",
|
||||
"helpDialog.item.abortActiveRun": "Interromper execução ativa (duplo clique)",
|
||||
"helpDialog.item.toggleRightSidebar": 'Alternar painel de contexto',
|
||||
"helpDialog.item.openRightSidebarGitTab": 'Abrir superfície do Git',
|
||||
"helpDialog.item.openRightSidebarFilesTab": 'Abrir superfície de arquivos',
|
||||
"helpDialog.item.toggleTerminalDock": "Mostrar ou ocultar dock de terminal",
|
||||
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
|
||||
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
|
||||
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
|
||||
"helpDialog.item.switchSessionTab": "Alternar aba de sessão",
|
||||
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
|
||||
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
|
||||
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
|
||||
"helpDialog.item.openSettings": "Abrir configurações",
|
||||
"helpDialog.keyCombiner.or": "ou",
|
||||
"helpDialog.proTips.title": "Dicas:",
|
||||
"helpDialog.proTips.commandPalette": "Use a paleta de comandos ({shortcut}) para acessar rapidamente todas as ações",
|
||||
"helpDialog.proTips.recentSessions": "As cinco sessões mais recentes aparecem na paleta de comandos",
|
||||
"helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões",
|
||||
"helpDialog.proTips.leaderSequences": "Atalhos em duas etapas: pressione a combinação e depois a segunda tecla — Esc cancela",
|
||||
"header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})",
|
||||
"header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita",
|
||||
"header.actions.openAppMenu": "Menu do OpenChamber",
|
||||
@@ -2061,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.statusRow.tasksTitle": "Tarefas",
|
||||
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||
"chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes",
|
||||
"chat.statusRow.aborted": "Interrompido",
|
||||
"chat.revertIndicator.redo": "Refazer",
|
||||
"chat.revertIndicator.redoAria": "Refazer — restaurar mensagens revertidas",
|
||||
"chat.revertPopover.title": "Revertidas",
|
||||
@@ -2139,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
|
||||
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
|
||||
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
|
||||
"chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.",
|
||||
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
|
||||
"chat.container.sessionLoadError.authDescription": "Sua sessão expirou, então o servidor recusou a solicitação. Entre e a conversa será carregada.",
|
||||
"chat.container.sessionLoadError.retry": "Tentar novamente",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
|
||||
@@ -2182,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.title.commentOnSelection": "Comentar a seleção",
|
||||
"chat.textSelection.comment.placeholder": "Adicione um comentário opcional...",
|
||||
"chat.textSelection.comment.attach": "Anexar",
|
||||
"chat.textSelection.actions.newSession": "Nova sessão",
|
||||
"chat.textSelection.actions.addToNotes": "Adicionar às notas",
|
||||
"chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção",
|
||||
"chat.textSelection.title.saveInsightToNotes": "Salvar texto selecionado em notas",
|
||||
"chat.messageBody.actions.revertAria": "Voltar para esta mensagem",
|
||||
"chat.messageBody.actions.revert": "Voltar daqui",
|
||||
@@ -2449,6 +2448,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"commandPalette.item.toggleSidebar": "Mostrar ou ocultar barra lateral",
|
||||
"commandPalette.item.showContextUsage": "Mostrar uso do contexto",
|
||||
"commandPalette.item.toggleTerminal": "Mostrar ou ocultar terminal",
|
||||
"commandPalette.item.cycleTheme": "Alternar tema",
|
||||
"commandPalette.item.showOpenCodeStatus": "Mostrar status do OpenCode",
|
||||
"commandPalette.item.toggleMemoryDebug": "Alternar painel de depuração de memória",
|
||||
"commandPalette.item.pinSession": "Fixar ou desafixar sessão",
|
||||
"commandPalette.item.copySessionId": "Copiar ID da sessão",
|
||||
"commandPalette.item.openMultiRun": "Abrir lançador multi-run",
|
||||
"commandPalette.item.openArchive": "Abrir sessões arquivadas",
|
||||
"commandPalette.item.openNotes": "Abrir painel de notas",
|
||||
"commandPalette.item.openTodos": "Abrir painel de tarefas",
|
||||
"commandPalette.item.openSettings": "Abrir configurações...",
|
||||
"commandPalette.session.untitled": "Sessão sem título",
|
||||
"openCodeStatusDialog.title": "Status do OpenCode",
|
||||
@@ -2663,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessionAuth.error.passkeySignInCanceled": "O início de sessão com chave de acesso foi cancelado.",
|
||||
"sessionAuth.error.enterPasswordForPasskey": "Digite sua senha para adicionar uma chave de acesso.",
|
||||
"sessionAuth.locked.tunnelTitle": "É necessário acesso por túnel",
|
||||
"sessionAuth.expired.banner": "Sua sessão expirou — entre para continuar.",
|
||||
"sessionAuth.expired.loginAction": "Entrar",
|
||||
"sessionAuth.expired.sendBlocked": "Sessão expirada — entre para enviar mensagens.",
|
||||
"sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber",
|
||||
"sessionAuth.locked.tunnelDescription": "Abra este túnel usando o link de conexão única do aplicativo desktop.",
|
||||
"sessionAuth.locked.passwordDescription": "Esta sessão está protegida com senha.",
|
||||
|
||||
@@ -206,9 +206,9 @@ export const settingsDict = {
|
||||
"settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting": "Перед початком додайте токен керованого віддаленого тунелю",
|
||||
"settings.openchamber.tunnel.toast.startFailed": "Не вдалося запустити тунель",
|
||||
"settings.openchamber.tunnel.toast.startedButNoPublicUrl": "Тунель запущено, але публічний URL не повернувся",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесія.",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесію.",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions": "Попередній тунель замінено: відкликано 1 посилання, анульовано сесій: {invalidatedSessionCount}.",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесія.",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесію.",
|
||||
"settings.openchamber.tunnel.toast.replacedTunnelManyMany": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано сесій: {invalidatedSessionCount}.",
|
||||
"settings.openchamber.tunnel.toast.linkReady": "Тунель готовий",
|
||||
"settings.openchamber.tunnel.toast.stopped": "Тунель зупинено",
|
||||
@@ -1101,7 +1101,7 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.overwritePrompt": "Ця комбінація вже використовується іншою комбінацією клавіш. Перезаписати та очистити інше зіставлення?",
|
||||
"settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...",
|
||||
"settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Її все одно збережено.",
|
||||
"settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Перейти до рядка (редактор файлів)",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд",
|
||||
"settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу",
|
||||
@@ -1110,18 +1110,20 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал",
|
||||
"settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
|
||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Перемкнути вкладку сесії",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Попередня сесія",
|
||||
"settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Наступна сесія",
|
||||
"settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Перейменувати поточну сесію",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Перемкнути авто-дозволи",
|
||||
"settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
|
||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Перемкнути контекстну панель плану",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Перемкнути меню сервісів",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Перемкнути вкладку сервісів",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Перемкнути тему",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Перемкнути агента",
|
||||
"settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Перемкнути улюблену модель вперед",
|
||||
@@ -1130,6 +1132,27 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.action.expand_input.label": "Розгорнути введення",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Відкрити хронологію розмови",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Показати або приховати навігатор промптів",
|
||||
"settings.openchamber.keyboardShortcuts.warning.contextualPrefix": "Ця послідовність має спільний контекстний префікс із дією {action}. Коли її контекст активний, ця дія має пріоритет.",
|
||||
"settings.openchamber.keyboardShortcuts.category.session": "Керування сесією",
|
||||
"settings.openchamber.keyboardShortcuts.category.models": "Моделі й агенти",
|
||||
"settings.openchamber.keyboardShortcuts.category.panels": "Панелі та інструменти",
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Навігація",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Застосунок",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш, не більше трьох клавіш у кожній. Після першої зачекайте до 3 секунд на другу комбінацію. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Не призначено",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Це конфліктує з послідовністю, яку використовує {action}. Виберіть іншу комбінацію.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Ця комбінація конфліктує з вбудованим скороченням, яке не можна замінити.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії",
|
||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення",
|
||||
"settings.projects.sidebar.total": "Усього {count}",
|
||||
"settings.projects.sidebar.actions.addProject": "Додати проєкт",
|
||||
"settings.projects.page.empty.noProjects": "Немає доступних проєктів.",
|
||||
@@ -2122,7 +2145,7 @@ export const settingsDict = {
|
||||
"settings.magicPrompts.page.group.planImprove.title": "Поліпшити план",
|
||||
"settings.magicPrompts.page.group.planImprove.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік покращення.",
|
||||
"settings.magicPrompts.page.group.planTodo.title": "Планування Todo",
|
||||
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нового сесії планування.",
|
||||
"settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нової сесії планування.",
|
||||
"settings.magicPrompts.page.group.planImplement.title": "Реалізувати план",
|
||||
"settings.magicPrompts.page.group.planImplement.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік реалізації.",
|
||||
"settings.magicPrompts.page.group.sessionSummary.title": "Підсумок сесії",
|
||||
|
||||
@@ -504,12 +504,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.bulkActions.failedDeletePlural": "Не вдалося видалити сесії {count}",
|
||||
"sessions.sidebar.bulkActions.archivedSingle": "Заархівовано сесію: {count}",
|
||||
"sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}",
|
||||
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}",
|
||||
"sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесію {count}",
|
||||
"sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}",
|
||||
"sessions.sidebar.bulkActions.restore": "Відновити",
|
||||
"sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}",
|
||||
"sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}",
|
||||
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}",
|
||||
"sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесію {count}",
|
||||
"sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}",
|
||||
"sessions.sidebar.folders.none": "Папок ще немає",
|
||||
"sessions.sidebar.folders.newFolderEllipsis": "Нова папка...",
|
||||
@@ -560,9 +560,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?",
|
||||
"sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів",
|
||||
"sessions.sidebar.session.export.dialog.confirm": "Експортувати",
|
||||
"sessions.sidebar.session.status.active": "Сесія активний",
|
||||
"sessions.sidebar.session.status.active": "Сесія активна",
|
||||
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
|
||||
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
||||
"sessions.sidebar.session.status.pinned": "Закріплена сесія",
|
||||
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
|
||||
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
|
||||
"sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді",
|
||||
@@ -571,8 +571,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}",
|
||||
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
|
||||
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
|
||||
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?",
|
||||
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесія?",
|
||||
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесію?",
|
||||
"sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесію?",
|
||||
"sessions.sidebar.dialogs.deleteSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
|
||||
"sessions.sidebar.dialogs.deleteSession.withManySubtasks": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.",
|
||||
"sessions.sidebar.dialogs.archiveSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде заархівовано.",
|
||||
@@ -581,7 +581,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.dialogs.archiveSession.single": "\"{sessionTitle}\" буде заархівовано.",
|
||||
"sessions.sidebar.dialogs.neverAsk": "Більше не питати",
|
||||
"sessions.sidebar.dialogs.cancel": "Скасувати",
|
||||
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесія",
|
||||
"sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесію",
|
||||
"sessions.sidebar.dialogs.deleteSessions.titleAction": "Видалити сесії",
|
||||
"sessions.sidebar.dialogs.deleteSessions.title": "Видалити сесії?",
|
||||
"sessions.sidebar.dialogs.archiveSessions.title": "Архівувати сесії?",
|
||||
@@ -661,7 +661,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.folderItem.deleteFolderAria": "Видалити папку {folderName}",
|
||||
"sessions.sidebar.folderItem.emptyFolder": "Порожня папка",
|
||||
"sessions.sidebar.sessionDialogs.ok": "OK",
|
||||
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язаний сесія",
|
||||
"sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язана сесія",
|
||||
"sessions.sidebar.sessionDialogs.linkedSessionPlural": "Пов’язані сесії",
|
||||
"sessions.sidebar.sessionDialogs.delete.note": "Каталоги worktree залишаються недоторканими. Підсесії, пов’язані з вибраними сесіями, також буде видалено.",
|
||||
"sessions.sidebar.sessionDialogs.directory.errorSelectTitle": "Не вдалося вибрати каталог",
|
||||
@@ -1140,6 +1140,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
"contextPanel.mode.context": "Контекст",
|
||||
"contextPanel.mode.preview": "Перегляд",
|
||||
"contextPanel.mode.browser": "Браузер",
|
||||
"contextRail.configure.open": "Налаштувати панелі",
|
||||
"contextRail.configure.dialogTitle": "Панелі рейки",
|
||||
"contextRail.configure.dialogDescription": "Обери, які панелі показує рейка. Приховані панелі зберігають дані й доступні з палітри команд.",
|
||||
"contextRail.configure.showAll": "Показати всі",
|
||||
"contextRail.configure.noneWarning": "Усі панелі приховано.",
|
||||
"contextRail.aria.rail": "Поверхні панелі",
|
||||
"contextPanel.editorEmpty.title": "Файл не відкрито",
|
||||
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
|
||||
@@ -1646,7 +1651,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано",
|
||||
"rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Не вдалося прочитати файл плану",
|
||||
"inlineComment.range.lines": "Рядки {start}-{end}",
|
||||
"inlineComment.input.placeholder": "Додайте коментар... (Cmd+Enter, щоб зберегти)",
|
||||
"inlineComment.input.placeholder": "Додайте коментар... ({shortcut}, щоб зберегти)",
|
||||
"inlineComment.input.placeholderShort": "Додайте коментар...",
|
||||
"inlineComment.actions.cancel": "Скасувати",
|
||||
"inlineComment.actions.save": "Зберегти",
|
||||
@@ -1836,22 +1841,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"helpDialog.item.focusChatInput": "Фокус на полі вводу чату",
|
||||
"helpDialog.item.togglePromptNavigator": "Показати або приховати навігатор промптів",
|
||||
"helpDialog.item.abortActiveRun": "Перервати активний запуск (подвійне натискання)",
|
||||
"helpDialog.item.toggleRightSidebar": 'Перемкнути контекстну панель',
|
||||
"helpDialog.item.openRightSidebarGitTab": 'Відкрити поверхню Git',
|
||||
"helpDialog.item.openRightSidebarFilesTab": 'Відкрити поверхню файлів',
|
||||
"helpDialog.item.toggleTerminalDock": "Перемкнути панель терміналу",
|
||||
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
|
||||
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
|
||||
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
|
||||
"helpDialog.item.switchSessionTab": "Перемкнути вкладку сесії",
|
||||
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
|
||||
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
|
||||
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
|
||||
"helpDialog.item.openSettings": "Відкрити налаштування",
|
||||
"helpDialog.keyCombiner.or": "або",
|
||||
"helpDialog.proTips.title": "Поради:",
|
||||
"helpDialog.proTips.commandPalette": "Використовуйте палітру команд ({shortcut}), щоб швидко перейти до будь-якої дії",
|
||||
"helpDialog.proTips.recentSessions": "5 останніх сесій відображаються на панелі команд",
|
||||
"helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій",
|
||||
"helpDialog.proTips.leaderSequences": "Двокрокові шорткати: натисни комбінацію, потім другу клавішу — Esc скасовує",
|
||||
"header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})",
|
||||
"header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель",
|
||||
"header.actions.openAppMenu": "Меню OpenChamber",
|
||||
@@ -2061,7 +2062,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.statusRow.tasksTitle": "завдання",
|
||||
"chat.statusRow.modelStatus": "{model} · {status}",
|
||||
"chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}",
|
||||
"chat.statusRow.aborted": "Перервано",
|
||||
"chat.revertIndicator.redo": "Повторити",
|
||||
"chat.revertIndicator.redoAria": "Повторити — відновити відкочені повідомлення",
|
||||
"chat.revertPopover.title": "Відкочено",
|
||||
@@ -2139,7 +2139,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
|
||||
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
|
||||
"chat.container.sessionLoadError.title": "Не вдалося завантажити сесію",
|
||||
"chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.",
|
||||
"chat.container.sessionLoadError.description": "Не вдалося отримати розмову — сервер може бути вимкнений або недосяжний. Нічого не втрачено; спробуй знову, коли він повернеться.",
|
||||
"chat.container.sessionLoadError.authDescription": "Сесія завершилась, тож сервер відхилив запит. Увійди — і розмова завантажиться.",
|
||||
"chat.container.sessionLoadError.retry": "Спробувати знову",
|
||||
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
|
||||
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
|
||||
@@ -2182,10 +2183,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
|
||||
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
|
||||
"chat.textSelection.comment.attach": "Прикріпити",
|
||||
"chat.textSelection.actions.newSession": "Нова сесія",
|
||||
"chat.textSelection.actions.addToNotes": "Додати до нотаток",
|
||||
"chat.textSelection.title.addToCurrentChat": "Додати до поточного чату",
|
||||
"chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням",
|
||||
"chat.textSelection.title.saveInsightToNotes": "Зберегти вибраний текст у нотатках",
|
||||
"chat.messageBody.actions.revertAria": "Повернутися до цього повідомлення",
|
||||
"chat.messageBody.actions.revert": "Повернутися звідси",
|
||||
@@ -2430,7 +2429,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.messageBody.subtask.title": "Делеговане завдання",
|
||||
"chat.messageBody.subtask.hidePrompt": "Приховати промпт",
|
||||
"chat.messageBody.subtask.showPrompt": "Показати промпт",
|
||||
"chat.messageBody.subtask.openSession": "Відкрити сесія підзавдання",
|
||||
"chat.messageBody.subtask.openSession": "Відкрити сесію підзавдання",
|
||||
"chat.messageBody.shellCommand.title": "Команда оболонки",
|
||||
"chat.messageBody.shellCommand.hideOutput": "Приховати вивід",
|
||||
"chat.messageBody.shellCommand.showOutput": "Показати результат",
|
||||
@@ -2449,6 +2448,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
"commandPalette.item.toggleSidebar": "Перемкнути бічну панель",
|
||||
"commandPalette.item.showContextUsage": "Показати використання контексту",
|
||||
"commandPalette.item.toggleTerminal": "Перемкнути термінал",
|
||||
"commandPalette.item.cycleTheme": "Перемкнути тему",
|
||||
"commandPalette.item.showOpenCodeStatus": "Показати статус OpenCode",
|
||||
"commandPalette.item.toggleMemoryDebug": "Показати/сховати панель memory debug",
|
||||
"commandPalette.item.pinSession": "Прикріпити або відкріпити сесію",
|
||||
"commandPalette.item.copySessionId": "Скопіювати ID сесії",
|
||||
"commandPalette.item.openMultiRun": "Відкрити лаунчер multi-run",
|
||||
"commandPalette.item.openArchive": "Відкрити архівовані сесії",
|
||||
"commandPalette.item.openNotes": "Відкрити панель нотаток",
|
||||
"commandPalette.item.openTodos": "Відкрити панель завдань",
|
||||
"commandPalette.item.openSettings": "Відкрити налаштування...",
|
||||
"commandPalette.session.untitled": "Сесія без назви",
|
||||
"openCodeStatusDialog.title": "Статус OpenCode",
|
||||
@@ -2663,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessionAuth.error.passkeySignInCanceled": "Вхід за ключем доступу скасовано.",
|
||||
"sessionAuth.error.enterPasswordForPasskey": "Введіть пароль, щоб додати ключ доступу.",
|
||||
"sessionAuth.locked.tunnelTitle": "Потрібен доступ до тунелю",
|
||||
"sessionAuth.expired.banner": "Сесія завершилась — увійди, щоб продовжити.",
|
||||
"sessionAuth.expired.loginAction": "Увійти",
|
||||
"sessionAuth.expired.sendBlocked": "Сесія завершилась — увійди, щоб надсилати повідомлення.",
|
||||
"sessionAuth.locked.unlockTitle": "Розблокувати OpenChamber",
|
||||
"sessionAuth.locked.tunnelDescription": "Відкрийте цей тунель за допомогою одноразового посилання для з’єднання з настільної програми.",
|
||||
"sessionAuth.locked.passwordDescription": "Ця сесія захищена паролем.",
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': '该组合已被其他快捷键使用。是否覆盖并清除原映射?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍已保存。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍可保存。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳转到行(文件编辑器)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框',
|
||||
@@ -1110,18 +1110,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切换会话标签页',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一个会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一个会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重命名当前会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切换权限自动批准',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切换上下文面板中的计划',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切换服务菜单',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '轮换服务菜单标签',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '轮换主题',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '轮换智能体',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前轮换收藏模型',
|
||||
@@ -1130,6 +1132,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展开输入框',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '打开对话时间线',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '显示或隐藏提示词导航',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列与“{action}”共享上下文前缀。对应上下文生效时,该操作会优先执行。',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': '会话控制',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': '模型和智能体',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': '面板和工具',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '导航',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '应用程序',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多输入两个按键组合,每个组合最多同时按下三个按键。输入第一个组合后,最多等待 3 秒以输入第二个组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未分配',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '这与 {action} 使用的序列冲突。请选择其他组合。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此组合与内置快捷键冲突,内置快捷键不能被替换。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入',
|
||||
'settings.projects.sidebar.total': '总计 {count}',
|
||||
'settings.projects.sidebar.actions.addProject': '添加项目',
|
||||
'settings.projects.page.empty.noProjects': '暂无项目。',
|
||||
|
||||
@@ -1140,6 +1140,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.context': '上下文',
|
||||
'contextPanel.mode.preview': '预览',
|
||||
'contextPanel.mode.browser': '浏览器',
|
||||
'contextRail.configure.open': '配置面板',
|
||||
'contextRail.configure.dialogTitle': '侧栏面板',
|
||||
'contextRail.configure.dialogDescription': '选择侧栏显示哪些面板。隐藏的面板会保留数据,仍可通过命令面板打开。',
|
||||
'contextRail.configure.showAll': '全部显示',
|
||||
'contextRail.configure.noneWarning': '所有面板均已隐藏。',
|
||||
'contextRail.aria.rail': '面板界面',
|
||||
'contextPanel.editorEmpty.title': '未打开文件',
|
||||
'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。',
|
||||
@@ -1634,7 +1639,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '读取计划文件失败',
|
||||
'inlineComment.range.lines': '行 {start}-{end}',
|
||||
'inlineComment.input.placeholder': '添加评论...(Cmd+Enter 保存)',
|
||||
'inlineComment.input.placeholder': '添加评论...({shortcut} 保存)',
|
||||
'inlineComment.input.placeholderShort': '添加评论…',
|
||||
'inlineComment.actions.cancel': '取消',
|
||||
'inlineComment.actions.save': '保存',
|
||||
@@ -1824,22 +1829,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.focusChatInput': '聚焦聊天输入框',
|
||||
'helpDialog.item.togglePromptNavigator': '显示或隐藏提示词导航',
|
||||
'helpDialog.item.abortActiveRun': '中止当前运行(双击)',
|
||||
'helpDialog.item.toggleRightSidebar': '切换上下文面板',
|
||||
'helpDialog.item.openRightSidebarGitTab': '打开 Git 界面',
|
||||
'helpDialog.item.openRightSidebarFilesTab': '打开文件界面',
|
||||
'helpDialog.item.toggleTerminalDock': '切换终端停靠栏',
|
||||
'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态',
|
||||
'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)',
|
||||
'helpDialog.item.switchSessionTab': '切换会话标签页',
|
||||
'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)',
|
||||
'helpDialog.item.toggleServicesMenu': '切换服务菜单',
|
||||
'helpDialog.item.cycleServicesTab': '循环服务标签',
|
||||
'helpDialog.item.openSettings': '打开设置',
|
||||
'helpDialog.keyCombiner.or': '或',
|
||||
'helpDialog.proTips.title': '使用提示:',
|
||||
'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速访问所有操作',
|
||||
'helpDialog.proTips.recentSessions': '最近 5 个会话会显示在命令面板中',
|
||||
'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好',
|
||||
'helpDialog.proTips.leaderSequences': '两段式快捷键:先按组合键,再按第二个键(Esc 取消)',
|
||||
'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': '切换右侧边栏',
|
||||
'header.actions.openAppMenu': 'OpenChamber 菜单',
|
||||
@@ -2049,7 +2050,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.statusRow.tasksTitle': '任务',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个',
|
||||
'chat.statusRow.aborted': '已中止',
|
||||
'chat.revertIndicator.redo': '重做',
|
||||
'chat.revertIndicator.redoAria': '重做 — 恢复已撤回的消息',
|
||||
'chat.revertPopover.title': '已撤回',
|
||||
@@ -2127,7 +2127,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
|
||||
'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。',
|
||||
'chat.container.sessionLoadError.title': '无法加载会话',
|
||||
'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。',
|
||||
'chat.container.sessionLoadError.description': '无法获取对话——服务器可能已关闭或无法访问。内容没有丢失;等它恢复后重试即可。',
|
||||
'chat.container.sessionLoadError.authDescription': '会话已过期,服务器拒绝了请求。登录后对话即会加载。',
|
||||
'chat.container.sessionLoadError.retry': '重试',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。',
|
||||
@@ -2170,10 +2171,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.title.commentOnSelection': '评论所选内容',
|
||||
'chat.textSelection.comment.placeholder': '添加可选评论...',
|
||||
'chat.textSelection.comment.attach': '附加',
|
||||
'chat.textSelection.actions.newSession': '新建会话',
|
||||
'chat.textSelection.actions.addToNotes': '添加到笔记',
|
||||
'chat.textSelection.title.addToCurrentChat': '添加到当前聊天',
|
||||
'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话',
|
||||
'chat.textSelection.title.saveInsightToNotes': '将选中文本保存到笔记',
|
||||
'chat.messageBody.actions.revertAria': '回退到这条消息',
|
||||
'chat.messageBody.actions.revert': '从此处回退',
|
||||
@@ -2449,6 +2448,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'commandPalette.item.toggleSidebar': '切换侧边栏',
|
||||
'commandPalette.item.showContextUsage': '显示上下文用量',
|
||||
'commandPalette.item.toggleTerminal': '切换终端',
|
||||
'commandPalette.item.cycleTheme': '轮换主题',
|
||||
'commandPalette.item.showOpenCodeStatus': '显示 OpenCode 状态',
|
||||
'commandPalette.item.toggleMemoryDebug': '切换内存调试面板',
|
||||
'commandPalette.item.pinSession': '固定或取消固定会话',
|
||||
'commandPalette.item.copySessionId': '复制会话 ID',
|
||||
'commandPalette.item.openMultiRun': '打开多任务启动器',
|
||||
'commandPalette.item.openArchive': '打开已归档会话',
|
||||
'commandPalette.item.openNotes': '打开笔记面板',
|
||||
'commandPalette.item.openTodos': '打开待办面板',
|
||||
'commandPalette.item.openSettings': '打开设置...',
|
||||
'commandPalette.session.untitled': '未命名会话',
|
||||
'openCodeStatusDialog.title': 'OpenCode 状态',
|
||||
@@ -2663,6 +2671,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登录已取消。',
|
||||
'sessionAuth.error.enterPasswordForPasskey': '请输入密码以添加 passkey。',
|
||||
'sessionAuth.locked.tunnelTitle': '需要隧道访问',
|
||||
'sessionAuth.expired.banner': '会话已过期——请登录以继续。',
|
||||
'sessionAuth.expired.loginAction': '登录',
|
||||
'sessionAuth.expired.sendBlocked': '会话已过期——请登录后再发送消息。',
|
||||
'sessionAuth.locked.unlockTitle': '解锁 OpenChamber',
|
||||
'sessionAuth.locked.tunnelDescription': '请使用桌面应用提供的一次性连接链接打开该隧道。',
|
||||
'sessionAuth.locked.passwordDescription': '此会话受密码保护。',
|
||||
|
||||
@@ -1008,7 +1008,7 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.overwritePrompt': '該組合已被其他快速鍵使用。是否覆寫並清除原對應?',
|
||||
'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...',
|
||||
'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍已儲存。',
|
||||
'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍可儲存。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳轉到行(檔案編輯器)',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊',
|
||||
@@ -1017,18 +1017,20 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開',
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切換工作階段分頁',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一個工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一個工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重新命名目前的工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切換權限自動核准',
|
||||
'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿',
|
||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': '開啟鍵盤快速鍵',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切換上下文面板中的計畫',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切換服務選單',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '輪換服務選單分頁',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '輪換主題',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '輪換 agent',
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前輪換收藏模型',
|
||||
@@ -1037,6 +1039,27 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展開輸入方塊',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '開啟對話時間軸',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '顯示或隱藏提示詞導覽',
|
||||
'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列與「{action}」共用情境前綴。對應情境生效時,該操作會優先執行。',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': '工作階段控制',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': '模型與代理',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': '面板與工具',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '導覽',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '應用程式',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多輸入兩個按鍵組合,每個組合最多同時按下三個按鍵。輸入第一個組合後,最多等待 3 秒以輸入第二個組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未指派',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '這與 {action} 使用的序列衝突。請選擇其他組合。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此組合與內建快捷鍵衝突,內建快捷鍵不能被取代。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入',
|
||||
'settings.projects.sidebar.total': '總計 {count}',
|
||||
'settings.projects.sidebar.actions.addProject': '新增專案',
|
||||
'settings.projects.page.empty.noProjects': '暫無專案。',
|
||||
|
||||
@@ -1152,6 +1152,11 @@ export const dict: Record<I18nKey, string> = {
|
||||
'contextPanel.mode.context': '上下文',
|
||||
'contextPanel.mode.preview': '預覽',
|
||||
'contextPanel.mode.browser': '瀏覽器',
|
||||
'contextRail.configure.open': '設定面板',
|
||||
'contextRail.configure.dialogTitle': '側欄面板',
|
||||
'contextRail.configure.dialogDescription': '選擇側欄顯示哪些面板。隱藏的面板會保留資料,仍可透過命令面板開啟。',
|
||||
'contextRail.configure.showAll': '全部顯示',
|
||||
'contextRail.configure.noneWarning': '所有面板皆已隱藏。',
|
||||
'contextRail.aria.rail': '面板介面',
|
||||
'contextPanel.editorEmpty.title': '未開啟檔案',
|
||||
'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。',
|
||||
@@ -1644,7 +1649,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入',
|
||||
'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '讀取計畫檔案失敗',
|
||||
'inlineComment.range.lines': '行 {start}-{end}',
|
||||
'inlineComment.input.placeholder': '新增留言...(Cmd+Enter 儲存)',
|
||||
'inlineComment.input.placeholder': '新增留言...({shortcut} 儲存)',
|
||||
'inlineComment.input.placeholderShort': '新增留言…',
|
||||
'inlineComment.actions.cancel': '取消',
|
||||
'inlineComment.actions.save': '儲存',
|
||||
@@ -1828,22 +1833,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'helpDialog.item.focusChatInput': '聚焦聊天輸入框',
|
||||
'helpDialog.item.togglePromptNavigator': '顯示或隱藏提示詞導覽',
|
||||
'helpDialog.item.abortActiveRun': '中止目前執行(連按兩下)',
|
||||
'helpDialog.item.toggleRightSidebar': '切換上下文面板',
|
||||
'helpDialog.item.openRightSidebarGitTab': '開啟 Git 介面',
|
||||
'helpDialog.item.openRightSidebarFilesTab': '開啟檔案介面',
|
||||
'helpDialog.item.toggleTerminalDock': '切換終端機停靠欄',
|
||||
'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態',
|
||||
'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板',
|
||||
'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)',
|
||||
'helpDialog.item.switchSessionTab': '切換工作階段分頁',
|
||||
'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)',
|
||||
'helpDialog.item.toggleServicesMenu': '切換服務選單',
|
||||
'helpDialog.item.cycleServicesTab': '循環服務標籤',
|
||||
'helpDialog.item.openSettings': '開啟設定',
|
||||
'helpDialog.keyCombiner.or': '或',
|
||||
'helpDialog.proTips.title': '使用提示:',
|
||||
'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速存取所有操作',
|
||||
'helpDialog.proTips.recentSessions': '最近 5 個會话會顯示在命令面板中',
|
||||
'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好',
|
||||
'helpDialog.proTips.leaderSequences': '兩段式快捷鍵:先按組合鍵,再按第二個鍵(Esc 取消)',
|
||||
'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut})',
|
||||
'header.actions.toggleRightSidebarAria': '切換右側邊欄',
|
||||
'header.actions.openAppMenu': 'OpenChamber 選單',
|
||||
@@ -2053,7 +2054,6 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.statusRow.tasksTitle': '任務',
|
||||
'chat.statusRow.modelStatus': '{model} · {status}',
|
||||
'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個',
|
||||
'chat.statusRow.aborted': '已中止',
|
||||
'chat.revertIndicator.redo': '重做',
|
||||
'chat.revertIndicator.redoAria': '重做 — 恢復已收回的訊息',
|
||||
'chat.revertPopover.title': '已收回',
|
||||
@@ -2131,7 +2131,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
|
||||
'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。',
|
||||
'chat.container.sessionLoadError.title': '無法載入工作階段',
|
||||
'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。',
|
||||
'chat.container.sessionLoadError.description': '無法取得對話——伺服器可能已關閉或無法連線。內容沒有遺失;待其恢復後再試即可。',
|
||||
'chat.container.sessionLoadError.authDescription': '工作階段已過期,伺服器拒絕了請求。登入後對話即會載入。',
|
||||
'chat.container.sessionLoadError.retry': '再試一次',
|
||||
'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…',
|
||||
'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。',
|
||||
@@ -2174,10 +2175,8 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
|
||||
'chat.textSelection.comment.placeholder': '新增選填留言...',
|
||||
'chat.textSelection.comment.attach': '附加',
|
||||
'chat.textSelection.actions.newSession': '新增會話',
|
||||
'chat.textSelection.actions.addToNotes': '加入筆記',
|
||||
'chat.textSelection.title.addToCurrentChat': '加入目前聊天',
|
||||
'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話',
|
||||
'chat.textSelection.title.saveInsightToNotes': '將選取文字儲存到筆記',
|
||||
'chat.messageBody.actions.revertAria': '收回到這條訊息',
|
||||
'chat.messageBody.actions.revert': '從此處收回',
|
||||
@@ -2453,6 +2452,15 @@ export const dict: Record<I18nKey, string> = {
|
||||
'commandPalette.item.toggleSidebar': '切換側邊欄',
|
||||
'commandPalette.item.showContextUsage': '顯示上下文用量',
|
||||
'commandPalette.item.toggleTerminal': '切換終端機',
|
||||
'commandPalette.item.cycleTheme': '輪換主題',
|
||||
'commandPalette.item.showOpenCodeStatus': '顯示 OpenCode 狀態',
|
||||
'commandPalette.item.toggleMemoryDebug': '切換記憶體偵錯面板',
|
||||
'commandPalette.item.pinSession': '釘選或取消釘選會話',
|
||||
'commandPalette.item.copySessionId': '複製會話 ID',
|
||||
'commandPalette.item.openMultiRun': '開啟多任務啟動器',
|
||||
'commandPalette.item.openArchive': '開啟已封存會話',
|
||||
'commandPalette.item.openNotes': '開啟筆記面板',
|
||||
'commandPalette.item.openTodos': '開啟待辦面板',
|
||||
'commandPalette.item.openSettings': '開啟設定...',
|
||||
'commandPalette.session.untitled': '未命名會話',
|
||||
'openCodeStatusDialog.title': 'OpenCode 狀態',
|
||||
@@ -2667,6 +2675,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessionAuth.error.passkeySignInCanceled': 'Passkey 登入已取消。',
|
||||
'sessionAuth.error.enterPasswordForPasskey': '請輸入密碼以新增 passkey。',
|
||||
'sessionAuth.locked.tunnelTitle': '需要 Tunnel 存取',
|
||||
'sessionAuth.expired.banner': '工作階段已過期——請登入以繼續。',
|
||||
'sessionAuth.expired.loginAction': '登入',
|
||||
'sessionAuth.expired.sendBlocked': '工作階段已過期——請登入後再傳送訊息。',
|
||||
'sessionAuth.locked.unlockTitle': '解鎖 OpenChamber',
|
||||
'sessionAuth.locked.tunnelDescription': '請使用桌面應用程式提供的一次性連結開啟該 Tunnel。',
|
||||
'sessionAuth.locked.passwordDescription': '此會話受密碼保護。',
|
||||
|
||||
@@ -558,7 +558,7 @@ describe('updateDesktopSettings', () => {
|
||||
});
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -584,7 +584,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -616,7 +616,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('updateDesktopSettings', () => {
|
||||
invalidateSettingsCache();
|
||||
const syncedSettings: SettingsPayload[] = [];
|
||||
const handleSettingsSynced = (event: Event) => {
|
||||
syncedSettings.push((event as CustomEvent<SettingsPayload>).detail);
|
||||
syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings);
|
||||
};
|
||||
getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced);
|
||||
|
||||
|
||||
@@ -199,11 +199,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
|
||||
setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null);
|
||||
};
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings): void => {
|
||||
export interface SettingsSyncedDetail {
|
||||
settings: DesktopSettings;
|
||||
/** Whether listeners may adopt cross-window workspace pointers
|
||||
(activeProjectId / lastDirectory). True only for a bootstrap-grade sync:
|
||||
the settings document is shared by every window of this server, so a
|
||||
mid-session reconciliation adopting them would hijack this window's
|
||||
workspace with another window's choice. */
|
||||
adoptWorkspace: boolean;
|
||||
}
|
||||
|
||||
const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<DesktopSettings>('openchamber:settings-synced', { detail: settings }));
|
||||
window.dispatchEvent(new CustomEvent<SettingsSyncedDetail>('openchamber:settings-synced', {
|
||||
detail: { settings, adoptWorkspace },
|
||||
}));
|
||||
};
|
||||
|
||||
type SettingsSaveState = 'idle' | 'saving' | 'error';
|
||||
@@ -1841,7 +1853,8 @@ export const invalidateSettingsCache = (): void => {
|
||||
_settingsCache = null;
|
||||
};
|
||||
|
||||
export const syncDesktopSettings = async (): Promise<void> => {
|
||||
export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise<void> => {
|
||||
const adoptWorkspace = options?.adoptWorkspace !== false;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -1970,7 +1983,7 @@ export const syncDesktopSettings = async (): Promise<void> => {
|
||||
if (!isSettingsRuntimeContextCurrent(context)) return;
|
||||
}
|
||||
|
||||
dispatchSettingsSynced(authoritativeSettings);
|
||||
dispatchSettingsSynced(authoritativeSettings, adoptWorkspace);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -2013,7 +2026,7 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
if (updated) {
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSynced(reconciled, false);
|
||||
_settingsCache = null;
|
||||
}
|
||||
dispatchSettingsSaveState(updated ? 'saved' : 'error');
|
||||
@@ -2047,7 +2060,7 @@ async function _flushSettingsUpdate(): Promise<void> {
|
||||
if (updated) {
|
||||
const reconciled = _settingsMutationTracker.reconcile(updated, operation);
|
||||
applyDesktopUiPreferences(reconciled);
|
||||
dispatchSettingsSynced(reconciled);
|
||||
dispatchSettingsSynced(reconciled, false);
|
||||
dispatchSettingsSaveState('saved');
|
||||
// Invalidate GET cache so next read sees the fresh data
|
||||
_settingsCache = null;
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface QuotaProviderMeta {
|
||||
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'claude', name: 'Claude' },
|
||||
{ id: 'codex', name: 'Codex' },
|
||||
{ id: 'command-code', name: 'Command Code' },
|
||||
{ id: 'cursor', name: 'Cursor' },
|
||||
{ id: 'github-copilot', name: 'GitHub Copilot' },
|
||||
{ id: 'google', name: 'Google' },
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// Proactive detection of an expired OpenChamber client session (cookie or
|
||||
// bearer). There is no polling: every HTTP response already funnels through
|
||||
// runtimeFetch, and this module only classifies what passes by. A 401 alone
|
||||
// is NOT proof — OpenCode proxies provider errors through the same routes, so
|
||||
// a dead Anthropic key also surfaces as 401. Every suspicion is therefore
|
||||
// confirmed with one debounced GET /auth/session before the state flips.
|
||||
//
|
||||
// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the
|
||||
// composer, and the native mobile app, which feeds the signal into its own
|
||||
// connection orchestration instead of showing the shared banner.
|
||||
|
||||
export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating';
|
||||
|
||||
interface AuthSessionStore {
|
||||
state: AuthSessionState;
|
||||
/** Set only by the confirmed classifier or an explicit auth failure. */
|
||||
markExpired: () => void;
|
||||
markReauthenticating: () => void;
|
||||
markAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export const useAuthSessionStore = create<AuthSessionStore>((set) => ({
|
||||
state: 'ok',
|
||||
markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })),
|
||||
markReauthenticating: () => set({ state: 'reauthenticating' }),
|
||||
markAuthenticated: () => set({ state: 'ok' }),
|
||||
}));
|
||||
|
||||
// One confirm probe per window: parallel 401s from a burst of requests must
|
||||
// not turn into a probe storm, and a provider-side 401 that keeps repeating
|
||||
// must not re-probe on every retry.
|
||||
const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000;
|
||||
// Focus revalidation only bothers the server when the tab was away long
|
||||
// enough for a 12h/7d session to plausibly have died.
|
||||
const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
let lastProbeAt = 0;
|
||||
let probeInFlight = false;
|
||||
|
||||
// Paths where a 401 is part of a normal flow (wrong password on login, a
|
||||
// pairing redeem, the confirm probe itself) rather than evidence of expiry.
|
||||
const isExcludedAuthPath = (url: string): boolean => (
|
||||
url.includes('/auth/session') || url.includes('/api/client-auth/')
|
||||
);
|
||||
|
||||
const isClassifiablePath = (url: string): boolean => {
|
||||
const path = url.startsWith('/') ? url : (() => {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false;
|
||||
return !isExcludedAuthPath(path);
|
||||
};
|
||||
|
||||
const confirmSessionExpired = async (): Promise<void> => {
|
||||
if (probeInFlight) return;
|
||||
probeInFlight = true;
|
||||
try {
|
||||
// Deferred import: runtime-fetch classifies through this module, and the
|
||||
// probe deliberately re-enters it (its /auth/session path is excluded).
|
||||
const { runtimeFetch } = await import('./runtime-fetch');
|
||||
const response = await runtimeFetch('/auth/session', { credentials: 'include' });
|
||||
if (response.status === 401) {
|
||||
useAuthSessionStore.getState().markExpired();
|
||||
return;
|
||||
}
|
||||
if (response.ok) {
|
||||
// The suspicious 401 came from deeper in the chain (a provider key, an
|
||||
// upstream OpenCode instance) — the OpenChamber session is alive.
|
||||
const { state, markAuthenticated } = useAuthSessionStore.getState();
|
||||
if (state === 'expired') markAuthenticated();
|
||||
}
|
||||
} catch {
|
||||
// Transport failure is connectivity, not authentication; the connection
|
||||
// status machinery owns that story.
|
||||
} finally {
|
||||
probeInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Called by runtimeFetch for every response. Cheap by design: everything but
|
||||
* a 401 on a classifiable path returns immediately.
|
||||
*/
|
||||
export const observeRuntimeAuthResponse = (url: string, status: number): void => {
|
||||
if (status !== 401) return;
|
||||
if (useAuthSessionStore.getState().state === 'expired') return;
|
||||
if (!isClassifiablePath(url)) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return;
|
||||
lastProbeAt = now;
|
||||
void confirmSessionExpired();
|
||||
};
|
||||
|
||||
let watchInstalled = false;
|
||||
|
||||
/**
|
||||
* Revalidates the session when the tab regains visibility after a long
|
||||
* absence — the "laptop woke up, everything looks alive, first click fails"
|
||||
* case. One request per wake, nothing periodic.
|
||||
*/
|
||||
export const installAuthSessionFocusWatch = (): void => {
|
||||
// Callers are React effects, so a document always exists here.
|
||||
if (watchInstalled) return;
|
||||
watchInstalled = true;
|
||||
let lastConfirmedAt = Date.now();
|
||||
const revalidate = () => {
|
||||
if (useAuthSessionStore.getState().state !== 'ok') return;
|
||||
const now = Date.now();
|
||||
if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return;
|
||||
lastConfirmedAt = now;
|
||||
lastProbeAt = now;
|
||||
void confirmSessionExpired();
|
||||
};
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') revalidate();
|
||||
});
|
||||
// App switches on desktop can refocus the window without a visibility
|
||||
// change; both signals share one throttle, so a wake costs one request.
|
||||
window.addEventListener('focus', revalidate);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getActiveRelayTunnel } from './relay/runtime-tunnel';
|
||||
import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads';
|
||||
import { buildRuntimeAuthHeaders } from './runtime-auth';
|
||||
import { observeRuntimeAuthResponse } from './runtime-auth-expiry';
|
||||
import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url';
|
||||
|
||||
export interface RuntimeFetchOptions extends RequestInit {
|
||||
@@ -294,6 +295,14 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
|
||||
).toUpperCase();
|
||||
}
|
||||
|
||||
// Session-expiry classification rides on responses that already flow
|
||||
// through here; only the status is read, never the body.
|
||||
const rawFetch = doFetch;
|
||||
doFetch = () => rawFetch().then((response) => {
|
||||
observeRuntimeAuthResponse(url, response.status);
|
||||
return response;
|
||||
});
|
||||
|
||||
// A Request always carries a (possibly default) signal; treat any Request, or
|
||||
// an explicit init.signal, as "has signal" and skip coalescing for safety.
|
||||
const hasSignal = requestInit.signal != null || input instanceof Request;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { navigateSessionHistory } from './sessionNavigationHistory';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
|
||||
// SAFETY: the history module only reads a session's id and directory metadata.
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
title: id,
|
||||
directory: '/repo',
|
||||
projectID: 'p1',
|
||||
version: '1',
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session);
|
||||
|
||||
describe('sessionNavigationHistory', () => {
|
||||
test('steps back and forward through the visit order', () => {
|
||||
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s2'), session('s3')] });
|
||||
|
||||
useSessionUIStore.setState({ currentSessionId: 's1' });
|
||||
useSessionUIStore.setState({ currentSessionId: 's2' });
|
||||
useSessionUIStore.setState({ currentSessionId: 's3' });
|
||||
|
||||
expect(navigateSessionHistory(-1)).toBe(true);
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
|
||||
expect(navigateSessionHistory(-1)).toBe(true);
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
|
||||
expect(navigateSessionHistory(-1)).toBe(false);
|
||||
|
||||
expect(navigateSessionHistory(1)).toBe(true);
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
|
||||
});
|
||||
|
||||
test('a fresh visit truncates the forward branch', () => {
|
||||
// Continues from the previous test's state: at s2 with s3 forward.
|
||||
useSessionUIStore.setState({ currentSessionId: 's1' });
|
||||
expect(navigateSessionHistory(1)).toBe(false);
|
||||
expect(navigateSessionHistory(-1)).toBe(true);
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe('s2');
|
||||
});
|
||||
|
||||
test('skips and drops entries whose session no longer exists', () => {
|
||||
useSessionUIStore.setState({ currentSessionId: 's3' });
|
||||
useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s3')] });
|
||||
// History behind s3 contains s2 (dead) then s1 (alive).
|
||||
expect(navigateSessionHistory(-1)).toBe(true);
|
||||
expect(useSessionUIStore.getState().currentSessionId).toBe('s1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
|
||||
// Browser-style back/forward over the order sessions were opened in this
|
||||
// window. A normal session switch truncates the forward part and appends;
|
||||
// stepping through history moves only the cursor, so back stays back even
|
||||
// after several presses. In-memory by design: the stack describes this
|
||||
// window's journey, not durable state.
|
||||
|
||||
const MAX_HISTORY = 100;
|
||||
|
||||
let visitedSessionIds: string[] = [];
|
||||
let cursor = -1;
|
||||
let navigating = false;
|
||||
|
||||
const recordVisit = (sessionId: string): void => {
|
||||
if (visitedSessionIds[cursor] === sessionId) return;
|
||||
visitedSessionIds = [...visitedSessionIds.slice(0, cursor + 1), sessionId].slice(-MAX_HISTORY);
|
||||
cursor = visitedSessionIds.length - 1;
|
||||
};
|
||||
|
||||
useSessionUIStore.subscribe((state, previousState) => {
|
||||
if (state.currentSessionId === previousState.currentSessionId) return;
|
||||
if (!state.currentSessionId || navigating) return;
|
||||
recordVisit(state.currentSessionId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Steps the current session back (-1) or forward (+1) through this window's
|
||||
* open history. Entries whose session no longer exists in the loaded list are
|
||||
* skipped and dropped. Returns false when there is nowhere to go.
|
||||
*/
|
||||
export const navigateSessionHistory = (delta: -1 | 1): boolean => {
|
||||
const sessionsById = new Map(
|
||||
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
|
||||
);
|
||||
let nextCursor = cursor + delta;
|
||||
while (nextCursor >= 0 && nextCursor < visitedSessionIds.length) {
|
||||
const session = sessionsById.get(visitedSessionIds[nextCursor]);
|
||||
if (session) {
|
||||
cursor = nextCursor;
|
||||
navigating = true;
|
||||
try {
|
||||
useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
|
||||
} finally {
|
||||
navigating = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Drop the dead entry at nextCursor and keep scanning in the same
|
||||
// direction: a removal shifts later entries one index down, so the next
|
||||
// forward candidate lands on the same index while a backward scan steps.
|
||||
visitedSessionIds = [
|
||||
...visitedSessionIds.slice(0, nextCursor),
|
||||
...visitedSessionIds.slice(nextCursor + 1),
|
||||
];
|
||||
if (nextCursor < cursor) cursor -= 1;
|
||||
if (delta < 0) nextCursor -= 1;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -9,6 +9,45 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
* count as neighbours — the same rule the strip uses for rendering. The
|
||||
* session itself is never touched.
|
||||
*/
|
||||
/**
|
||||
* Activate the nth (0-based) header session tab, counting only tabs whose
|
||||
* session is present in the loaded session list — the same rule the strip
|
||||
* uses for rendering, so the digit matches what the user sees.
|
||||
*/
|
||||
export const activateSessionTabByIndex = (index: number): boolean => {
|
||||
const { tabIds } = useSessionTabsStore.getState();
|
||||
const sessionsById = new Map(
|
||||
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
|
||||
);
|
||||
const renderable = tabIds.filter((id) => sessionsById.has(id));
|
||||
const session = renderable[index] ? sessionsById.get(renderable[index]) : null;
|
||||
if (!session) return false;
|
||||
useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Activate the tab one step right (+1) or left (-1) of the current session
|
||||
* in the rendered strip order, wrapping around the ends. Returns false when
|
||||
* the current session has no tab or there is nothing to move to.
|
||||
*/
|
||||
export const activateAdjacentSessionTab = (delta: -1 | 1): boolean => {
|
||||
const { tabIds } = useSessionTabsStore.getState();
|
||||
const { currentSessionId, setCurrentSession } = useSessionUIStore.getState();
|
||||
const sessionsById = new Map(
|
||||
useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const),
|
||||
);
|
||||
const renderable = tabIds.filter((id) => sessionsById.has(id));
|
||||
if (!currentSessionId || renderable.length < 2) return false;
|
||||
const index = renderable.indexOf(currentSessionId);
|
||||
if (index === -1) return false;
|
||||
const nextId = renderable[(index + delta + renderable.length) % renderable.length];
|
||||
const next = sessionsById.get(nextId);
|
||||
if (!next) return false;
|
||||
setCurrentSession(next.id, resolveGlobalSessionDirectory(next));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => {
|
||||
const { tabIds, closeTab } = useSessionTabsStore.getState();
|
||||
if (!tabIds.includes(sessionId)) return;
|
||||
|
||||
@@ -1,688 +0,0 @@
|
||||
import { isMacOS } from '@/lib/utils';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
|
||||
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl';
|
||||
type ShortcutKey = string;
|
||||
export type ShortcutCombo = string;
|
||||
|
||||
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
|
||||
|
||||
export interface ShortcutAction {
|
||||
id: string;
|
||||
defaultCombo: ShortcutCombo;
|
||||
label: string;
|
||||
description?: string;
|
||||
customizable?: boolean;
|
||||
}
|
||||
|
||||
interface ParsedShortcut {
|
||||
modifiers: Set<ShortcutModifier>;
|
||||
key: ShortcutKey;
|
||||
}
|
||||
|
||||
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
|
||||
'mod': 'mod',
|
||||
'shift': 'shift',
|
||||
'alt': 'alt',
|
||||
'option': 'alt',
|
||||
'ctrl': 'ctrl',
|
||||
'meta': 'mod',
|
||||
'cmd': 'mod',
|
||||
'command': 'mod',
|
||||
};
|
||||
|
||||
const DISPLAY_LABEL_MAP: Record<ShortcutModifier, string> = {
|
||||
'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl',
|
||||
'shift': '⇧',
|
||||
'alt': '⌥',
|
||||
'option': '⌥',
|
||||
'ctrl': '⌃',
|
||||
};
|
||||
|
||||
// Physical `event.key` values (lowercased) that satisfy each modifier while a
|
||||
// chord is being held. `mod` maps to the platform primary key; on web macOS it
|
||||
// accepts either Meta or Ctrl, matching eventMatchesShortcut.
|
||||
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
|
||||
'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
|
||||
'shift': ['shift'],
|
||||
'alt': ['alt'],
|
||||
'option': ['alt'],
|
||||
'ctrl': ['control'],
|
||||
};
|
||||
|
||||
const KEY_LABEL_MAP: Record<string, string> = {
|
||||
'comma': ',',
|
||||
'period': '.',
|
||||
'enter': 'Enter',
|
||||
'escape': 'Esc',
|
||||
'tab': 'Tab',
|
||||
'space': 'Space',
|
||||
'backspace': '⌫',
|
||||
'delete': '⌦',
|
||||
'arrowup': '↑',
|
||||
'arrowdown': '↓',
|
||||
'arrowleft': '←',
|
||||
'arrowright': '→',
|
||||
'home': 'Home',
|
||||
'end': 'End',
|
||||
'pageup': 'Page Up',
|
||||
'pagedown': 'Page Down',
|
||||
};
|
||||
|
||||
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
|
||||
|
||||
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
|
||||
'{': '[',
|
||||
'}': ']',
|
||||
':': ';',
|
||||
'"': "'",
|
||||
'<': ',',
|
||||
'>': '.',
|
||||
'?': '/',
|
||||
'|': '\\',
|
||||
'~': '`',
|
||||
'!': '1',
|
||||
'@': '2',
|
||||
'#': '3',
|
||||
'$': '4',
|
||||
'%': '5',
|
||||
'^': '6',
|
||||
'&': '7',
|
||||
'*': '8',
|
||||
'(': '9',
|
||||
')': '0',
|
||||
};
|
||||
|
||||
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
|
||||
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
|
||||
export function keyToShortcutToken(key: string): string {
|
||||
const lowered = key.toLowerCase();
|
||||
|
||||
if (lowered === ',') return 'comma';
|
||||
if (lowered === '.') return 'period';
|
||||
if (lowered === ' ') return 'space';
|
||||
if (lowered === 'esc') return 'escape';
|
||||
if (lowered === '+') return 'plus';
|
||||
if (lowered === '-' || lowered === '_') return 'minus';
|
||||
if (lowered === 'arrowup') return 'arrowup';
|
||||
if (lowered === 'arrowdown') return 'arrowdown';
|
||||
if (lowered === 'arrowleft') return 'arrowleft';
|
||||
if (lowered === 'arrowright') return 'arrowright';
|
||||
|
||||
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
|
||||
}
|
||||
|
||||
const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
{
|
||||
id: 'open_go_to_line',
|
||||
defaultCombo: 'alt+g',
|
||||
label: 'Go to line (files editor)',
|
||||
description: 'Open go to line in the files editor',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_command_palette',
|
||||
defaultCombo: 'mod+p',
|
||||
label: 'Open command palette',
|
||||
description: 'Open the command palette',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'focus_input',
|
||||
defaultCombo: 'mod+i',
|
||||
label: 'Focus input',
|
||||
description: 'Focus the chat input field',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_status',
|
||||
defaultCombo: 'mod+shift+o',
|
||||
label: 'Open OpenCode status',
|
||||
description: 'Open the OpenCode status dialog',
|
||||
},
|
||||
{
|
||||
id: 'open_settings',
|
||||
defaultCombo: 'mod+comma',
|
||||
label: 'Open settings',
|
||||
description: 'Open the settings panel',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal',
|
||||
defaultCombo: 'mod+j',
|
||||
label: 'Toggle terminal dock',
|
||||
description: 'Toggle the bottom terminal dock',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal_expanded',
|
||||
defaultCombo: 'mod+shift+j',
|
||||
label: 'Toggle terminal expanded',
|
||||
description: 'Toggle terminal expanded or collapsed',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_files',
|
||||
defaultCombo: 'mod+shift+f',
|
||||
label: 'Toggle files',
|
||||
description: 'Toggle the files panel',
|
||||
},
|
||||
{
|
||||
id: 'add_selection_to_chat',
|
||||
defaultCombo: 'mod+l',
|
||||
label: 'Add selection to chat',
|
||||
description: 'Add the selected text to the chat input',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_sidebar',
|
||||
defaultCombo: 'mod+alt+l',
|
||||
label: 'Toggle sidebar',
|
||||
description: 'Toggle the session sidebar',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_timeline_dialog',
|
||||
defaultCombo: 'mod+t',
|
||||
label: 'Open conversation timeline',
|
||||
description: 'Search and navigate within current conversation',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_prompt_navigator',
|
||||
defaultCombo: 'mod+alt+p',
|
||||
label: 'Toggle prompt navigator',
|
||||
description: 'Show or hide the prompt navigator panel in chat',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_right_sidebar',
|
||||
defaultCombo: 'mod+b',
|
||||
label: 'Toggle right sidebar',
|
||||
description: 'Toggle the right sidebar',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_git',
|
||||
defaultCombo: 'mod+shift+g',
|
||||
label: 'Open right sidebar Git tab',
|
||||
description: 'Open right sidebar and select Git',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_right_sidebar_files',
|
||||
defaultCombo: 'mod+shift+f',
|
||||
label: 'Open right sidebar Files tab',
|
||||
description: 'Open right sidebar and select Files',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'switch_context_surface',
|
||||
defaultCombo: 'mod',
|
||||
label: 'Switch context panel surface',
|
||||
description: 'Hold the modifier and press a number to open or close the matching rail icon',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'new_chat',
|
||||
defaultCombo: 'mod+n',
|
||||
label: 'New session',
|
||||
description: 'Start a new session',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'new_chat_worktree',
|
||||
defaultCombo: 'mod+shift+n',
|
||||
label: 'New worktree draft',
|
||||
description: 'Create a new worktree and open a draft in it',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'close_session_tab',
|
||||
defaultCombo: 'alt+w',
|
||||
label: 'Close session tab',
|
||||
description: 'Close the active session tab in the header (the session itself stays)',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'new_mini_chat',
|
||||
defaultCombo: 'mod+alt+n',
|
||||
label: 'New Mini Chat window',
|
||||
description: 'Open a new Mini Chat draft window',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'submit_message',
|
||||
defaultCombo: 'mod+enter',
|
||||
label: 'Submit message',
|
||||
description: 'Submit the current message',
|
||||
},
|
||||
{
|
||||
id: 'clear_input',
|
||||
defaultCombo: 'escape',
|
||||
label: 'Clear input',
|
||||
description: 'Clear the input field',
|
||||
},
|
||||
{
|
||||
id: 'open_help',
|
||||
defaultCombo: 'mod+.',
|
||||
label: 'Open keyboard shortcuts',
|
||||
description: 'Show the keyboard shortcuts help',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_context_plan',
|
||||
defaultCombo: 'mod+shift+p',
|
||||
label: 'Toggle plan context panel',
|
||||
description: 'Open or close plan in the context panel',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_services_menu',
|
||||
defaultCombo: 'mod+shift+s',
|
||||
label: 'Toggle services menu',
|
||||
description: 'Open or close the services menu',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'cycle_services_tab',
|
||||
defaultCombo: 'mod+shift+[',
|
||||
label: 'Cycle services tab',
|
||||
description: 'Cycle through tabs in the services menu',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'cycle_theme',
|
||||
defaultCombo: 'mod+/',
|
||||
label: 'Cycle theme',
|
||||
description: 'Cycle between light, dark, and system theme',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'open_model_selector',
|
||||
defaultCombo: 'mod+shift+m',
|
||||
label: 'Open model selector',
|
||||
description: 'Open model selector while in chat',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'cycle_thinking_variant',
|
||||
defaultCombo: 'mod+shift+t',
|
||||
label: 'Cycle thinking variant',
|
||||
description: 'Cycle thinking variant while in chat',
|
||||
},
|
||||
{
|
||||
id: 'cycle_agent',
|
||||
defaultCombo: 'tab',
|
||||
label: 'Cycle agent',
|
||||
description: 'Cycle agent while the model selector is open',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'cycle_favorite_model_forward',
|
||||
defaultCombo: 'ctrl+]',
|
||||
label: 'Cycle favorite model forward',
|
||||
description: 'Cycle forward through starred models without opening the picker',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'cycle_favorite_model_backward',
|
||||
defaultCombo: 'ctrl+[',
|
||||
label: 'Cycle favorite model backward',
|
||||
description: 'Cycle backward through starred models without opening the picker',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'expand_input',
|
||||
defaultCombo: 'mod+shift+e',
|
||||
label: 'Expand input',
|
||||
description: 'Toggle focus mode for the chat input',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'toggle_dictation',
|
||||
defaultCombo: 'mod+alt+v',
|
||||
label: 'Voice input',
|
||||
description: 'Start dictation; press again to confirm and insert the transcript',
|
||||
customizable: true,
|
||||
},
|
||||
{
|
||||
id: 'abort_run',
|
||||
defaultCombo: 'escape',
|
||||
label: 'Abort active run',
|
||||
description: 'Abort the currently running task (double press)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
|
||||
const rawParts = combo
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const modifiers = new Set<ShortcutModifier>();
|
||||
let key = '';
|
||||
|
||||
for (const rawPart of rawParts) {
|
||||
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
|
||||
const modifier = MODIFIER_KEY_MAP[part];
|
||||
if (modifier) {
|
||||
modifiers.add(modifier);
|
||||
continue;
|
||||
}
|
||||
key = part;
|
||||
}
|
||||
|
||||
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
|
||||
return [...orderedModifiers, key].filter(Boolean).join('+');
|
||||
}
|
||||
|
||||
function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(combo);
|
||||
return parsed.key.trim().length > 0;
|
||||
}
|
||||
|
||||
function parseShortcut(combo: ShortcutCombo): ParsedShortcut {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return { modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT };
|
||||
}
|
||||
|
||||
const normalized = normalizeCombo(combo);
|
||||
const parts = normalized.split('+');
|
||||
const modifiers: Set<ShortcutModifier> = new Set();
|
||||
let key: ShortcutKey = '';
|
||||
|
||||
for (const part of parts) {
|
||||
const modifier = MODIFIER_KEY_MAP[part];
|
||||
if (modifier) {
|
||||
modifiers.add(modifier);
|
||||
} else {
|
||||
key = part;
|
||||
}
|
||||
}
|
||||
|
||||
return { modifiers, key };
|
||||
}
|
||||
|
||||
export function formatShortcutForDisplay(combo: ShortcutCombo): string {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return 'Unassigned';
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(combo);
|
||||
|
||||
if (!parsed.key && parsed.modifiers.size === 0) {
|
||||
return 'Unassigned';
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const modifier of MODIFIER_PRIORITY) {
|
||||
if (parsed.modifiers.has(modifier)) {
|
||||
parts.push(DISPLAY_LABEL_MAP[modifier]);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key) {
|
||||
const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase();
|
||||
parts.push(keyLabel);
|
||||
}
|
||||
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||
return SHORTCUT_ACTIONS.find((action) => action.id === id);
|
||||
}
|
||||
|
||||
export function getCustomizableShortcutActions(): ReadonlyArray<ShortcutAction> {
|
||||
return SHORTCUT_ACTIONS.filter((action) => action.customizable === true);
|
||||
}
|
||||
|
||||
export function getEffectiveShortcutCombo(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string') {
|
||||
if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) {
|
||||
return UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
|
||||
if (isValidShortcutCombo(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return action.defaultCombo;
|
||||
}
|
||||
|
||||
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(combo);
|
||||
if (!parsed.modifiers.has('mod')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = parsed.key.toLowerCase();
|
||||
const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']);
|
||||
return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt');
|
||||
}
|
||||
|
||||
export function eventMatchesShortcut(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
shortcut: ShortcutAction | ShortcutCombo
|
||||
): boolean {
|
||||
const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo;
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(combo);
|
||||
|
||||
const expectedMod = parsed.modifiers.has('mod');
|
||||
const expectedShift = parsed.modifiers.has('shift');
|
||||
const expectedAlt = parsed.modifiers.has('alt');
|
||||
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
|
||||
const modMatches = isDesktopMac
|
||||
? event.metaKey
|
||||
: isMac
|
||||
? (event.metaKey || event.ctrlKey)
|
||||
: event.ctrlKey;
|
||||
|
||||
if (expectedMod && !modMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expectedMod && event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedShift !== event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedAlt !== event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let eventKeyRaw = event.key;
|
||||
if (event.altKey) {
|
||||
if (event.code.startsWith('Key') && event.code.length === 4) {
|
||||
eventKeyRaw = event.code.slice(3).toLowerCase();
|
||||
} else if (event.code.startsWith('Digit') && event.code.length === 6) {
|
||||
eventKeyRaw = event.code.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
const eventKey = keyToShortcutToken(eventKeyRaw);
|
||||
const expectedKey = keyToShortcutToken(parsed.key);
|
||||
|
||||
return eventKey === expectedKey;
|
||||
}
|
||||
|
||||
export function getModifierLabel(): string {
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configurable prefix for chord-style shortcuts such as
|
||||
* "switch context panel surface", where a trailing digit key completes the
|
||||
* combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the
|
||||
* bare `mod` primary key) are honored so the prefix can omit a primary key.
|
||||
* Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix.
|
||||
*/
|
||||
export function getEffectiveShortcutPrefix(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string' && override.trim() !== '') {
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) {
|
||||
return UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
if (normalized) {
|
||||
const parsed = parseShortcut(normalized);
|
||||
if (parsed.modifiers.size > 0 || parsed.key) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return action.defaultCombo;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the physical keys required to "arm" a prefix combo are currently
|
||||
* held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at
|
||||
* least one alias must be held.
|
||||
*/
|
||||
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const aliases = MODIFIER_KEY_ALIASES[modifier];
|
||||
if (!aliases.some((alias) => heldKeys.has(alias))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches an activating keydown (the caller checks the event's own key, e.g. a
|
||||
* digit) against a chord prefix: the event's modifier state must match the
|
||||
* prefix's modifiers, and when the prefix has a primary key that key must
|
||||
* currently be held.
|
||||
*/
|
||||
export function eventMatchesShortcutPrefix(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
prefixCombo: ShortcutCombo,
|
||||
heldKeys?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
|
||||
const expectedMod = parsed.modifiers.has('mod');
|
||||
const expectedShift = parsed.modifiers.has('shift');
|
||||
const expectedAlt = parsed.modifiers.has('alt');
|
||||
const expectedCtrl = parsed.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
|
||||
const modMatches = isDesktopMac
|
||||
? event.metaKey
|
||||
: isMac
|
||||
? (event.metaKey || event.ctrlKey)
|
||||
: event.ctrlKey;
|
||||
|
||||
if (expectedMod && !modMatches) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expectedMod && event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedShift !== event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedAlt !== event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Registration boundary
|
||||
|
||||
Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command.
|
||||
|
||||
Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components.
|
||||
|
||||
# Schema contract
|
||||
|
||||
`config.ts` is the declaration-only source for application commands. It organizes entries into `session`, `models`, `panels`, `navigation`, and `application` groups, then explicitly concatenates them into `SHORTCUT_SCHEMA`. Every entry declares an ID, default binding, and whether users can customize it. Customizable entries also declare their Settings translation key, so Settings must not maintain an action-ID switch or English fallback labels.
|
||||
|
||||
Configuration must not contain lookup functions, override resolution, event matching, registry state, or runtime handlers. Those concerns belong to the owning modules below. Keeping configuration declarative makes the complete shortcut inventory reviewable without reading execution code.
|
||||
|
||||
Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples.
|
||||
|
||||
# Module roles
|
||||
|
||||
- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`.
|
||||
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
|
||||
- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution.
|
||||
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
|
||||
- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers.
|
||||
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
|
||||
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
|
||||
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
|
||||
|
||||
# Binding rules
|
||||
|
||||
Bindings remain persisted as `Record<string, string>`. Each binding has one chord or at most two space-separated chords, such as `mod+k p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid.
|
||||
|
||||
The default layout follows three modes: single chords for everyday actions, the `mod+k` leader for open/go actions (`mod+k p`, `mod+k g`, `mod+k l`, `mod+k t`, `mod+k n`, `mod+k i`, `mod+k h`), and held digit prefixes — held `mod` + digit switches header session tabs, held `mod+alt` + digit switches context panel surfaces. Every schema action ships with a default binding; palette-only commands (context surfaces, OpenCode status, memory debug) live outside the schema and the palette invokes their owning modules directly. Single-chord handlers still get the first chance at a leader's chord; returning `false` lets the dispatcher arm the sequence.
|
||||
|
||||
The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
|
||||
|
||||
The settings recorder captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
|
||||
|
||||
`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix.
|
||||
|
||||
# Dispatching
|
||||
|
||||
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 3000ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. Normal application shortcuts remain window-bubble listeners.
|
||||
|
||||
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending global dispatcher prefix, so stale second keys and Escape cannot consume it. Interaction surfaces that need shortcuts while suspended must own a dedicated scoped dispatcher and process it before the global route.
|
||||
|
||||
Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount. Exact `Ctrl+N` and `Ctrl+P` chords are translated to menu navigation even when the native event reports IME composition; no other composing key is intercepted. Window capture stops an IME Escape before Base UI's document-level dismiss listener without preventing the native IME action. Controlled draft project and worktree pickers close on non-IME Escape from either the trigger or portaled popup.
|
||||
|
||||
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
|
||||
|
||||
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
|
||||
|
||||
# Adding shortcuts
|
||||
|
||||
1. Add the command to the matching group in `config.ts`. Use a stable action ID and a normalized default binding. Keep sequences to at most two chords.
|
||||
2. Mark the command `customizable: true` only when it should appear in Settings. Add its `settingsLabelKey` and provide that key in every locale in the same change.
|
||||
3. Register the handler with `useKeybind` or `useKeybinds` near the state or UI that owns the behavior. Do not pass shortcut callbacks through unrelated components or move local UI state into a global store.
|
||||
4. Return `false` when the mounted handler is not applicable in the current runtime or focus context. This lets another command sharing the binding or prefix continue dispatching.
|
||||
5. Add or update schema, binding, registry, or dispatcher tests for the changed contract. Update Help Dialog metadata when the command should be discoverable there.
|
||||
|
||||
# Best practices
|
||||
|
||||
- Import production APIs only from `@/lib/shortcuts`; deep imports are reserved for files and tests inside this module.
|
||||
- Keep `config.ts` declarative and grouped. Do not add helpers there for querying state or executing behavior.
|
||||
- Every application command must appear exactly once in `SHORTCUT_SCHEMA`, including internal and debug commands. Component-only editing and navigation keys stay local and out of the schema.
|
||||
- Avoid exact default-binding conflicts. When runtime-exclusive commands intentionally share one, document the reason beside both declarations and make each handler return `false` outside its runtime.
|
||||
- Persist bindings as normalized strings. Never change the `Record<string, string>` override contract without an explicit migration and compatibility tests.
|
||||
- Preserve the two-chord maximum in configuration, recording UI, parsing, conflict detection, display, and tests.
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
eventMatchesShortcut,
|
||||
eventMatchesShortcutPrefix,
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutConflict,
|
||||
isRiskyBrowserShortcut,
|
||||
isShortcutPrefixHeld,
|
||||
normalizeCombo,
|
||||
parseShortcut,
|
||||
resolveShortcutEventDigit,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
} from './index';
|
||||
|
||||
describe('getEffectiveShortcutPrefix', () => {
|
||||
test('falls back to the action default (bare mod+alt) when unset', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod+alt');
|
||||
});
|
||||
|
||||
test('honors modifier + key overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p');
|
||||
});
|
||||
|
||||
test('honors modifier-only overrides', () => {
|
||||
expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift');
|
||||
});
|
||||
|
||||
test('returns UNASSIGNED for an explicit unassignment', () => {
|
||||
expect(
|
||||
getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }),
|
||||
).toBe(UNASSIGNED_SHORTCUT);
|
||||
});
|
||||
|
||||
test('returns empty string for an unknown action', () => {
|
||||
expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isShortcutPrefixHeld', () => {
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('requires every prefix modifier to be held', () => {
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false);
|
||||
expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent =>
|
||||
({
|
||||
key,
|
||||
metaKey: mods.meta ?? false,
|
||||
ctrlKey: mods.ctrl ?? false,
|
||||
shiftKey: mods.shift ?? false,
|
||||
altKey: mods.alt ?? false,
|
||||
}) as KeyboardEvent;
|
||||
|
||||
describe('eventMatchesShortcutPrefix', () => {
|
||||
test('matches a bare mod prefix when the primary modifier is held', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true);
|
||||
});
|
||||
|
||||
test('rejects a bare mod prefix without the primary modifier', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects when the event carries modifiers the prefix does not expect', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false);
|
||||
});
|
||||
|
||||
test('requires the prefix primary key to be held at match time', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false);
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true);
|
||||
});
|
||||
|
||||
test('false for an unassigned prefix', () => {
|
||||
expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcut sequences', () => {
|
||||
test('normalizes, parses, and formats up to two chords', () => {
|
||||
expect(normalizeCombo(' command + S P ')).toBe('mod+s p');
|
||||
expect(parseShortcut('mod+s p')?.chords).toHaveLength(2);
|
||||
expect(formatShortcutForDisplay('mod+s p')).toBe('Ctrl + S, P');
|
||||
});
|
||||
|
||||
test('rejects bindings with more than two chords', () => {
|
||||
expect(normalizeCombo('mod+s p q')).toBe('');
|
||||
expect(parseShortcut('mod+s p q')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('reports exact and prefix conflicts but allows sibling sequences', () => {
|
||||
expect(getShortcutConflict('mod+s', 'mod+s')).toBe('exact');
|
||||
expect(getShortcutConflict('mod+s', 'mod+s p')).toBe('prefix');
|
||||
expect(getShortcutConflict('mod+s p', 'mod+s q')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('warns when a sequence leader conflicts with a browser shortcut', () => {
|
||||
expect(isRiskyBrowserShortcut('mod+s p')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform shortcut labels', () => {
|
||||
test('normalizes Command and Option to platform-neutral modifiers', () => {
|
||||
expect(normalizeCombo('command+option+n')).toBe('mod+alt+n');
|
||||
});
|
||||
|
||||
test('uses macOS modifier symbols', () => {
|
||||
expect(formatShortcutForDisplay('mod+ctrl+shift+alt+n', 'Unassigned', 'macos')).toBe(
|
||||
'⌘ + ⌃ + ⇧ + ⌥ + N',
|
||||
);
|
||||
expect(formatShortcutForDisplay('alt', 'Unassigned', 'macos')).toBe('⌥');
|
||||
});
|
||||
|
||||
test('uses named modifiers on other platforms', () => {
|
||||
expect(formatShortcutForDisplay('mod+shift+alt+n', 'Unassigned', 'other')).toBe(
|
||||
'Ctrl + Shift + Alt + N',
|
||||
);
|
||||
expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout-independent key matching', () => {
|
||||
const event = (overrides: Partial<KeyboardEvent>): KeyboardEvent =>
|
||||
// SAFETY: the matcher only reads the modifier flags, key, and code
|
||||
// provided here; a full KeyboardEvent is not constructible in bun tests.
|
||||
({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, key: '', code: '', ...overrides }) as KeyboardEvent;
|
||||
|
||||
test('a non-Latin layout letter matches through the physical key code', () => {
|
||||
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'л', code: 'KeyK' }), 'mod+k')).toBe(true);
|
||||
expect(eventMatchesShortcut(event({ key: 'з', code: 'KeyP' }), 'p')).toBe(true);
|
||||
});
|
||||
|
||||
test('macOS Option symbol substitution matches through the digit code', () => {
|
||||
expect(eventMatchesShortcut(event({ ctrlKey: true, altKey: true, key: '¡', code: 'Digit1' }), 'mod+alt+1')).toBe(true);
|
||||
});
|
||||
|
||||
test('Latin layouts that move keys keep their key-based meaning', () => {
|
||||
// Dvorak: physical KeyT produces "y"; the binding follows the character.
|
||||
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+y')).toBe(true);
|
||||
expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+t')).toBe(false);
|
||||
});
|
||||
|
||||
test('resolveShortcutEventDigit reads the digit from the code under Option', () => {
|
||||
expect(resolveShortcutEventDigit({ key: '¡', code: 'Digit1' })).toBe('1');
|
||||
expect(resolveShortcutEventDigit({ key: '5', code: 'Digit5' })).toBe('5');
|
||||
expect(resolveShortcutEventDigit({ key: 'a', code: 'KeyA' })).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import type React from 'react';
|
||||
import { isDesktopShell } from '@/lib/desktop';
|
||||
import { isMacOS } from '@/lib/utils';
|
||||
|
||||
type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl';
|
||||
type ShortcutDisplayPlatform = 'macos' | 'other';
|
||||
type ShortcutKey = string;
|
||||
|
||||
export type ShortcutCombo = string;
|
||||
export type ShortcutConflict = 'exact' | 'prefix';
|
||||
|
||||
export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__';
|
||||
|
||||
interface ParsedShortcutChord {
|
||||
modifiers: Set<ShortcutModifier>;
|
||||
key: ShortcutKey;
|
||||
}
|
||||
|
||||
export interface ParsedShortcut {
|
||||
chords: ReadonlyArray<ParsedShortcutChord>;
|
||||
}
|
||||
|
||||
const MODIFIER_KEY_MAP: Record<string, ShortcutModifier> = {
|
||||
mod: 'mod',
|
||||
shift: 'shift',
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
ctrl: 'ctrl',
|
||||
meta: 'mod',
|
||||
cmd: 'mod',
|
||||
command: 'mod',
|
||||
};
|
||||
|
||||
const MODIFIER_LABELS: Record<ShortcutDisplayPlatform, Record<ShortcutModifier, string>> = {
|
||||
macos: {
|
||||
mod: '⌘',
|
||||
shift: '⇧',
|
||||
alt: '⌥',
|
||||
ctrl: '⌃',
|
||||
},
|
||||
other: {
|
||||
mod: 'Ctrl',
|
||||
shift: 'Shift',
|
||||
alt: 'Alt',
|
||||
ctrl: 'Ctrl',
|
||||
},
|
||||
};
|
||||
|
||||
const KEY_LABEL_MAP: Record<string, string> = {
|
||||
comma: ',',
|
||||
period: '.',
|
||||
enter: 'Enter',
|
||||
escape: 'Esc',
|
||||
tab: 'Tab',
|
||||
space: 'Space',
|
||||
backspace: '⌫',
|
||||
delete: '⌦',
|
||||
arrowup: '↑',
|
||||
arrowdown: '↓',
|
||||
arrowleft: '←',
|
||||
arrowright: '→',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageup: 'Page Up',
|
||||
pagedown: 'Page Down',
|
||||
};
|
||||
|
||||
const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt'];
|
||||
const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n', 'q', 'd', 'h', 'j', 'o', 'u']);
|
||||
const MODIFIER_KEY_ALIASES: Record<ShortcutModifier, readonly string[]> = {
|
||||
mod: isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'],
|
||||
shift: ['shift'],
|
||||
alt: ['alt'],
|
||||
ctrl: ['control'],
|
||||
};
|
||||
|
||||
const SHIFTED_KEY_BASE_MAP: Record<string, string> = {
|
||||
'{': '[',
|
||||
'}': ']',
|
||||
':': ';',
|
||||
'"': "'",
|
||||
'<': ',',
|
||||
'>': '.',
|
||||
'?': '/',
|
||||
'|': '\\',
|
||||
'~': '`',
|
||||
'!': '1',
|
||||
'@': '2',
|
||||
'#': '3',
|
||||
'$': '4',
|
||||
'%': '5',
|
||||
'^': '6',
|
||||
'&': '7',
|
||||
'*': '8',
|
||||
'(': '9',
|
||||
')': '0',
|
||||
};
|
||||
|
||||
function isUnassignedShortcut(combo: ShortcutCombo): boolean {
|
||||
return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT;
|
||||
}
|
||||
|
||||
export function keyToShortcutToken(key: string): string {
|
||||
const lowered = key.toLowerCase();
|
||||
|
||||
if (lowered === ',') return 'comma';
|
||||
if (lowered === '.') return 'period';
|
||||
if (lowered === ' ') return 'space';
|
||||
if (lowered === 'esc') return 'escape';
|
||||
if (lowered === '+') return 'plus';
|
||||
if (lowered === '-' || lowered === '_') return 'minus';
|
||||
if (lowered === 'arrowup') return 'arrowup';
|
||||
if (lowered === 'arrowdown') return 'arrowdown';
|
||||
if (lowered === 'arrowleft') return 'arrowleft';
|
||||
if (lowered === 'arrowright') return 'arrowright';
|
||||
|
||||
return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered;
|
||||
}
|
||||
|
||||
export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo {
|
||||
if (isUnassignedShortcut(combo)) return UNASSIGNED_SHORTCUT;
|
||||
|
||||
const chords = combo
|
||||
.trim()
|
||||
.replace(/\s*\+\s*/g, '+')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (chords.length === 0 || chords.length > 2) return '';
|
||||
|
||||
return chords.map(normalizeChord).join(' ');
|
||||
}
|
||||
|
||||
function normalizeChord(combo: ShortcutCombo): ShortcutCombo {
|
||||
const rawParts = combo
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.split('+')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const modifiers = new Set<ShortcutModifier>();
|
||||
let key = '';
|
||||
|
||||
for (const rawPart of rawParts) {
|
||||
const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart;
|
||||
const modifier = MODIFIER_KEY_MAP[part];
|
||||
if (modifier) {
|
||||
modifiers.add(modifier);
|
||||
} else {
|
||||
key = part;
|
||||
}
|
||||
}
|
||||
|
||||
const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier));
|
||||
return [...orderedModifiers, key].filter(Boolean).join('+');
|
||||
}
|
||||
|
||||
export function isValidShortcutCombo(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) return true;
|
||||
const parsed = parseShortcut(combo);
|
||||
return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0);
|
||||
}
|
||||
|
||||
export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined {
|
||||
if (isUnassignedShortcut(combo)) {
|
||||
return { chords: [{ modifiers: new Set<ShortcutModifier>(), key: UNASSIGNED_SHORTCUT }] };
|
||||
}
|
||||
|
||||
const normalized = normalizeCombo(combo);
|
||||
if (!normalized) return undefined;
|
||||
|
||||
return {
|
||||
chords: normalized.split(' ').map((chord) => {
|
||||
const modifiers = new Set<ShortcutModifier>();
|
||||
let key: ShortcutKey = '';
|
||||
for (const part of chord.split('+')) {
|
||||
const modifier = MODIFIER_KEY_MAP[part];
|
||||
if (modifier) {
|
||||
modifiers.add(modifier);
|
||||
} else {
|
||||
key = part;
|
||||
}
|
||||
}
|
||||
return { modifiers, key };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getShortcutDisplayPlatform(): ShortcutDisplayPlatform {
|
||||
return isMacOS() ? 'macos' : 'other';
|
||||
}
|
||||
|
||||
export function formatShortcutForDisplay(
|
||||
combo: ShortcutCombo,
|
||||
unassignedLabel = 'Unassigned',
|
||||
platform = getShortcutDisplayPlatform(),
|
||||
): string {
|
||||
if (isUnassignedShortcut(combo)) return unassignedLabel;
|
||||
const parsed = parseShortcut(combo);
|
||||
if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) {
|
||||
return unassignedLabel;
|
||||
}
|
||||
return parsed.chords.map((chord) => formatChordForDisplay(chord, platform)).join(', ');
|
||||
}
|
||||
|
||||
function formatChordForDisplay(
|
||||
parsed: ParsedShortcutChord,
|
||||
platform: ShortcutDisplayPlatform,
|
||||
): string {
|
||||
const modifierLabels = MODIFIER_LABELS[platform];
|
||||
const parts = MODIFIER_PRIORITY
|
||||
.filter((modifier) => parsed.modifiers.has(modifier))
|
||||
.map((modifier) => modifierLabels[modifier]);
|
||||
if (parsed.key) {
|
||||
parts.push(KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase());
|
||||
}
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined {
|
||||
const normalizedLeft = normalizeCombo(left);
|
||||
const normalizedRight = normalizeCombo(right);
|
||||
const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) || !isValidShortcutCombo(normalizedRight);
|
||||
const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT
|
||||
|| normalizedRight === UNASSIGNED_SHORTCUT;
|
||||
if (hasInvalidBinding || hasUnassignedBinding) return undefined;
|
||||
if (normalizedLeft === normalizedRight) return 'exact';
|
||||
|
||||
const leftChords = normalizedLeft.split(' ');
|
||||
const rightChords = normalizedRight.split(' ');
|
||||
const sharesLeader = leftChords[0] === rightChords[0];
|
||||
return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined;
|
||||
}
|
||||
|
||||
export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean {
|
||||
if (isUnassignedShortcut(combo)) return false;
|
||||
const parsed = parseShortcut(combo);
|
||||
if (!parsed) return false;
|
||||
// Every chord counts: a second chord like "mod+w" is just as capable of
|
||||
// closing the tab as a first one, and mod+shift+w closes a window.
|
||||
return parsed.chords.some((chord) => {
|
||||
if (!chord.modifiers.has('mod')) return false;
|
||||
if (chord.modifiers.has('alt')) return false;
|
||||
if (chord.modifiers.has('shift')) {
|
||||
return chord.key.toLowerCase() === 'w' || chord.key.toLowerCase() === 'q';
|
||||
}
|
||||
return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
const CODE_KEY_MAP = new Map<string, string>([
|
||||
['Comma', ','],
|
||||
['Period', '.'],
|
||||
['Slash', '/'],
|
||||
['Backquote', '`'],
|
||||
['BracketLeft', '['],
|
||||
['BracketRight', ']'],
|
||||
['Semicolon', ';'],
|
||||
['Quote', "'"],
|
||||
['Minus', '-'],
|
||||
['Equal', '='],
|
||||
]);
|
||||
|
||||
function keyFromEventCode(code: string): string | null {
|
||||
if (code.startsWith('Key') && code.length === 4) return code.slice(3).toLowerCase();
|
||||
if (code.startsWith('Digit') && code.length === 6) return code.slice(5);
|
||||
return CODE_KEY_MAP.get(code) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The character a physical key press should match against bindings. `key`
|
||||
* carries the layout-produced character: Option on macOS substitutes symbols
|
||||
* ("¡" for ⌥1) and non-Latin layouts substitute their own alphabet ("л" for
|
||||
* K). Both keep the physical key in `code`, so those two cases fall back to
|
||||
* it; Latin layouts that MOVE keys (Dvorak, AZERTY) keep their `key`-based
|
||||
* meaning untouched.
|
||||
*/
|
||||
export function resolveShortcutEventKey(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code' | 'altKey'>,
|
||||
): string {
|
||||
const raw = event.key;
|
||||
if (event.altKey) return keyFromEventCode(event.code) ?? raw;
|
||||
if (raw.length === 1 && raw.charCodeAt(0) > 127) return keyFromEventCode(event.code) ?? raw;
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** The digit a press addresses, layout- and Option-proof via `code`. */
|
||||
export function resolveShortcutEventDigit(
|
||||
event: Pick<KeyboardEvent, 'key' | 'code'>,
|
||||
): string | null {
|
||||
if (event.code.startsWith('Digit') && event.code.length === 6) return event.code.slice(5);
|
||||
return event.key.length === 1 && event.key >= '0' && event.key <= '9' ? event.key : null;
|
||||
}
|
||||
|
||||
export function eventMatchesShortcut(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
combo: ShortcutCombo,
|
||||
): boolean {
|
||||
if (isUnassignedShortcut(combo)) return false;
|
||||
const parsed = parseShortcut(combo);
|
||||
if (!parsed || parsed.chords.length !== 1) return false;
|
||||
const chord = parsed.chords[0];
|
||||
|
||||
const expectedMod = chord.modifiers.has('mod');
|
||||
const expectedShift = chord.modifiers.has('shift');
|
||||
const expectedAlt = chord.modifiers.has('alt');
|
||||
const expectedCtrl = chord.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
let modMatches = event.ctrlKey;
|
||||
if (isDesktopMac) {
|
||||
modMatches = event.metaKey;
|
||||
} else if (isMac) {
|
||||
modMatches = event.metaKey || event.ctrlKey;
|
||||
}
|
||||
|
||||
if (expectedMod && !modMatches) return false;
|
||||
if (!expectedMod && event.metaKey) return false;
|
||||
if (expectedShift !== event.shiftKey) return false;
|
||||
if (expectedAlt !== event.altKey) return false;
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) return false;
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) return false;
|
||||
}
|
||||
|
||||
return keyToShortcutToken(resolveShortcutEventKey(event)) === keyToShortcutToken(chord.key);
|
||||
}
|
||||
|
||||
export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet<string>): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) return false;
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
if (!parsed || parsed.chords.length !== 1) return false;
|
||||
const chord = parsed.chords[0];
|
||||
|
||||
for (const modifier of chord.modifiers) {
|
||||
if (!MODIFIER_KEY_ALIASES[modifier].some((alias) => heldKeys.has(alias))) return false;
|
||||
}
|
||||
return !chord.key || heldKeys.has(chord.key.toLowerCase());
|
||||
}
|
||||
|
||||
export function eventMatchesShortcutPrefix(
|
||||
event: KeyboardEvent | React.KeyboardEvent,
|
||||
prefixCombo: ShortcutCombo,
|
||||
heldKeys?: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (isUnassignedShortcut(prefixCombo)) return false;
|
||||
const parsed = parseShortcut(prefixCombo);
|
||||
if (!parsed || parsed.chords.length !== 1) return false;
|
||||
const chord = parsed.chords[0];
|
||||
const expectedMod = chord.modifiers.has('mod');
|
||||
const expectedShift = chord.modifiers.has('shift');
|
||||
const expectedAlt = chord.modifiers.has('alt');
|
||||
const expectedCtrl = chord.modifiers.has('ctrl');
|
||||
const isDesktopMac = isMacOS() && isDesktopShell();
|
||||
const isMac = isMacOS();
|
||||
const modMatches = isDesktopMac ? event.metaKey : isMac ? event.metaKey || event.ctrlKey : event.ctrlKey;
|
||||
|
||||
if (expectedMod && !modMatches) return false;
|
||||
if (!expectedMod && event.metaKey) return false;
|
||||
if (expectedShift !== event.shiftKey || expectedAlt !== event.altKey) return false;
|
||||
if (expectedCtrl) {
|
||||
if (!event.ctrlKey) return false;
|
||||
} else {
|
||||
const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey;
|
||||
if (event.ctrlKey && !ctrlUsedAsMod) return false;
|
||||
}
|
||||
|
||||
return !chord.key || Boolean(heldKeys?.has(chord.key.toLowerCase()));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { ShortcutCombo } from './bindings';
|
||||
|
||||
type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application';
|
||||
|
||||
type ShortcutConfig = {
|
||||
id: string;
|
||||
defaultBinding: ShortcutCombo;
|
||||
/** The binding is a bare-modifier chord prefix (completed by another key);
|
||||
conflict resolution compares its prefix rather than a full combo. */
|
||||
prefixStyle?: true;
|
||||
} & (
|
||||
| { customizable: false }
|
||||
| {
|
||||
customizable: true;
|
||||
settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`;
|
||||
}
|
||||
);
|
||||
|
||||
// Default layout, unified around three modes:
|
||||
// - Single chords for everyday actions.
|
||||
// - The mod+k leader for "open/go" actions, second key mnemonic.
|
||||
// - Held mod + digit switches header session tabs; held mod+alt + digit
|
||||
// switches context panel surfaces (mod+shift+digit is reserved by macOS
|
||||
// screenshots).
|
||||
// Everything else lives only in the command palette, outside this schema.
|
||||
const SHORTCUT_GROUPS = {
|
||||
session: [
|
||||
{
|
||||
id: 'add_selection_to_chat',
|
||||
defaultBinding: 'mod+l',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label',
|
||||
},
|
||||
{
|
||||
id: 'focus_input',
|
||||
defaultBinding: 'mod+i',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.focus_input.label',
|
||||
},
|
||||
{
|
||||
id: 'open_timeline_dialog',
|
||||
defaultBinding: 'mod+k t',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label',
|
||||
},
|
||||
{
|
||||
id: 'new_chat',
|
||||
defaultBinding: 'mod+n',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label',
|
||||
},
|
||||
{
|
||||
id: 'switch_session_previous',
|
||||
defaultBinding: 'mod+alt+arrowleft',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label',
|
||||
},
|
||||
{
|
||||
id: 'switch_session_next',
|
||||
defaultBinding: 'mod+alt+arrowright',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_next.label',
|
||||
},
|
||||
{
|
||||
id: 'rename_current_session',
|
||||
defaultBinding: 'mod+k r',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.rename_current_session.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_permission_auto_accept',
|
||||
defaultBinding: 'mod+k a',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label',
|
||||
},
|
||||
{
|
||||
id: 'close_session_tab',
|
||||
defaultBinding: 'alt+w',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_project_picker',
|
||||
defaultBinding: 'mod+k p',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label',
|
||||
},
|
||||
{
|
||||
id: 'open_draft_worktree_picker',
|
||||
defaultBinding: 'mod+k g',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label',
|
||||
},
|
||||
{
|
||||
id: 'open_session_list',
|
||||
defaultBinding: 'mod+k l',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label',
|
||||
},
|
||||
{
|
||||
id: 'new_chat_worktree',
|
||||
defaultBinding: 'mod+shift+n',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label',
|
||||
},
|
||||
{
|
||||
id: 'new_mini_chat',
|
||||
defaultBinding: 'mod+alt+n',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label',
|
||||
},
|
||||
{
|
||||
id: 'expand_input',
|
||||
defaultBinding: 'mod+shift+e',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.expand_input.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_dictation',
|
||||
defaultBinding: 'mod+alt+v',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label',
|
||||
},
|
||||
{ id: 'abort_run', defaultBinding: 'escape', customizable: false },
|
||||
],
|
||||
models: [
|
||||
{
|
||||
id: 'open_model_selector',
|
||||
defaultBinding: 'mod+shift+m',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_model_selector.label',
|
||||
},
|
||||
{ id: 'cycle_thinking_variant', defaultBinding: 'mod+shift+t', customizable: false },
|
||||
{
|
||||
id: 'cycle_agent',
|
||||
defaultBinding: 'tab',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label',
|
||||
},
|
||||
{
|
||||
id: 'cycle_favorite_model_forward',
|
||||
defaultBinding: 'ctrl+]',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label',
|
||||
},
|
||||
{
|
||||
id: 'cycle_favorite_model_backward',
|
||||
defaultBinding: 'ctrl+[',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label',
|
||||
},
|
||||
],
|
||||
panels: [
|
||||
{
|
||||
id: 'toggle_terminal',
|
||||
defaultBinding: 'mod+j',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_terminal_expanded',
|
||||
defaultBinding: 'mod+shift+j',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_sidebar',
|
||||
defaultBinding: 'mod+b',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_prompt_navigator',
|
||||
defaultBinding: 'mod+k n',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label',
|
||||
},
|
||||
{
|
||||
id: 'switch_session_tab',
|
||||
defaultBinding: 'mod',
|
||||
// The binding is a bare modifier acting as a chord prefix (completed by
|
||||
// a digit); conflict resolution must compare its PREFIX, not a combo.
|
||||
prefixStyle: true,
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label',
|
||||
},
|
||||
{
|
||||
id: 'switch_context_surface',
|
||||
defaultBinding: 'mod+alt',
|
||||
prefixStyle: true,
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label',
|
||||
},
|
||||
{
|
||||
id: 'toggle_services_menu',
|
||||
defaultBinding: 'mod+k i',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label',
|
||||
},
|
||||
],
|
||||
navigation: [
|
||||
{ id: 'save_file', defaultBinding: 'mod+s', customizable: false },
|
||||
{ id: 'find_in_file', defaultBinding: 'mod+f', customizable: false },
|
||||
{
|
||||
id: 'open_go_to_line',
|
||||
defaultBinding: 'alt+g',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label',
|
||||
},
|
||||
],
|
||||
application: [
|
||||
{
|
||||
id: 'open_command_palette',
|
||||
defaultBinding: 'mod+p',
|
||||
customizable: true,
|
||||
settingsLabelKey:
|
||||
'settings.openchamber.keyboardShortcuts.action.open_command_palette.label',
|
||||
},
|
||||
{
|
||||
id: 'open_settings',
|
||||
defaultBinding: 'mod+comma',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_settings.label',
|
||||
},
|
||||
{
|
||||
id: 'open_help',
|
||||
defaultBinding: 'mod+k h',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label',
|
||||
},
|
||||
{
|
||||
id: 'cycle_theme',
|
||||
defaultBinding: 'mod+k c',
|
||||
customizable: true,
|
||||
settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label',
|
||||
},
|
||||
],
|
||||
} as const satisfies Record<ShortcutCategory, readonly ShortcutConfig[]>;
|
||||
|
||||
/** All application shortcuts, flattened in the same order used by Settings. */
|
||||
export const SHORTCUT_SCHEMA = [
|
||||
...SHORTCUT_GROUPS.session.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: 'session' as const,
|
||||
})),
|
||||
...SHORTCUT_GROUPS.models.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: 'models' as const,
|
||||
})),
|
||||
...SHORTCUT_GROUPS.panels.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: 'panels' as const,
|
||||
})),
|
||||
...SHORTCUT_GROUPS.navigation.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: 'navigation' as const,
|
||||
})),
|
||||
...SHORTCUT_GROUPS.application.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: 'application' as const,
|
||||
})),
|
||||
] as const;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { ShortcutDispatcher } from './dispatcher';
|
||||
import { ShortcutRegistry } from './registry';
|
||||
|
||||
function key(key: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return {
|
||||
key,
|
||||
code: `Key${key.toUpperCase()}`,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
repeat: false,
|
||||
isComposing: false,
|
||||
...options,
|
||||
} as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe('ShortcutDispatcher', () => {
|
||||
test('dispatches a sequence and consumes only leaders with active handlers', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
const unregister = registry.register('open_command_palette', (event) => {
|
||||
calls.push(event.key);
|
||||
});
|
||||
const dispatcher = new ShortcutDispatcher({
|
||||
registry,
|
||||
getBinding: (id) => id === 'open_command_palette' ? 'g h' : '',
|
||||
});
|
||||
|
||||
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
||||
expect(dispatcher.dispatch(key('h'))).toBe(true);
|
||||
expect(calls).toEqual(['h']);
|
||||
|
||||
unregister();
|
||||
expect(dispatcher.dispatch(key('g'))).toBe(false);
|
||||
});
|
||||
|
||||
test('re-matches a prefix mismatch and clears on escape or blur', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||
registry.register('open_help', () => { calls.push('single'); });
|
||||
const dispatcher = new ShortcutDispatcher({
|
||||
registry,
|
||||
getBinding: (id) => id === 'open_command_palette' ? 'g h' : 'x',
|
||||
});
|
||||
|
||||
dispatcher.dispatch(key('g'));
|
||||
expect(dispatcher.dispatch(key('x'))).toBe(true);
|
||||
expect(calls).toEqual(['single']);
|
||||
dispatcher.dispatch(key('g'));
|
||||
expect(dispatcher.dispatch(key('Escape'))).toBe(true);
|
||||
expect(dispatcher.handleEscape()).toBe(false);
|
||||
dispatcher.dispatch(key('g'));
|
||||
dispatcher.handleBlur();
|
||||
expect(dispatcher.dispatch(key('h'))).toBe(false);
|
||||
});
|
||||
|
||||
test('expires prefixes and ignores repeats, composition, and modifier keys', () => {
|
||||
let now = 0;
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now });
|
||||
|
||||
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
||||
now = 2999;
|
||||
expect(dispatcher.hasActivePrefix()).toBe(true);
|
||||
now = 3000;
|
||||
expect(dispatcher.dispatch(key('h'))).toBe(false);
|
||||
expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false);
|
||||
expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false);
|
||||
expect(dispatcher.dispatch(key('Shift'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('does not consume a completed binding when every handler declines it', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
registry.register('open_command_palette', () => false);
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
|
||||
|
||||
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
||||
expect(dispatcher.dispatch(key('h'))).toBe(false);
|
||||
});
|
||||
|
||||
test('does not consume a single chord when its handler declines it', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
registry.register('open_command_palette', () => false);
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
|
||||
|
||||
expect(dispatcher.dispatch(key('x'))).toBe(false);
|
||||
});
|
||||
|
||||
test('starts a sequence when a single-chord handler with the same leader declines', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('save_file', () => false);
|
||||
registry.register('open_draft_project_picker', () => { calls.push('project'); });
|
||||
const dispatcher = new ShortcutDispatcher({
|
||||
registry,
|
||||
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
|
||||
});
|
||||
|
||||
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
|
||||
expect(dispatcher.dispatch(key('p'))).toBe(true);
|
||||
expect(calls).toEqual(['project']);
|
||||
});
|
||||
|
||||
test('does not start a sequence when a single-chord handler accepts the leader', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('save_file', () => { calls.push('save'); });
|
||||
registry.register('open_draft_project_picker', () => { calls.push('project'); });
|
||||
const dispatcher = new ShortcutDispatcher({
|
||||
registry,
|
||||
getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p',
|
||||
});
|
||||
|
||||
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
|
||||
expect(dispatcher.dispatch(key('p'))).toBe(false);
|
||||
expect(calls).toEqual(['save']);
|
||||
});
|
||||
|
||||
test('resolves bindings at dispatch time', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
let binding = 'x';
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', (event) => { calls.push(event.key); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => binding });
|
||||
|
||||
expect(dispatcher.dispatch(key('x'))).toBe(true);
|
||||
binding = 'y';
|
||||
expect(dispatcher.dispatch(key('x'))).toBe(false);
|
||||
expect(dispatcher.dispatch(key('y'))).toBe(true);
|
||||
expect(calls).toEqual(['x', 'y']);
|
||||
});
|
||||
|
||||
test('invalidates a prefix when shortcut suspension changes', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
|
||||
|
||||
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
||||
const resume = registry.suspend();
|
||||
expect(dispatcher.hasActivePrefix()).toBe(false);
|
||||
expect(dispatcher.handleEscape()).toBe(false);
|
||||
resume();
|
||||
expect(dispatcher.dispatch(key('h'))).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('marks a second key dispatched from capture so bubble does not dispatch it again', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
|
||||
const secondKey = key('h');
|
||||
|
||||
dispatcher.dispatch(key('g'));
|
||||
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
|
||||
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
|
||||
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false);
|
||||
expect(calls).toEqual(['sequence']);
|
||||
});
|
||||
|
||||
test('consumes a matching captured prefix key during IME composition', () => {
|
||||
for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_session_list', () => { calls.push('sequence'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
|
||||
const secondKey = key('l', compositionState);
|
||||
|
||||
expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true);
|
||||
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
|
||||
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
|
||||
expect(calls).toEqual(['sequence']);
|
||||
}
|
||||
});
|
||||
|
||||
test('clears an active prefix but preserves an unmatched IME key', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_session_list', () => { calls.push('sequence'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' });
|
||||
const secondKey = key('x', { isComposing: true });
|
||||
|
||||
dispatcher.dispatch(key('s', { ctrlKey: true }));
|
||||
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false);
|
||||
expect(dispatcher.hasActivePrefix()).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('stops after the first handler that accepts a conflicting binding', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const calls: string[] = [];
|
||||
registry.register('open_command_palette', () => { calls.push('declined'); return false; });
|
||||
registry.register('open_help', () => { calls.push('first'); });
|
||||
registry.register('open_settings', () => { calls.push('second'); });
|
||||
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' });
|
||||
|
||||
expect(dispatcher.dispatch(key('x'))).toBe(true);
|
||||
expect(calls).toEqual(['declined', 'first']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
eventMatchesShortcut,
|
||||
normalizeCombo,
|
||||
parseShortcut,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
} from './bindings';
|
||||
import { type ShortcutHandler, ShortcutRegistry } from './registry';
|
||||
import type { ShortcutActionId } from './schema';
|
||||
import { isIMECompositionEvent } from '../ime';
|
||||
|
||||
const SEQUENCE_TIMEOUT_MS = 3000;
|
||||
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
|
||||
|
||||
export interface ShortcutDispatcherOptions {
|
||||
registry: ShortcutRegistry;
|
||||
getBinding: (actionId: ShortcutActionId) => ShortcutCombo;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface BindingMatch {
|
||||
chords: string[];
|
||||
handler: ShortcutHandler;
|
||||
}
|
||||
|
||||
/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */
|
||||
export class ShortcutDispatcher {
|
||||
private readonly now: () => number;
|
||||
private readonly timeoutMs: number;
|
||||
private prefix: string | undefined;
|
||||
// The target the leader chord was pressed on. DOM-agnostic (opaque
|
||||
// EventTarget): callers use it to decide whether an unmodified completion
|
||||
// key arriving from an EDITABLE target is a deliberate sequence (same
|
||||
// target as the arming press) or typing that must not be swallowed.
|
||||
private prefixTarget: EventTarget | null = null;
|
||||
private expiresAt = 0;
|
||||
private prefixSuspensionVersion = 0;
|
||||
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
|
||||
|
||||
constructor(private readonly options: ShortcutDispatcherOptions) {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
dispatch(event: KeyboardEvent): boolean {
|
||||
if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (event.key === 'Escape' && this.hasActivePrefix()) {
|
||||
return this.handleEscape();
|
||||
}
|
||||
this.hasActivePrefix();
|
||||
|
||||
const matches = this.getMatches();
|
||||
if (this.prefix) {
|
||||
const pending = this.getPrefixMatches(matches, event);
|
||||
if (pending.length > 0) {
|
||||
this.clear();
|
||||
return this.invoke(pending, event);
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
const singles = matches.filter((match) => (
|
||||
match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0])
|
||||
));
|
||||
if (singles.length > 0 && this.invoke(singles, event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const leader = matches.find((match) => (
|
||||
match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0])
|
||||
));
|
||||
if (leader) {
|
||||
this.prefix = leader.chords[0];
|
||||
this.prefixTarget = event.target;
|
||||
this.expiresAt = this.now() + this.timeoutMs;
|
||||
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.prefix = undefined;
|
||||
this.prefixTarget = null;
|
||||
this.expiresAt = 0;
|
||||
this.prefixSuspensionVersion = 0;
|
||||
}
|
||||
|
||||
getActivePrefixTarget(): EventTarget | null {
|
||||
return this.hasActivePrefix() ? this.prefixTarget : null;
|
||||
}
|
||||
|
||||
handleBlur(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
handleEscape(): boolean {
|
||||
const hadPrefix = this.hasActivePrefix();
|
||||
this.clear();
|
||||
return hadPrefix;
|
||||
}
|
||||
|
||||
hasActivePrefix(): boolean {
|
||||
if (!this.prefix) return false;
|
||||
if (
|
||||
this.now() >= this.expiresAt
|
||||
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
|
||||
) {
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
dispatchActivePrefix(event: KeyboardEvent): boolean {
|
||||
this.capturedPrefixEvents.add(event);
|
||||
if (isIMECompositionEvent(event)) {
|
||||
if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) {
|
||||
return false;
|
||||
}
|
||||
const pending = this.getPrefixMatches(this.getMatches(), event);
|
||||
this.clear();
|
||||
return pending.length > 0 ? this.invoke(pending, event) : false;
|
||||
}
|
||||
return this.dispatch(event);
|
||||
}
|
||||
|
||||
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
|
||||
if (!this.capturedPrefixEvents.has(event)) return false;
|
||||
this.capturedPrefixEvents.delete(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
|
||||
for (const match of matches) {
|
||||
if (match.handler(event) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] {
|
||||
return matches.filter((match) => (
|
||||
match.chords.length === 2
|
||||
&& match.chords[0] === this.prefix
|
||||
&& eventMatchesShortcut(event, match.chords[1])
|
||||
));
|
||||
}
|
||||
|
||||
private getMatches(): BindingMatch[] {
|
||||
const matches: BindingMatch[] = [];
|
||||
for (const actionId of this.options.registry.actionIds()) {
|
||||
const handler = this.options.registry.get(actionId);
|
||||
if (!handler) continue;
|
||||
|
||||
const binding = normalizeCombo(this.options.getBinding(actionId));
|
||||
const parsed = parseShortcut(binding);
|
||||
if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({ chords: binding.split(' '), handler });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
eventMatchesShortcut,
|
||||
eventMatchesShortcutPrefix,
|
||||
formatShortcutForDisplay,
|
||||
getShortcutConflict,
|
||||
isRiskyBrowserShortcut,
|
||||
isShortcutPrefixHeld,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
parseShortcut,
|
||||
resolveShortcutEventDigit,
|
||||
resolveShortcutEventKey,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
} from './bindings';
|
||||
export type { ShortcutCombo } from './bindings';
|
||||
export { ShortcutDispatcher } from './dispatcher';
|
||||
export { shortcutRegistry } from './registry';
|
||||
export type { ShortcutHandler } from './registry';
|
||||
export {
|
||||
getCustomizableShortcutActions,
|
||||
getShortcutBindingConflicts,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutAction,
|
||||
SHORTCUT_SCHEMA,
|
||||
} from './schema';
|
||||
export type {
|
||||
CustomizableShortcutAction,
|
||||
ShortcutBindingConflict,
|
||||
ShortcutActionId,
|
||||
ShortcutCategory,
|
||||
} from './schema';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { ShortcutRegistry } from './registry';
|
||||
|
||||
test('the first registration wins and a later unregister cannot remove it', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const firstHandler = () => undefined;
|
||||
const first = registry.register('open_settings', firstHandler);
|
||||
const replacement = registry.register('open_settings', () => false);
|
||||
|
||||
replacement();
|
||||
|
||||
expect(registry.get('open_settings')).toBe(firstHandler);
|
||||
first();
|
||||
expect(registry.get('open_settings')).toBe(undefined);
|
||||
});
|
||||
|
||||
test('a later registration takes over after the first unregisters', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const firstHandler = () => undefined;
|
||||
const secondHandler = () => false;
|
||||
const first = registry.register('open_settings', firstHandler);
|
||||
registry.register('open_settings', secondHandler);
|
||||
|
||||
expect(registry.get('open_settings')).toBe(firstHandler);
|
||||
first();
|
||||
expect(registry.get('open_settings')).toBe(secondHandler);
|
||||
});
|
||||
|
||||
test('suspends all handlers until every idempotent cleanup completes', () => {
|
||||
const registry = new ShortcutRegistry();
|
||||
const handler = () => undefined;
|
||||
registry.register('open_settings', handler);
|
||||
|
||||
const resumeFirst = registry.suspend();
|
||||
const resumeSecond = registry.suspend();
|
||||
expect(registry.get('open_settings')).toBe(undefined);
|
||||
expect(registry.isSuspended()).toBe(true);
|
||||
|
||||
resumeFirst();
|
||||
resumeFirst();
|
||||
expect(registry.get('open_settings')).toBe(undefined);
|
||||
resumeSecond();
|
||||
resumeSecond();
|
||||
expect(registry.get('open_settings')).toBe(handler);
|
||||
expect(registry.isSuspended()).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ShortcutActionId } from './schema';
|
||||
|
||||
export type ShortcutHandler = (event: KeyboardEvent) => boolean | void;
|
||||
|
||||
interface RegisteredHandler {
|
||||
handler: ShortcutHandler;
|
||||
}
|
||||
|
||||
/** Active application command handlers, keyed by shortcut action ID. */
|
||||
export class ShortcutRegistry {
|
||||
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
|
||||
private suspensionCount = 0;
|
||||
private suspensionVersion = 0;
|
||||
|
||||
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
|
||||
const registration = { handler };
|
||||
const registered = this.handlers.get(actionId) ?? [];
|
||||
if (registered.length > 0 && typeof console !== 'undefined' && import.meta.env?.DEV) {
|
||||
// First registration wins at dispatch; a silent second registration is
|
||||
// almost always two components fighting over one action.
|
||||
console.warn(`[shortcuts] duplicate handler registration for "${actionId}" — only the first will dispatch`);
|
||||
}
|
||||
registered.push(registration);
|
||||
this.handlers.set(actionId, registered);
|
||||
return () => {
|
||||
const current = this.handlers.get(actionId);
|
||||
if (!current) return;
|
||||
const index = current.indexOf(registration);
|
||||
if (index === -1) return;
|
||||
current.splice(index, 1);
|
||||
if (current.length === 0) {
|
||||
this.handlers.delete(actionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
|
||||
if (this.suspensionCount > 0) return undefined;
|
||||
return this.handlers.get(actionId)?.[0]?.handler;
|
||||
}
|
||||
|
||||
/** Runs an action outside keyboard dispatch (command palette). Bypasses
|
||||
suspension: the invoking surface, not the keyboard, owns the gesture. */
|
||||
invoke(actionId: ShortcutActionId): boolean {
|
||||
const handler = this.handlers.get(actionId)?.[0]?.handler;
|
||||
if (!handler) return false;
|
||||
return handler(new KeyboardEvent('keydown')) !== false;
|
||||
}
|
||||
|
||||
/** Temporarily disables every registered application shortcut. */
|
||||
suspend(): () => void {
|
||||
this.suspensionCount += 1;
|
||||
this.suspensionVersion += 1;
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
this.suspensionCount -= 1;
|
||||
if (this.suspensionCount === 0) {
|
||||
this.suspensionVersion += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getSuspensionVersion(): number {
|
||||
return this.suspensionVersion;
|
||||
}
|
||||
|
||||
isSuspended(): boolean {
|
||||
return this.suspensionCount > 0;
|
||||
}
|
||||
|
||||
actionIds(): IterableIterator<ShortcutActionId> {
|
||||
return this.handlers.keys();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared registry for application commands registered by React surfaces. */
|
||||
export const shortcutRegistry = new ShortcutRegistry();
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
getShortcutBindingConflicts,
|
||||
getShortcutAction,
|
||||
parseShortcut,
|
||||
SHORTCUT_SCHEMA,
|
||||
type ShortcutCategory,
|
||||
} from './index';
|
||||
|
||||
describe('shortcut schema', () => {
|
||||
test('declares unique IDs and valid bindings for every application shortcut', () => {
|
||||
const ids = SHORTCUT_SCHEMA.map((action) => action.id);
|
||||
const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => {
|
||||
const chordCount = parseShortcut(action.defaultBinding)?.chords.length;
|
||||
return Boolean(action.category)
|
||||
&& chordCount !== undefined
|
||||
&& chordCount >= 1
|
||||
&& chordCount <= 2;
|
||||
});
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(hasValidMetadata).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the flattened schema grouped in Settings order', () => {
|
||||
const groupOrder: ShortcutCategory[] = [];
|
||||
for (const action of SHORTCUT_SCHEMA) {
|
||||
if (groupOrder.at(-1) !== action.category) {
|
||||
groupOrder.push(action.category);
|
||||
}
|
||||
}
|
||||
|
||||
expect(groupOrder).toEqual([
|
||||
'session',
|
||||
'models',
|
||||
'panels',
|
||||
'navigation',
|
||||
'application',
|
||||
]);
|
||||
});
|
||||
|
||||
test('derives settings labels for every customizable shortcut', () => {
|
||||
const customizable = getCustomizableShortcutActions();
|
||||
expect(customizable.length).toBeGreaterThan(0);
|
||||
expect(customizable.every((action) => (
|
||||
action.settingsLabelKey === `settings.openchamber.keyboardShortcuts.action.${action.id}.label`
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps the mod+k leader for open/go actions', () => {
|
||||
expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+k p');
|
||||
expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+k g');
|
||||
expect(getShortcutAction('open_session_list')?.defaultBinding).toBe('mod+k l');
|
||||
expect(getShortcutAction('open_timeline_dialog')?.defaultBinding).toBe('mod+k t');
|
||||
expect(getShortcutAction('toggle_prompt_navigator')?.defaultBinding).toBe('mod+k n');
|
||||
expect(getShortcutAction('toggle_services_menu')?.defaultBinding).toBe('mod+k i');
|
||||
expect(getShortcutAction('open_help')?.defaultBinding).toBe('mod+k h');
|
||||
expect(getShortcutAction('cycle_theme')?.defaultBinding).toBe('mod+k c');
|
||||
expect(getShortcutAction('focus_input')?.category).toBe('session');
|
||||
});
|
||||
|
||||
test('splits the held digit prefixes between session tabs and surfaces', () => {
|
||||
expect(getShortcutAction('switch_session_tab')?.defaultBinding).toBe('mod');
|
||||
expect(getShortcutAction('switch_context_surface')?.defaultBinding).toBe('mod+alt');
|
||||
});
|
||||
|
||||
test('every action ships with a default binding', () => {
|
||||
// Palette-only commands live outside this schema entirely; an action in
|
||||
// the schema without a binding would be dead weight in Settings.
|
||||
for (const action of SHORTCUT_SCHEMA) {
|
||||
expect(getEffectiveShortcutCombo(action.id)).not.toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves valid overrides and falls back from malformed bindings', () => {
|
||||
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k');
|
||||
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n');
|
||||
});
|
||||
|
||||
test('keeps internal bindings authoritative over persisted overrides', () => {
|
||||
expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s');
|
||||
expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s');
|
||||
});
|
||||
|
||||
test('detects conflicts against customizable and internal bindings', () => {
|
||||
const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p')
|
||||
.find((conflict) => conflict.action.id === 'open_command_palette');
|
||||
const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f')
|
||||
.find((conflict) => conflict.action.id === 'find_in_file');
|
||||
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
|
||||
.find((conflict) => conflict.action.id === 'save_file');
|
||||
const leaderPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+k')
|
||||
.find((conflict) => conflict.action.id === 'open_session_list');
|
||||
const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x')
|
||||
.find((conflict) => conflict.action.id === 'open_command_palette');
|
||||
|
||||
expect(customizableConflict?.kind).toBe('exact');
|
||||
expect(customizableConflict?.action.customizable).toBe(true);
|
||||
expect(internalConflict?.kind).toBe('exact');
|
||||
expect(internalConflict?.action.customizable).toBe(false);
|
||||
expect(internalPrefixConflict?.kind).toBe('prefix');
|
||||
expect(internalPrefixConflict?.action.customizable).toBe(false);
|
||||
expect(leaderPrefixConflict?.kind).toBe('prefix');
|
||||
expect(blockingPrefixConflict?.kind).toBe('prefix');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcut defaults', () => {
|
||||
// Two actions silently sharing a default binding would race at dispatch
|
||||
// (registry insertion order decides). Pairs that intentionally share a
|
||||
// combo because they can never be active in the same runtime must be
|
||||
// whitelisted here explicitly.
|
||||
const RUNTIME_EXCLUSIVE_BINDING_PAIRS: ReadonlyArray<ReadonlySet<string>> = [];
|
||||
|
||||
test('no two actions share a normalized default binding', () => {
|
||||
const byBinding = new Map<string, string[]>();
|
||||
for (const action of SHORTCUT_SCHEMA) {
|
||||
const combo = getEffectiveShortcutCombo(action.id);
|
||||
if (!combo) continue;
|
||||
const list = byBinding.get(combo) ?? [];
|
||||
list.push(action.id);
|
||||
byBinding.set(combo, list);
|
||||
}
|
||||
const conflicts = [...byBinding.entries()]
|
||||
.filter(([, ids]) => ids.length > 1)
|
||||
.filter(([, ids]) => !RUNTIME_EXCLUSIVE_BINDING_PAIRS.some(
|
||||
(pair) => ids.every((id) => pair.has(id)),
|
||||
))
|
||||
.map(([combo, ids]) => `"${combo}" shared by ${ids.join(', ')}`);
|
||||
expect(conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
test('overrides recorded under the flat-file era still resolve', () => {
|
||||
// The persisted override format is a flat Record<string, string> and
|
||||
// must keep resolving through the schema after the module split.
|
||||
const overrides = { close_session_tab: 'alt+q', open_command_palette: 'mod+shift+k' };
|
||||
expect(getEffectiveShortcutCombo('close_session_tab', overrides)).toBe('alt+q');
|
||||
expect(getEffectiveShortcutCombo('open_command_palette', overrides)).toBe('mod+shift+k');
|
||||
// Unknown ids stay inert rather than throwing.
|
||||
expect(getEffectiveShortcutCombo('close_session_tab', { ghost_action: 'mod+z', close_session_tab: 'alt+q' } as Record<string, string>)).toBe('alt+q');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
getShortcutConflict,
|
||||
isValidShortcutCombo,
|
||||
normalizeCombo,
|
||||
parseShortcut,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
type ShortcutConflict,
|
||||
} from './bindings';
|
||||
import { SHORTCUT_SCHEMA } from './config';
|
||||
|
||||
export { SHORTCUT_SCHEMA } from './config';
|
||||
|
||||
export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
|
||||
export type ShortcutActionId = ShortcutAction['id'];
|
||||
export type ShortcutCategory = ShortcutAction['category'];
|
||||
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
|
||||
/** 'contextual-prefix' is kept in the union for the recording dialog's
|
||||
messaging even though no default layout produces it any more. */
|
||||
export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix';
|
||||
export type ShortcutBindingConflict = {
|
||||
action: ShortcutAction;
|
||||
kind: ShortcutBindingConflictKind;
|
||||
};
|
||||
|
||||
export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||
return SHORTCUT_SCHEMA.find((action) => action.id === id);
|
||||
}
|
||||
|
||||
export function getCustomizableShortcutActions(): ReadonlyArray<CustomizableShortcutAction> {
|
||||
return SHORTCUT_SCHEMA.filter(
|
||||
(action): action is CustomizableShortcutAction => action.customizable,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEffectiveShortcutCombo(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) return '';
|
||||
const defaultBinding = action.defaultBinding === UNASSIGNED_SHORTCUT ? '' : action.defaultBinding;
|
||||
if (!action.customizable) return defaultBinding;
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string') {
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) return '';
|
||||
if (isValidShortcutCombo(normalized)) return normalized;
|
||||
}
|
||||
|
||||
return defaultBinding;
|
||||
}
|
||||
|
||||
export function getEffectiveShortcutPrefix(
|
||||
actionId: string,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) return '';
|
||||
if (!action.customizable) return action.defaultBinding;
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string' && override.trim() !== '') {
|
||||
const normalized = normalizeCombo(override);
|
||||
if (normalized === UNASSIGNED_SHORTCUT) return UNASSIGNED_SHORTCUT;
|
||||
const chord = parseShortcut(normalized)?.chords[0];
|
||||
if (chord && (chord.modifiers.size > 0 || chord.key)) return normalized;
|
||||
}
|
||||
|
||||
return action.defaultBinding;
|
||||
}
|
||||
|
||||
export function getShortcutBindingConflicts(
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutBindingConflict[] {
|
||||
const conflicts: ShortcutBindingConflict[] = [];
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) return conflicts;
|
||||
for (const candidate of SHORTCUT_SCHEMA) {
|
||||
if (candidate.id === actionId) continue;
|
||||
const candidateCombo = ('prefixStyle' in candidate && candidate.prefixStyle)
|
||||
? getEffectiveShortcutPrefix(candidate.id, overrides)
|
||||
: getEffectiveShortcutCombo(candidate.id, overrides);
|
||||
const kind = getShortcutConflict(combo, candidateCombo);
|
||||
if (!kind) continue;
|
||||
conflicts.push({ action: candidate, kind });
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
@@ -22,7 +22,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
||||
registry's default order and appends any missing surfaces.
|
||||
- `getVisibleContextRailSurfaces` is the single visibility filter shared by the
|
||||
rail and the global surface-switch shortcut (`switch_context_surface` in
|
||||
`lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled,
|
||||
`lib/shortcuts`): it drops surfaces the user hid
|
||||
(`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing
|
||||
configure button — `ContextRailSurfacesDialog`), drops the plan surface
|
||||
unless plan mode is enabled,
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides
|
||||
`has-content` surfaces until a tab of their mode exists. Both consumers use
|
||||
it so the digit shown on a rail badge always maps to the same surface the
|
||||
|
||||
@@ -187,6 +187,9 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac
|
||||
|
||||
type VisibleRailSurfacesOptions = {
|
||||
railOrder: readonly string[];
|
||||
/** Surfaces the user chose to hide from the rail (and from the digit
|
||||
shortcuts, which share this filter). */
|
||||
hiddenSurfaces?: readonly string[];
|
||||
planModeEnabled: boolean;
|
||||
isVSCode: boolean;
|
||||
screenWidth: number;
|
||||
@@ -203,6 +206,9 @@ type VisibleRailSurfacesOptions = {
|
||||
*/
|
||||
export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => {
|
||||
return sortContextSurfaces(options.railOrder).filter((surface) => {
|
||||
if (options.hiddenSurfaces?.includes(surface.id)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.id === 'plan' && !options.planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { isDesktopShell } from "@/lib/desktop";
|
||||
import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch";
|
||||
import type { I18nKey } from "@/lib/i18n";
|
||||
|
||||
@@ -28,24 +27,6 @@ export const getRevealLabelKey = (): I18nKey => {
|
||||
return 'common.revealPath.fileManager';
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the platform-appropriate modifier key is pressed.
|
||||
* On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey).
|
||||
* Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app.
|
||||
*/
|
||||
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
|
||||
return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the platform-appropriate modifier key label.
|
||||
* On macOS desktop app: "⌘", on other platforms or web: "Ctrl"
|
||||
* Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app.
|
||||
*/
|
||||
export const getModifierLabel = (): string => {
|
||||
return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl';
|
||||
};
|
||||
|
||||
export const truncatePathMiddle = (
|
||||
value: string,
|
||||
options?: { maxLength?: number }
|
||||
|
||||
Reference in New Issue
Block a user