refactor(i18n): route command + settings manifest labels through the catalog (T-462)

Apply the manifest-i18n foundation across every extension: each
CommandContribution (45, command-palette + menu) gains titleKey + i18nNamespace,
and each SettingsCategory/section/field/option gains its key — with the English
text added to the owning extension's catalog. The settings renderer
(settings_category_view + settings_modal) threads the category's i18nNamespace
down and resolves every label/help/option through it; new catalogs created for
view, cli-install, keybindings-ui. No en_US behaviour change — the command
palette, menus, and settings panel now localize from the catalog (D-21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 10:20:19 +02:00
co-authored by Claude Opus 4.8
parent 198c8b18b5
commit 37fa255a63
28 changed files with 419 additions and 72 deletions
+51 -5
View File
@@ -76,6 +76,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.new-secondary', id: 'claude.new-secondary',
command: 'claude.new-secondary', command: 'claude.new-secondary',
title: 'Claude: open a secondary session', title: 'Claude: open a secondary session',
titleKey: 'command.newSecondary',
i18nNamespace: id,
run: (_) async { run: (_) async {
_hostKey.currentState?.addSecondary(); _hostKey.currentState?.addSecondary();
return IpcResponse.ok(id: '', data: const {'status': 'spawned'}); return IpcResponse.ok(id: '', data: const {'status': 'spawned'});
@@ -85,12 +87,16 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.kill-all-sessions', id: 'claude.kill-all-sessions',
command: 'claude.kill-all-sessions', command: 'claude.kill-all-sessions',
title: 'Claude: kill all sessions for this repo', title: 'Claude: kill all sessions for this repo',
titleKey: 'command.killAllSessions',
i18nNamespace: id,
run: _killAllSessions, run: _killAllSessions,
), ),
CommandContribution( CommandContribution(
id: 'claude.session-storage', id: 'claude.session-storage',
command: 'claude.session-storage', command: 'claude.session-storage',
title: 'Claude: session storage (disk usage + cleanup)', title: 'Claude: session storage (disk usage + cleanup)',
titleKey: 'command.sessionStorage',
i18nNamespace: id,
run: _manageStorage, run: _manageStorage,
), ),
// T-235: cycle how aggressively the activity card folds meta steps // T-235: cycle how aggressively the activity card folds meta steps
@@ -100,33 +106,40 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.activity.fold-level', id: 'claude.activity.fold-level',
command: 'claude.activity.fold-level', command: 'claude.activity.fold-level',
title: 'Claude: cycle activity fold level', title: 'Claude: cycle activity fold level',
titleKey: 'command.activity.foldLevel',
i18nNamespace: id,
run: _cycleFoldLevel, run: _cycleFoldLevel,
), ),
// Activity settings category (T-453) — the fold level as a schema field, // Activity settings category (T-453) — the fold level as a schema field,
// written to kActivityFoldLevelKey; the panes already rebuild off the // written to kActivityFoldLevelKey; the panes already rebuild off the
// settings notifier, so picking a level applies live. // settings notifier, so picking a level applies live.
const SettingsCategoryContribution( SettingsCategoryContribution(
id: 'activity', id: 'activity',
category: SettingsCategory( category: SettingsCategory(
id: 'activity', id: 'activity',
title: 'Activity', title: 'Activity',
titleKey: 'settings.activity.title',
i18nNamespace: id,
iconName: 'cards-three', iconName: 'cards-three',
priority: 50, priority: 50,
sections: [ sections: [
SettingsSection( SettingsSection(
label: 'Conversation', label: 'Conversation',
labelKey: 'settings.activity.conversation.label',
fields: [ fields: [
SettingsField( SettingsField(
key: kActivityFoldLevelKey, key: kActivityFoldLevelKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Fold level', label: 'Fold level',
labelKey: 'settings.activity.foldLevel.label',
help: 'How aggressively the conversation folds tool calls, thinking, and results.', help: 'How aggressively the conversation folds tool calls, thinking, and results.',
helpKey: 'settings.activity.foldLevel.help',
defaultValue: 'tools', defaultValue: 'tools',
options: [ options: [
SettingsOption(value: 'none', label: 'Show everything'), SettingsOption(value: 'none', label: 'Show everything', labelKey: 'settings.activity.opt.none'),
SettingsOption(value: 'tools', label: 'Fold tool calls'), SettingsOption(value: 'tools', label: 'Fold tool calls', labelKey: 'settings.activity.opt.tools'),
SettingsOption(value: 'thinking', label: 'Fold tools + thinking'), SettingsOption(value: 'thinking', label: 'Fold tools + thinking', labelKey: 'settings.activity.opt.thinking'),
SettingsOption(value: 'everything', label: 'Fold all but prose'), SettingsOption(value: 'everything', label: 'Fold all but prose', labelKey: 'settings.activity.opt.everything'),
], ],
), ),
], ],
@@ -142,17 +155,22 @@ class ClaudeExtension extends ClideExtension {
category: SettingsCategory( category: SettingsCategory(
id: 'claude', id: 'claude',
title: 'Claude', title: 'Claude',
titleKey: 'settings.claude.title',
i18nNamespace: id,
iconName: 'sparkle', iconName: 'sparkle',
priority: 40, priority: 40,
sections: [ sections: [
SettingsSection( SettingsSection(
label: 'New session defaults', label: 'New session defaults',
labelKey: 'settings.claude.newSessionDefaults.label',
fields: [ fields: [
SettingsField( SettingsField(
key: kDefaultModelKey, key: kDefaultModelKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Model', label: 'Model',
labelKey: 'settings.claude.model.label',
help: 'Model for new sessions.', help: 'Model for new sessions.',
helpKey: 'settings.claude.model.help',
defaultValue: 'default', defaultValue: 'default',
options: [for (final m in kFallbackModels) SettingsOption(value: m.value, label: m.displayName)], options: [for (final m in kFallbackModels) SettingsOption(value: m.value, label: m.displayName)],
), ),
@@ -160,7 +178,9 @@ class ClaudeExtension extends ClideExtension {
key: kDefaultEffortKey, key: kDefaultEffortKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Effort', label: 'Effort',
labelKey: 'settings.claude.effort.label',
help: 'Reasoning effort for new sessions (applied via --effort at spawn).', help: 'Reasoning effort for new sessions (applied via --effort at spawn).',
helpKey: 'settings.claude.effort.help',
defaultValue: 'high', defaultValue: 'high',
options: [for (final l in kEffortLevels) SettingsOption(value: l.value, label: l.displayName)], options: [for (final l in kEffortLevels) SettingsOption(value: l.value, label: l.displayName)],
), ),
@@ -168,7 +188,9 @@ class ClaudeExtension extends ClideExtension {
key: kDefaultPermissionModeKey, key: kDefaultPermissionModeKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Permission mode', label: 'Permission mode',
labelKey: 'settings.claude.permissionMode.label',
help: 'Starting permission mode for new sessions.', help: 'Starting permission mode for new sessions.',
helpKey: 'settings.claude.permissionMode.help',
defaultValue: 'default', defaultValue: 'default',
options: [for (final p in kPermissionModes) SettingsOption(value: p.value, label: p.displayName)], options: [for (final p in kPermissionModes) SettingsOption(value: p.value, label: p.displayName)],
), ),
@@ -183,6 +205,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.show', id: 'claude.agent.show',
command: 'claude.agent.show', command: 'claude.agent.show',
title: 'Claude: show an agent session pane', title: 'Claude: show an agent session pane',
titleKey: 'command.agent.show',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -194,6 +218,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.hide', id: 'claude.agent.hide',
command: 'claude.agent.hide', command: 'claude.agent.hide',
title: 'Claude: hide an agent session pane', title: 'Claude: hide an agent session pane',
titleKey: 'command.agent.hide',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -205,6 +231,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.close', id: 'claude.agent.close',
command: 'claude.agent.close', command: 'claude.agent.close',
title: 'Claude: close (kill) an agent session', title: 'Claude: close (kill) an agent session',
titleKey: 'command.agent.close',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -216,6 +244,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.mute', id: 'claude.agent.mute',
command: 'claude.agent.mute', command: 'claude.agent.mute',
title: 'Claude: mute broker delivery to an agent session', title: 'Claude: mute broker delivery to an agent session',
titleKey: 'command.agent.mute',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -227,6 +257,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.unmute', id: 'claude.agent.unmute',
command: 'claude.agent.unmute', command: 'claude.agent.unmute',
title: 'Claude: unmute broker delivery to an agent session', title: 'Claude: unmute broker delivery to an agent session',
titleKey: 'command.agent.unmute',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -239,6 +271,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.inject-message', id: 'claude.agent.inject-message',
command: 'claude.agent.inject-message', command: 'claude.agent.inject-message',
title: 'Claude: inject a text turn into an agent session', title: 'Claude: inject a text turn into an agent session',
titleKey: 'command.agent.injectMessage',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -257,6 +291,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.set-permission-mode', id: 'claude.agent.set-permission-mode',
command: 'claude.agent.set-permission-mode', command: 'claude.agent.set-permission-mode',
title: 'Claude: set permission mode for an agent session', title: 'Claude: set permission mode for an agent session',
titleKey: 'command.agent.setPermissionMode',
i18nNamespace: id,
run: (args) async { run: (args) async {
final id = args.firstOrNull; final id = args.firstOrNull;
if (id == null) return _userErr('missing session id'); if (id == null) return _userErr('missing session id');
@@ -276,6 +312,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.mode.cycle', id: 'claude.mode.cycle',
command: 'claude.mode.cycle', command: 'claude.mode.cycle',
title: 'Claude: Cycle permission mode', title: 'Claude: Cycle permission mode',
titleKey: 'command.mode.cycle',
i18nNamespace: id,
run: (_) async { run: (_) async {
final managed = _orchestrator?.byId('primary'); final managed = _orchestrator?.byId('primary');
if (managed == null) return _notFound('no primary session'); if (managed == null) return _notFound('no primary session');
@@ -289,6 +327,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.task.reassign', id: 'claude.task.reassign',
command: 'claude.task.reassign', command: 'claude.task.reassign',
title: 'Claude: reassign a shared task to an agent', title: 'Claude: reassign a shared task to an agent',
titleKey: 'command.task.reassign',
i18nNamespace: id,
run: (args) async { run: (args) async {
if (args.length < 2) return _userErr('usage: <taskId> <sessionId>'); if (args.length < 2) return _userErr('usage: <taskId> <sessionId>');
final taskId = args[0]; final taskId = args[0];
@@ -319,6 +359,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.team-chat.open', id: 'claude.team-chat.open',
command: 'claude.team-chat.open', command: 'claude.team-chat.open',
title: 'Claude: open the team chat pane', title: 'Claude: open the team chat pane',
titleKey: 'command.teamChat.open',
i18nNamespace: id,
run: (args) async { run: (args) async {
_ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat'); _ctx?.panels.activateTab(Slots.workspace, 'claude.team-chat');
return IpcResponse.ok(id: '', data: const {'status': 'opened'}); return IpcResponse.ok(id: '', data: const {'status': 'opened'});
@@ -331,6 +373,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.team-chat.post', id: 'claude.team-chat.post',
command: 'claude.team-chat.post', command: 'claude.team-chat.post',
title: 'Claude: post a message into the team channel as the user', title: 'Claude: post a message into the team channel as the user',
titleKey: 'command.teamChat.post',
i18nNamespace: id,
run: (args) async { run: (args) async {
if (args.isEmpty) return _userErr('usage: [@name] <text>'); if (args.isEmpty) return _userErr('usage: [@name] <text>');
final raw = args.join(' '); final raw = args.join(' ');
@@ -357,6 +401,8 @@ class ClaudeExtension extends ClideExtension {
id: 'claude.agent.fork', id: 'claude.agent.fork',
command: 'claude.agent.fork', command: 'claude.agent.fork',
title: 'Claude: fork a managed session into a new branch session', title: 'Claude: fork a managed session into a new branch session',
titleKey: 'command.agent.fork',
i18nNamespace: id,
run: (args) async { run: (args) async {
final sourceId = args.firstOrNull; final sourceId = args.firstOrNull;
if (sourceId == null) { if (sourceId == null) {
@@ -65,6 +65,8 @@ class CliInstallExtension extends ClideExtension {
id: 'clide.installCli', id: 'clide.installCli',
command: 'clide.installCli', command: 'clide.installCli',
title: "clide: Install 'clide' command in PATH", title: "clide: Install 'clide' command in PATH",
titleKey: 'command.clide.installCli',
i18nNamespace: id,
run: (_) async { run: (_) async {
final r = _resolved.install(); final r = _resolved.install();
final ctx = _ctx; final ctx = _ctx;
+8 -1
View File
@@ -24,7 +24,14 @@ class DeepLinkExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
CommandContribution(id: 'deeplink.invoke', command: 'deeplink.invoke', title: 'Open a clide:// deep link', run: _invoke), CommandContribution(
id: 'deeplink.invoke',
command: 'deeplink.invoke',
title: 'Open a clide:// deep link',
titleKey: 'command.deeplink.invoke',
i18nNamespace: id,
run: _invoke,
),
]; ];
@override @override
+83 -8
View File
@@ -16,13 +16,30 @@ class DefaultLayoutExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
_preset ?? classicPreset(), _preset ?? classicPreset(),
CommandContribution(id: 'layout.reset', command: 'layout.reset', title: 'Layout: Reset to Classic', run: _reset), CommandContribution(
CommandContribution(id: 'palette.toggle', command: 'palette.toggle', title: 'Command Palette', defaultBinding: 'ctrl+shift+p', run: _togglePalette), id: 'layout.reset',
command: 'layout.reset',
title: 'Layout: Reset to Classic',
titleKey: 'command.reset',
i18nNamespace: id,
run: _reset,
),
CommandContribution(
id: 'palette.toggle',
command: 'palette.toggle',
title: 'Command Palette',
titleKey: 'command.palette.toggle',
i18nNamespace: id,
defaultBinding: 'ctrl+shift+p',
run: _togglePalette,
),
// Collapse toggles (D-051, D-054) // Collapse toggles (D-051, D-054)
CommandContribution( CommandContribution(
id: 'sidebar.collapse', id: 'sidebar.collapse',
command: 'sidebar.collapse', command: 'sidebar.collapse',
title: 'Toggle Sidebar Collapse', title: 'Toggle Sidebar Collapse',
titleKey: 'command.sidebar.collapse',
i18nNamespace: id,
defaultBinding: 'ctrl+shift+1', defaultBinding: 'ctrl+shift+1',
run: _collapseSidebar, run: _collapseSidebar,
), ),
@@ -30,19 +47,55 @@ class DefaultLayoutExtension extends ClideExtension {
id: 'context.collapse', id: 'context.collapse',
command: 'context.collapse', command: 'context.collapse',
title: 'Toggle Context Panel Collapse', title: 'Toggle Context Panel Collapse',
titleKey: 'command.context.collapse',
i18nNamespace: id,
defaultBinding: 'ctrl+shift+3', defaultBinding: 'ctrl+shift+3',
run: _collapseContext, run: _collapseContext,
), ),
// Panel focus (D-054) // Panel focus (D-054)
CommandContribution(id: 'panel.focus.left', command: 'panel.focus.left', title: 'Focus Left Panel', defaultBinding: 'ctrl+1', run: _focusLeft), CommandContribution(
CommandContribution(id: 'panel.focus.middle', command: 'panel.focus.middle', title: 'Focus Middle Panel', defaultBinding: 'ctrl+2', run: _focusMiddle), id: 'panel.focus.left',
CommandContribution(id: 'panel.focus.right', command: 'panel.focus.right', title: 'Focus Right Panel', defaultBinding: 'ctrl+3', run: _focusRight), command: 'panel.focus.left',
title: 'Focus Left Panel',
titleKey: 'command.panel.focus.left',
i18nNamespace: id,
defaultBinding: 'ctrl+1',
run: _focusLeft,
),
CommandContribution(
id: 'panel.focus.middle',
command: 'panel.focus.middle',
title: 'Focus Middle Panel',
titleKey: 'command.panel.focus.middle',
i18nNamespace: id,
defaultBinding: 'ctrl+2',
run: _focusMiddle,
),
CommandContribution(
id: 'panel.focus.right',
command: 'panel.focus.right',
title: 'Focus Right Panel',
titleKey: 'command.panel.focus.right',
i18nNamespace: id,
defaultBinding: 'ctrl+3',
run: _focusRight,
),
// Focus mode (D-052, D-054) // Focus mode (D-052, D-054)
CommandContribution(id: 'panel.focusMode', command: 'panel.focusMode', title: 'Toggle Focus Mode', defaultBinding: 'ctrl+.', run: _toggleFocusMode), CommandContribution(
id: 'panel.focusMode',
command: 'panel.focusMode',
title: 'Toggle Focus Mode',
titleKey: 'command.panel.focusMode',
i18nNamespace: id,
defaultBinding: 'ctrl+.',
run: _toggleFocusMode,
),
CommandContribution( CommandContribution(
id: 'panel.focusMode.exit', id: 'panel.focusMode.exit',
command: 'panel.focusMode.exit', command: 'panel.focusMode.exit',
title: 'Exit Focus Mode', title: 'Exit Focus Mode',
titleKey: 'command.panel.focusMode.exit',
i18nNamespace: id,
defaultBinding: 'escape', defaultBinding: 'escape',
// Stand down in Vim insert/visual mode so Esc returns to normal // Stand down in Vim insert/visual mode so Esc returns to normal
// mode instead of closing the editor (T-257). Symmetric with // mode instead of closing the editor (T-257). Symmetric with
@@ -51,8 +104,24 @@ class DefaultLayoutExtension extends ClideExtension {
run: _exitFocusMode, run: _exitFocusMode,
), ),
// Editor split (D-049, D-054) // Editor split (D-049, D-054)
CommandContribution(id: 'editor.open', command: 'editor.open', title: 'Open Editor', defaultBinding: 'ctrl+e', run: _openEditor), CommandContribution(
CommandContribution(id: 'editor.close', command: 'editor.close', title: 'Close Editor', defaultBinding: 'ctrl+w', run: _closeEditor), id: 'editor.open',
command: 'editor.open',
title: 'Open Editor',
titleKey: 'command.editor.open',
i18nNamespace: id,
defaultBinding: 'ctrl+e',
run: _openEditor,
),
CommandContribution(
id: 'editor.close',
command: 'editor.close',
title: 'Close Editor',
titleKey: 'command.editor.close',
i18nNamespace: id,
defaultBinding: 'ctrl+w',
run: _closeEditor,
),
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every // Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2, // preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
// once a global multi-chord matcher lands — see T-404). // once a global multi-chord matcher lands — see T-404).
@@ -60,6 +129,8 @@ class DefaultLayoutExtension extends ClideExtension {
id: 'workspace.tab.next', id: 'workspace.tab.next',
command: 'workspace.tab.next', command: 'workspace.tab.next',
title: 'Next Workspace Tab', title: 'Next Workspace Tab',
titleKey: 'command.workspace.tab.next',
i18nNamespace: id,
defaultBinding: 'ctrl+pagedown', defaultBinding: 'ctrl+pagedown',
run: _nextWorkspaceTab, run: _nextWorkspaceTab,
), ),
@@ -67,6 +138,8 @@ class DefaultLayoutExtension extends ClideExtension {
id: 'workspace.tab.previous', id: 'workspace.tab.previous',
command: 'workspace.tab.previous', command: 'workspace.tab.previous',
title: 'Previous Workspace Tab', title: 'Previous Workspace Tab',
titleKey: 'command.workspace.tab.previous',
i18nNamespace: id,
defaultBinding: 'ctrl+pageup', defaultBinding: 'ctrl+pageup',
run: _prevWorkspaceTab, run: _prevWorkspaceTab,
), ),
@@ -76,6 +149,8 @@ class DefaultLayoutExtension extends ClideExtension {
id: 'sidebar.section.${i + 1}', id: 'sidebar.section.${i + 1}',
command: 'sidebar.section.${i + 1}', command: 'sidebar.section.${i + 1}',
title: 'Sidebar: Section ${i + 1}', title: 'Sidebar: Section ${i + 1}',
titleKey: 'command.sidebar.section.${i + 1}',
i18nNamespace: id,
defaultBinding: 'alt+${i + 1}', defaultBinding: 'alt+${i + 1}',
run: (args) => _switchSidebarSection(i), run: (args) => _switchSidebarSection(i),
), ),
+13 -2
View File
@@ -20,17 +20,28 @@ class ExtensionsUiExtension extends ClideExtension {
@override @override
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
SettingsControlContribution(id: 'extensions-ui.notice-control', customId: 'extensions.notice', builder: (_) => const ExtensionsNotice()), SettingsControlContribution(id: 'extensions-ui.notice-control', customId: 'extensions.notice', builder: (_) => const ExtensionsNotice()),
const SettingsCategoryContribution( SettingsCategoryContribution(
id: 'extensions', id: 'extensions',
category: SettingsCategory( category: SettingsCategory(
id: 'extensions', id: 'extensions',
title: 'Extensions', title: 'Extensions',
titleKey: 'settings.extensions.title',
i18nNamespace: id,
iconName: 'puzzle-piece', iconName: 'puzzle-piece',
priority: 80, priority: 80,
sections: [ sections: [
SettingsSection( SettingsSection(
label: '', label: '',
fields: [SettingsField(key: 'app.extensions._notice', kind: SettingsFieldKind.custom, label: '', customId: 'extensions.notice')], labelKey: 'settings.extensions.section.notice',
fields: [
SettingsField(
key: 'app.extensions._notice',
kind: SettingsFieldKind.custom,
label: '',
labelKey: 'settings.extensions.field.notice.label',
customId: 'extensions.notice',
),
],
), ),
], ],
), ),
+12 -5
View File
@@ -37,6 +37,8 @@ class KeybindingsUiExtension extends ClideExtension {
id: 'keymap.preset.${entry.key}', id: 'keymap.preset.${entry.key}',
command: 'keymap.preset.${entry.key}', command: 'keymap.preset.${entry.key}',
title: entry.value, title: entry.value,
titleKey: 'command.preset.${entry.key}',
i18nNamespace: id,
run: (_) async { run: (_) async {
await _keymap?.setPreset(entry.key); await _keymap?.setPreset(entry.key);
return IpcResponse.ok(id: '', data: {'preset': entry.key}); return IpcResponse.ok(id: '', data: {'preset': entry.key});
@@ -46,29 +48,34 @@ class KeybindingsUiExtension extends ClideExtension {
// from kKeymapPresetSetting; picking one runs `keymap.preset.<value>` // from kKeymapPresetSetting; picking one runs `keymap.preset.<value>`
// (applyCommandPrefix), which calls KeymapService.setPreset — persisting // (applyCommandPrefix), which calls KeymapService.setPreset — persisting
// and reloading the layered keymap live. // and reloading the layered keymap live.
const SettingsCategoryContribution( SettingsCategoryContribution(
id: 'keymap', id: 'keymap',
category: SettingsCategory( category: SettingsCategory(
id: 'keymap', id: 'keymap',
title: 'Keymap', title: 'Keymap',
titleKey: 'settings.keymap.title',
i18nNamespace: id,
iconName: 'keyboard', iconName: 'keyboard',
priority: 20, priority: 20,
sections: [ sections: [
SettingsSection( SettingsSection(
label: 'Preset', label: 'Preset',
labelKey: 'settings.keymap.section.preset',
fields: [ fields: [
SettingsField( SettingsField(
key: kKeymapPresetSetting, key: kKeymapPresetSetting,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Active preset', label: 'Active preset',
labelKey: 'settings.keymap.field.preset.label',
help: 'Keyboard layout for the whole app.', help: 'Keyboard layout for the whole app.',
helpKey: 'settings.keymap.field.preset.help',
defaultValue: 'default', defaultValue: 'default',
applyCommandPrefix: 'keymap.preset.', applyCommandPrefix: 'keymap.preset.',
options: [ options: [
SettingsOption(value: 'default', label: 'Default'), SettingsOption(value: 'default', label: 'Default', labelKey: 'settings.keymap.field.preset.option.default'),
SettingsOption(value: 'vim', label: 'Vim'), SettingsOption(value: 'vim', label: 'Vim', labelKey: 'settings.keymap.field.preset.option.vim'),
SettingsOption(value: 'vscode', label: 'VS Code'), SettingsOption(value: 'vscode', label: 'VS Code', labelKey: 'settings.keymap.field.preset.option.vscode'),
SettingsOption(value: 'jetbrains', label: 'JetBrains'), SettingsOption(value: 'jetbrains', label: 'JetBrains', labelKey: 'settings.keymap.field.preset.option.jetbrains'),
], ],
), ),
], ],
+8
View File
@@ -30,6 +30,8 @@ class MenuBarExtension extends ClideExtension {
id: 'file.openFolder', id: 'file.openFolder',
command: 'file.openFolder', command: 'file.openFolder',
title: 'File: Open Folder…', title: 'File: Open Folder…',
titleKey: 'command.file.openFolder',
i18nNamespace: id,
run: (_) async { run: (_) async {
await _file.openFolder(); await _file.openFolder();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
@@ -39,6 +41,8 @@ class MenuBarExtension extends ClideExtension {
id: 'file.newWindow', id: 'file.newWindow',
command: 'file.newWindow', command: 'file.newWindow',
title: 'File: New Window', title: 'File: New Window',
titleKey: 'command.file.newWindow',
i18nNamespace: id,
run: (_) async { run: (_) async {
_file.newWindow(); _file.newWindow();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
@@ -48,6 +52,8 @@ class MenuBarExtension extends ClideExtension {
id: 'file.closeWorkspace', id: 'file.closeWorkspace',
command: 'file.closeWorkspace', command: 'file.closeWorkspace',
title: 'File: Close Project', title: 'File: Close Project',
titleKey: 'command.file.closeWorkspace',
i18nNamespace: id,
run: (_) async { run: (_) async {
_file.closeWorkspace(); _file.closeWorkspace();
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
@@ -57,6 +63,8 @@ class MenuBarExtension extends ClideExtension {
id: 'help.about', id: 'help.about',
command: 'help.about', command: 'help.about',
title: 'Help: About clide', title: 'Help: About clide',
titleKey: 'command.help.about',
i18nNamespace: id,
run: (_) async { run: (_) async {
services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss())); services.dialog.show<Object>((ctx, dismiss) => AboutDialog(onDismiss: () => dismiss()));
return IpcResponse.ok(id: '', data: const {}); return IpcResponse.ok(id: '', data: const {});
+2
View File
@@ -61,6 +61,8 @@ class OutputExtension extends ClideExtension {
id: 'dock.toggle', id: 'dock.toggle',
command: 'dock.toggle', command: 'dock.toggle',
title: 'Toggle output dock', title: 'Toggle output dock',
titleKey: 'command.dock.toggle',
i18nNamespace: id,
defaultBinding: 'ctrl+j', defaultBinding: 'ctrl+j',
run: (_) async { run: (_) async {
final ctx = _ctx; final ctx = _ctx;
+9 -1
View File
@@ -27,7 +27,15 @@ class SettingsUiExtension extends ClideExtension {
List<ContributionPoint> get contributions => [ List<ContributionPoint> get contributions => [
// Opens the Settings panel. Palette + File-menu entry come for free off // Opens the Settings panel. Palette + File-menu entry come for free off
// the title; ctrl+, is the conventional settings shortcut. // the title; ctrl+, is the conventional settings shortcut.
CommandContribution(id: 'settings.open', command: 'settings.open', title: 'Settings…', defaultBinding: 'ctrl+,', run: _open), CommandContribution(
id: 'settings.open',
command: 'settings.open',
title: 'Settings…',
titleKey: 'command.open',
i18nNamespace: id,
defaultBinding: 'ctrl+,',
run: _open,
),
]; ];
Future<IpcResponse> _open(List<String> args) async { Future<IpcResponse> _open(List<String> args) async {
@@ -5,6 +5,15 @@ import 'package:flutter/widgets.dart';
/// i18n namespace shared with the settings modal shell. /// i18n namespace shared with the settings modal shell.
const _settingsNs = 'builtin.settings-ui'; const _settingsNs = 'builtin.settings-ui';
/// Resolve a schema label through the catalog under the owning category's
/// [namespace], falling back to the English [english] label when the [key] is
/// null or the [namespace] is null/empty (T-462). Short-circuiting before the
/// lookup keeps key-less categories warning-free and pixel-identical.
String _localized(BuildContext context, String? key, String? namespace, String english) {
if (key == null || namespace == null || namespace.isEmpty) return english;
return ClideSettings.i18n.string(context, key, namespace: namespace, placeholder: english);
}
/// The schema-driven field renderer (T-448, epic T-444): turns a /// The schema-driven field renderer (T-448, epic T-444): turns a
/// [SettingsCategory] into carded sections of field rows. Each field binds to a /// [SettingsCategory] into carded sections of field rows. Each field binds to a
/// `SettingsStore` key — read with `get` (falling back to the schema default), /// `SettingsStore` key — read with `get` (falling back to the schema default),
@@ -28,7 +37,7 @@ class SettingsCategoryView extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [for (final section in category.sections) _SectionCard(section: section, store: store)], children: [for (final section in category.sections) _SectionCard(section: section, store: store, i18nNamespace: category.i18nNamespace)],
), ),
), ),
); );
@@ -37,6 +46,9 @@ class SettingsCategoryView extends StatelessWidget {
/// True when [field] matches [query] (case-insensitive; label or help). An /// True when [field] matches [query] (case-insensitive; label or help). An
/// empty query matches everything. /// empty query matches everything.
///
/// Context-free: matches the English label/help only — display localizes the
/// shown text, but search-by-translation is out of scope (T-462).
bool settingsFieldMatches(SettingsField field, String query) { bool settingsFieldMatches(SettingsField field, String query) {
final q = query.trim().toLowerCase(); final q = query.trim().toLowerCase();
if (q.isEmpty) return true; if (q.isEmpty) return true;
@@ -49,7 +61,8 @@ List<SettingsSection> settingsMatchingSections(SettingsCategory category, String
final out = <SettingsSection>[]; final out = <SettingsSection>[];
for (final s in category.sections) { for (final s in category.sections) {
final fields = s.fields.where((f) => settingsFieldMatches(f, query)).toList(); final fields = s.fields.where((f) => settingsFieldMatches(f, query)).toList();
if (fields.isNotEmpty) out.add(SettingsSection(label: s.label, fields: fields)); // Carry labelKey so search-result section headers still localize (T-462).
if (fields.isNotEmpty) out.add(SettingsSection(label: s.label, fields: fields, labelKey: s.labelKey));
} }
return out; return out;
} }
@@ -82,11 +95,16 @@ class SettingsSearchResults extends StatelessWidget {
blocks.add( blocks.add(
Padding( Padding(
padding: EdgeInsets.only(top: blocks.isEmpty ? 0 : 14, bottom: 8), padding: EdgeInsets.only(top: blocks.isEmpty ? 0 : 14, bottom: 8),
child: ClideText(c.title, fontSize: 13, fontWeight: FontWeight.w600, color: tokens.globalForeground), child: ClideText(
_localized(context, c.titleKey, c.i18nNamespace, c.title),
fontSize: 13,
fontWeight: FontWeight.w600,
color: tokens.globalForeground,
),
), ),
); );
for (final s in sections) { for (final s in sections) {
blocks.add(_SectionCard(section: s, store: store)); blocks.add(_SectionCard(section: s, store: store, i18nNamespace: c.i18nNamespace));
} }
} }
if (blocks.isEmpty) { if (blocks.isEmpty) {
@@ -112,24 +130,28 @@ class SettingsSearchResults extends StatelessWidget {
/// One section: a small-caps header above an elevated card of field rows. /// One section: a small-caps header above an elevated card of field rows.
class _SectionCard extends StatelessWidget { class _SectionCard extends StatelessWidget {
const _SectionCard({required this.section, required this.store}); const _SectionCard({required this.section, required this.store, this.i18nNamespace});
final SettingsSection section; final SettingsSection section;
final SettingsStore store; final SettingsStore store;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
final sectionLabel = _localized(context, section.labelKey, i18nNamespace, section.label);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 18), padding: const EdgeInsets.only(bottom: 18),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (section.label.isNotEmpty) if (sectionLabel.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6), padding: const EdgeInsets.only(left: 2, bottom: 6),
child: ClideText( child: ClideText(
section.label.toUpperCase(), sectionLabel.toUpperCase(),
fontSize: clideFontCaption, fontSize: clideFontCaption,
color: tokens.sidebarSectionHeader, color: tokens.sidebarSectionHeader,
fontFamily: ClideSettings.fonts.monoOf(context), fontFamily: ClideSettings.fonts.monoOf(context),
@@ -145,7 +167,10 @@ class _SectionCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (var i = 0; i < section.fields.length; i++) ...[if (i > 0) const ClideDivider(), _FieldRow(field: section.fields[i], store: store)], for (var i = 0; i < section.fields.length; i++) ...[
if (i > 0) const ClideDivider(),
_FieldRow(field: section.fields[i], store: store, i18nNamespace: i18nNamespace),
],
], ],
), ),
), ),
@@ -157,22 +182,27 @@ class _SectionCard extends StatelessWidget {
/// A label/help block + the field's control + a reset affordance. /// A label/help block + the field's control + a reset affordance.
class _FieldRow extends StatelessWidget { class _FieldRow extends StatelessWidget {
const _FieldRow({required this.field, required this.store}); const _FieldRow({required this.field, required this.store, this.i18nNamespace});
final SettingsField field; final SettingsField field;
final SettingsStore store; final SettingsStore store;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
final fieldLabel = _localized(context, field.labelKey, i18nNamespace, field.label);
final fieldHelp = field.help == null ? null : _localized(context, field.helpKey, i18nNamespace, field.help!);
final label = Column( final label = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
ClideText(field.label, color: tokens.globalForeground), ClideText(fieldLabel, color: tokens.globalForeground),
if (field.help != null && field.help!.isNotEmpty) if (fieldHelp != null && fieldHelp.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
child: ClideText(field.help!, fontSize: clideFontCaption, color: tokens.globalTextMuted), child: ClideText(fieldHelp, fontSize: clideFontCaption, color: tokens.globalTextMuted),
), ),
], ],
); );
@@ -187,7 +217,7 @@ class _FieldRow extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (field.label.isNotEmpty) ...[label, const SizedBox(height: 10)], if (fieldLabel.isNotEmpty) ...[label, const SizedBox(height: 10)],
builder?.call(context) ?? const SizedBox.shrink(), builder?.call(context) ?? const SizedBox.shrink(),
], ],
), ),
@@ -203,9 +233,9 @@ class _FieldRow extends StatelessWidget {
children: [ children: [
Expanded(child: label), Expanded(child: label),
const SizedBox(width: 16), const SizedBox(width: 16),
_Control(field: field, value: effective, store: store), _Control(field: field, value: effective, store: store, i18nNamespace: i18nNamespace),
const SizedBox(width: 10), const SizedBox(width: 10),
_ScopeTag(field: field, store: store, effectiveValue: effective), _ScopeTag(field: field, store: store, effectiveValue: effective, i18nNamespace: i18nNamespace),
], ],
), ),
); );
@@ -214,12 +244,15 @@ class _FieldRow extends StatelessWidget {
/// Dispatches to the control widget for the field's [SettingsFieldKind]. /// Dispatches to the control widget for the field's [SettingsFieldKind].
class _Control extends StatelessWidget { class _Control extends StatelessWidget {
const _Control({required this.field, required this.value, required this.store}); const _Control({required this.field, required this.value, required this.store, this.i18nNamespace});
final SettingsField field; final SettingsField field;
final Object? value; final Object? value;
final SettingsStore store; final SettingsStore store;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
void _set(Object? v) => store.set<Object?>(field.key, v); void _set(Object? v) => store.set<Object?>(field.key, v);
@override @override
@@ -231,6 +264,7 @@ class _Control extends StatelessWidget {
return _SelectControl( return _SelectControl(
field: field, field: field,
value: value?.toString(), value: value?.toString(),
i18nNamespace: i18nNamespace,
onPick: (v) { onPick: (v) {
final prefix = field.applyCommandPrefix; final prefix = field.applyCommandPrefix;
if (prefix != null) { if (prefix != null) {
@@ -242,11 +276,11 @@ class _Control extends StatelessWidget {
}, },
); );
case SettingsFieldKind.text: case SettingsFieldKind.text:
return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, onCommit: _set); return _EditControl(field: field, value: value?.toString() ?? '', numeric: false, i18nNamespace: i18nNamespace, onCommit: _set);
case SettingsFieldKind.number: case SettingsFieldKind.number:
return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, onCommit: _set); return _EditControl(field: field, value: value?.toString() ?? '', numeric: true, i18nNamespace: i18nNamespace, onCommit: _set);
case SettingsFieldKind.file: case SettingsFieldKind.file:
return _FileControl(field: field); return _FileControl(field: field, i18nNamespace: i18nNamespace);
case SettingsFieldKind.custom: case SettingsFieldKind.custom:
// Custom fields are rendered full-width by _FieldRow; never here. // Custom fields are rendered full-width by _FieldRow; never here.
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -287,12 +321,15 @@ class _ToggleControl extends StatelessWidget {
/// Enum picker — current value on an anchored popover of options. /// Enum picker — current value on an anchored popover of options.
class _SelectControl extends StatefulWidget { class _SelectControl extends StatefulWidget {
const _SelectControl({required this.field, required this.value, required this.onPick}); const _SelectControl({required this.field, required this.value, required this.onPick, this.i18nNamespace});
final SettingsField field; final SettingsField field;
final String? value; final String? value;
final void Function(String value) onPick; final void Function(String value) onPick;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
State<_SelectControl> createState() => _SelectControlState(); State<_SelectControl> createState() => _SelectControlState();
} }
@@ -306,9 +343,10 @@ class _SelectControlState extends State<_SelectControl> {
super.dispose(); super.dispose();
} }
String get _label { /// Localized label for the currently selected option (or the raw value).
String _selectedLabel(BuildContext context) {
for (final o in widget.field.options) { for (final o in widget.field.options) {
if (o.value == widget.value) return o.label; if (o.value == widget.value) return _localized(context, o.labelKey, widget.i18nNamespace, o.label);
} }
return widget.value ?? ''; return widget.value ?? '';
} }
@@ -316,6 +354,8 @@ class _SelectControlState extends State<_SelectControl> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
final fieldLabel = _localized(context, widget.field.labelKey, widget.i18nNamespace, widget.field.label);
final selectedLabel = _selectedLabel(context);
return ClideAnchoredOverlay( return ClideAnchoredOverlay(
controller: _overlay, controller: _overlay,
align: ClideAnchorAlign.end, align: ClideAnchorAlign.end,
@@ -324,16 +364,16 @@ class _SelectControlState extends State<_SelectControl> {
entries: [ entries: [
for (final o in widget.field.options) for (final o in widget.field.options)
ClideMenuItem( ClideMenuItem(
label: o.label, label: _localized(context, o.labelKey, widget.i18nNamespace, o.label),
active: o.value == widget.value, active: o.value == widget.value,
semanticLabel: '${widget.field.label}: ${o.label}', semanticLabel: '$fieldLabel: ${_localized(context, o.labelKey, widget.i18nNamespace, o.label)}',
onSelect: () => widget.onPick(o.value), onSelect: () => widget.onPick(o.value),
), ),
], ],
), ),
anchor: Semantics( anchor: Semantics(
button: true, button: true,
label: '${widget.field.label}: $_label. Click to change.', label: '$fieldLabel: $selectedLabel. Click to change.',
excludeSemantics: true, excludeSemantics: true,
onTap: _overlay.toggle, onTap: _overlay.toggle,
child: ClideTappable( child: ClideTappable(
@@ -349,7 +389,7 @@ class _SelectControlState extends State<_SelectControl> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ClideText(_label, color: tokens.globalForeground), ClideText(selectedLabel, color: tokens.globalForeground),
const SizedBox(width: 6), const SizedBox(width: 6),
ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted), ClideIcon(PhosphorIcons.byName('caret-down'), size: 10, color: tokens.globalTextMuted),
], ],
@@ -364,13 +404,16 @@ class _SelectControlState extends State<_SelectControl> {
/// Inline text / numeric editor. Commits on Enter and on focus loss; numeric /// Inline text / numeric editor. Commits on Enter and on focus loss; numeric
/// fields parse + clamp to the field's bounds, ignoring unparseable input. /// fields parse + clamp to the field's bounds, ignoring unparseable input.
class _EditControl extends StatefulWidget { class _EditControl extends StatefulWidget {
const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit}); const _EditControl({required this.field, required this.value, required this.numeric, required this.onCommit, this.i18nNamespace});
final SettingsField field; final SettingsField field;
final String value; final String value;
final bool numeric; final bool numeric;
final void Function(Object value) onCommit; final void Function(Object value) onCommit;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
State<_EditControl> createState() => _EditControlState(); State<_EditControl> createState() => _EditControlState();
} }
@@ -430,7 +473,7 @@ class _EditControlState extends State<_EditControl> {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
return Semantics( return Semantics(
textField: true, textField: true,
label: widget.field.label, label: _localized(context, widget.field.labelKey, widget.i18nNamespace, widget.field.label),
excludeSemantics: true, excludeSemantics: true,
child: Container( child: Container(
width: widget.numeric ? 88 : 180, width: widget.numeric ? 88 : 180,
@@ -458,15 +501,18 @@ class _EditControlState extends State<_EditControl> {
/// "Opens external file" affordance — a button that runs the field's command. /// "Opens external file" affordance — a button that runs the field's command.
class _FileControl extends StatelessWidget { class _FileControl extends StatelessWidget {
const _FileControl({required this.field}); const _FileControl({required this.field, this.i18nNamespace});
final SettingsField field; final SettingsField field;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commands = ClideKernel.of(context).commands; final commands = ClideKernel.of(context).commands;
return ClideButton( return ClideButton(
label: field.label, label: _localized(context, field.labelKey, i18nNamespace, field.label),
variant: ClideButtonVariant.subtle, variant: ClideButtonVariant.subtle,
onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!), onPressed: field.fileCommand == null ? null : () => commands.execute(field.fileCommand!),
); );
@@ -482,12 +528,15 @@ class _FileControl extends StatelessWidget {
/// live in either file (project overrides app); `app.*`/`project.*` keys live /// live in either file (project overrides app); `app.*`/`project.*` keys live
/// only in their prefix's layer, so their menu offers that one scope + reset. /// only in their prefix's layer, so their menu offers that one scope + reset.
class _ScopeTag extends StatefulWidget { class _ScopeTag extends StatefulWidget {
const _ScopeTag({required this.field, required this.store, required this.effectiveValue}); const _ScopeTag({required this.field, required this.store, required this.effectiveValue, this.i18nNamespace});
final SettingsField field; final SettingsField field;
final SettingsStore store; final SettingsStore store;
final Object? effectiveValue; final Object? effectiveValue;
/// Owning category's i18n namespace (T-462); null for key-less categories.
final String? i18nNamespace;
@override @override
State<_ScopeTag> createState() => _ScopeTagState(); State<_ScopeTag> createState() => _ScopeTagState();
} }
@@ -547,6 +596,7 @@ class _ScopeTagState extends State<_ScopeTag> {
final current = widget.store.effectiveLayer(widget.field.key); final current = widget.store.effectiveLayer(widget.field.key);
final look = _appearance(current); final look = _appearance(current);
final layers = widget.store.writableLayers(widget.field.key); final layers = widget.store.writableLayers(widget.field.key);
final fieldLabel = _localized(context, widget.field.labelKey, widget.i18nNamespace, widget.field.label);
return ClideAnchoredOverlay( return ClideAnchoredOverlay(
controller: _overlay, controller: _overlay,
align: ClideAnchorAlign.end, align: ClideAnchorAlign.end,
@@ -560,7 +610,7 @@ class _ScopeTagState extends State<_ScopeTag> {
), ),
anchor: Semantics( anchor: Semantics(
button: true, button: true,
label: '${widget.field.label} scope: ${look.label}', label: '$fieldLabel scope: ${look.label}',
excludeSemantics: true, excludeSemantics: true,
onTap: _overlay.toggle, onTap: _overlay.toggle,
child: ClideTappable( child: ClideTappable(
@@ -244,10 +244,15 @@ class _RailRow extends StatelessWidget {
final tokens = ClideSettings.theme.of(context).surface; final tokens = ClideSettings.theme.of(context).surface;
final dim = matchCount == 0; final dim = matchCount == 0;
final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground); final fg = dim ? tokens.globalTextMuted : (selected ? tokens.globalForeground : tokens.sidebarForeground);
// Localize the rail label via the owning category's namespace (T-462);
// key-less categories fall back to the English title unchanged.
final title = (category.titleKey == null || category.i18nNamespace == null || category.i18nNamespace!.isEmpty)
? category.title
: ClideSettings.i18n.string(context, category.titleKey!, namespace: category.i18nNamespace!, placeholder: category.title);
return Semantics( return Semantics(
button: true, button: true,
selected: selected, selected: selected,
label: category.title, label: title,
excludeSemantics: true, excludeSemantics: true,
child: ClideTappable( child: ClideTappable(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
@@ -267,7 +272,7 @@ class _RailRow extends StatelessWidget {
children: [ children: [
if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)], if (category.iconName != null) ...[ClideIcon(PhosphorIcons.byName(category.iconName!), size: 15, color: fg), const SizedBox(width: 8)],
Expanded( Expanded(
child: ClideText(category.title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis), child: ClideText(title, color: fg, maxLines: 1, overflow: TextOverflow.ellipsis),
), ),
if (matchCount != null) ClideText('$matchCount', fontSize: clideFontCaption, color: tokens.globalTextMuted), if (matchCount != null) ClideText('$matchCount', fontSize: clideFontCaption, color: tokens.globalTextMuted),
], ],
+24 -6
View File
@@ -28,7 +28,15 @@ class ThemePickerExtension extends ClideExtension {
// "Theme…" to disambiguate from the schema-driven Settings panel's // "Theme…" to disambiguate from the schema-driven Settings panel's
// `settings.open` (T-444); the theme picker folds into that panel's // `settings.open` (T-444); the theme picker folds into that panel's
// Appearance category in T-452. // Appearance category in T-452.
CommandContribution(id: 'theme.pick', command: 'theme.pick', title: 'Theme…', defaultBinding: 'ctrl+k', run: _pick), CommandContribution(
id: 'theme.pick',
command: 'theme.pick',
title: 'Theme…',
titleKey: 'command.pick',
i18nNamespace: id,
defaultBinding: 'ctrl+k',
run: _pick,
),
// Always-visible switcher in the far-right status bar (T-234). // Always-visible switcher in the far-right status bar (T-234).
// priority >= 100 places it in the right group; registered after // priority >= 100 places it in the right group; registered after
// ipc-status so it sits to its right. // ipc-status so it sits to its right.
@@ -36,49 +44,59 @@ class ThemePickerExtension extends ClideExtension {
// Appearance settings category (T-452) — the theme picker as the schema // Appearance settings category (T-452) — the theme picker as the schema
// engine's one custom control, registered against the control registry. // engine's one custom control, registered against the control registry.
SettingsControlContribution(id: 'theme-picker.appearance-control', customId: 'theme.picker', builder: (_) => const AppearanceThemeControl()), SettingsControlContribution(id: 'theme-picker.appearance-control', customId: 'theme.picker', builder: (_) => const AppearanceThemeControl()),
const SettingsCategoryContribution( SettingsCategoryContribution(
id: 'appearance', id: 'appearance',
category: SettingsCategory( category: SettingsCategory(
id: 'appearance', id: 'appearance',
title: 'Appearance', title: 'Appearance',
titleKey: 'settings.appearance.title',
i18nNamespace: id,
iconName: 'palette', iconName: 'palette',
priority: 10, priority: 10,
sections: [ sections: [
SettingsSection( SettingsSection(
label: 'Theme', label: 'Theme',
labelKey: 'settings.appearance.section.theme',
fields: [ fields: [
SettingsField( SettingsField(
key: 'app.theme', key: 'app.theme',
kind: SettingsFieldKind.custom, kind: SettingsFieldKind.custom,
label: 'Theme', label: 'Theme',
labelKey: 'settings.appearance.field.theme.label',
help: 'Color theme; high contrast switches to the accessible variant.', help: 'Color theme; high contrast switches to the accessible variant.',
helpKey: 'settings.appearance.field.theme.help',
customId: 'theme.picker', customId: 'theme.picker',
), ),
], ],
), ),
SettingsSection( SettingsSection(
label: 'Typography', label: 'Typography',
labelKey: 'settings.appearance.section.typography',
fields: [ fields: [
SettingsField( SettingsField(
key: kUiFontSettingKey, key: kUiFontSettingKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'UI font', label: 'UI font',
labelKey: 'settings.appearance.field.uiFont.label',
help: 'Typeface for the app interface; applies live.', help: 'Typeface for the app interface; applies live.',
helpKey: 'settings.appearance.field.uiFont.help',
defaultValue: 'JosefinSans', defaultValue: 'JosefinSans',
options: [ options: [
SettingsOption(value: 'JosefinSans', label: 'Josefin Sans'), SettingsOption(value: 'JosefinSans', label: 'Josefin Sans', labelKey: 'settings.appearance.field.uiFont.option.josefinSans'),
SettingsOption(value: 'Inter', label: 'Inter'), SettingsOption(value: 'Inter', label: 'Inter', labelKey: 'settings.appearance.field.uiFont.option.inter'),
], ],
), ),
SettingsField( SettingsField(
key: kMonoFontSettingKey, key: kMonoFontSettingKey,
kind: SettingsFieldKind.select, kind: SettingsFieldKind.select,
label: 'Monospace font', label: 'Monospace font',
labelKey: 'settings.appearance.field.monoFont.label',
help: 'Terminal, diffs, code, and IDs; applies live.', help: 'Terminal, diffs, code, and IDs; applies live.',
helpKey: 'settings.appearance.field.monoFont.help',
defaultValue: 'JetBrainsMono', defaultValue: 'JetBrainsMono',
options: [ options: [
SettingsOption(value: 'JetBrainsMono', label: 'JetBrains Mono'), SettingsOption(value: 'JetBrainsMono', label: 'JetBrains Mono', labelKey: 'settings.appearance.field.monoFont.option.jetBrainsMono'),
SettingsOption(value: 'FiraMono', label: 'Fira Mono'), SettingsOption(value: 'FiraMono', label: 'Fira Mono', labelKey: 'settings.appearance.field.monoFont.option.firaMono'),
], ],
), ),
], ],
+6
View File
@@ -30,6 +30,8 @@ class ViewExtension extends ClideExtension {
id: 'view.zoomIn', id: 'view.zoomIn',
command: 'view.zoomIn', command: 'view.zoomIn',
title: 'View: Zoom In', title: 'View: Zoom In',
titleKey: 'command.view.zoomIn',
i18nNamespace: id,
run: (_) async { run: (_) async {
textZoom.increase(); textZoom.increase();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
@@ -39,6 +41,8 @@ class ViewExtension extends ClideExtension {
id: 'view.zoomOut', id: 'view.zoomOut',
command: 'view.zoomOut', command: 'view.zoomOut',
title: 'View: Zoom Out', title: 'View: Zoom Out',
titleKey: 'command.view.zoomOut',
i18nNamespace: id,
run: (_) async { run: (_) async {
textZoom.decrease(); textZoom.decrease();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
@@ -48,6 +52,8 @@ class ViewExtension extends ClideExtension {
id: 'view.zoomReset', id: 'view.zoomReset',
command: 'view.zoomReset', command: 'view.zoomReset',
title: 'View: Reset Zoom', title: 'View: Reset Zoom',
titleKey: 'command.view.zoomReset',
i18nNamespace: id,
run: (_) async { run: (_) async {
textZoom.reset(); textZoom.reset();
return IpcResponse.ok(id: '', data: {'scale': textZoom.scale}); return IpcResponse.ok(id: '', data: {'scale': textZoom.scale});
+2
View File
@@ -80,6 +80,8 @@ class VimExtension extends ClideExtension {
id: id, id: id,
command: id, command: id,
title: title, title: title,
titleKey: 'command.$id',
i18nNamespace: this.id,
run: (_) async { run: (_) async {
apply(); apply();
return IpcResponse.ok(id: '', data: {'mode': _mode?.mode.name ?? 'disabled'}); return IpcResponse.ok(id: '', data: {'mode': _mode?.mode.name ?? 'disabled'});
+2
View File
@@ -26,6 +26,8 @@ class WelcomeExtension extends ClideExtension {
id: 'workspace.open-project', id: 'workspace.open-project',
command: 'workspace.open-project', command: 'workspace.open-project',
title: 'Workspace: Open project…', title: 'Workspace: Open project…',
titleKey: 'command.workspace.open-project',
i18nNamespace: id,
run: (_) async => IpcResponse.ok(id: '', data: const {'note': 'project picker lands in a later tier'}), run: (_) async => IpcResponse.ok(id: '', data: const {'note': 'project picker lands in a later tier'}),
), ),
]; ];
@@ -165,5 +165,37 @@
"teamChat.composer.placeholder": { "translation": "@name or @team …" }, "teamChat.composer.placeholder": { "translation": "@name or @team …" },
"teamChat.pane.title": { "translation": "Team Chat" }, "teamChat.pane.title": { "translation": "Team Chat" },
"teamChat.interrupt.semantics": { "translation": "Interrupt target session" }, "teamChat.interrupt.semantics": { "translation": "Interrupt target session" },
"teamChat.interrupt.label": { "translation": "Interrupt" } "teamChat.interrupt.label": { "translation": "Interrupt" },
"command.newSecondary": { "translation": "Claude: open a secondary session" },
"command.killAllSessions": { "translation": "Claude: kill all sessions for this repo" },
"command.sessionStorage": { "translation": "Claude: session storage (disk usage + cleanup)" },
"command.activity.foldLevel": { "translation": "Claude: cycle activity fold level" },
"command.agent.show": { "translation": "Claude: show an agent session pane" },
"command.agent.hide": { "translation": "Claude: hide an agent session pane" },
"command.agent.close": { "translation": "Claude: close (kill) an agent session" },
"command.agent.mute": { "translation": "Claude: mute broker delivery to an agent session" },
"command.agent.unmute": { "translation": "Claude: unmute broker delivery to an agent session" },
"command.agent.injectMessage": { "translation": "Claude: inject a text turn into an agent session" },
"command.agent.setPermissionMode": { "translation": "Claude: set permission mode for an agent session" },
"command.mode.cycle": { "translation": "Claude: Cycle permission mode" },
"command.task.reassign": { "translation": "Claude: reassign a shared task to an agent" },
"command.teamChat.open": { "translation": "Claude: open the team chat pane" },
"command.teamChat.post": { "translation": "Claude: post a message into the team channel as the user" },
"command.agent.fork": { "translation": "Claude: fork a managed session into a new branch session" },
"settings.activity.title": { "translation": "Activity" },
"settings.activity.conversation.label": { "translation": "Conversation" },
"settings.activity.foldLevel.label": { "translation": "Fold level" },
"settings.activity.foldLevel.help": { "translation": "How aggressively the conversation folds tool calls, thinking, and results." },
"settings.activity.opt.none": { "translation": "Show everything" },
"settings.activity.opt.tools": { "translation": "Fold tool calls" },
"settings.activity.opt.thinking": { "translation": "Fold tools + thinking" },
"settings.activity.opt.everything": { "translation": "Fold all but prose" },
"settings.claude.title": { "translation": "Claude" },
"settings.claude.newSessionDefaults.label": { "translation": "New session defaults" },
"settings.claude.model.label": { "translation": "Model" },
"settings.claude.model.help": { "translation": "Model for new sessions." },
"settings.claude.effort.label": { "translation": "Effort" },
"settings.claude.effort.help": { "translation": "Reasoning effort for new sessions (applied via --effort at spawn)." },
"settings.claude.permissionMode.label": { "translation": "Permission mode" },
"settings.claude.permissionMode.help": { "translation": "Starting permission mode for new sessions." }
} }
@@ -0,0 +1,3 @@
{
"command.clide.installCli": { "translation": "clide: Install 'clide' command in PATH" }
}
@@ -3,5 +3,6 @@
"dialog.body": { "translation": "A clide:// link from outside the app is asking to:" }, "dialog.body": { "translation": "A clide:// link from outside the app is asking to:" },
"dialog.warning": { "translation": "Only allow this if you trust where the link came from." }, "dialog.warning": { "translation": "Only allow this if you trust where the link came from." },
"button.cancel": { "translation": "Cancel" }, "button.cancel": { "translation": "Cancel" },
"button.open": { "translation": "Open" } "button.open": { "translation": "Open" },
"command.deeplink.invoke": { "translation": "Open a clide:// deep link" }
} }
@@ -1,4 +1,21 @@
{ {
"command.reset": { "translation": "Layout: Reset to Classic" }, "command.reset": { "translation": "Layout: Reset to Classic" },
"preset.classic": { "translation": "Classic" } "preset.classic": { "translation": "Classic" },
"command.palette.toggle": { "translation": "Command Palette" },
"command.sidebar.collapse": { "translation": "Toggle Sidebar Collapse" },
"command.context.collapse": { "translation": "Toggle Context Panel Collapse" },
"command.panel.focus.left": { "translation": "Focus Left Panel" },
"command.panel.focus.middle": { "translation": "Focus Middle Panel" },
"command.panel.focus.right": { "translation": "Focus Right Panel" },
"command.panel.focusMode": { "translation": "Toggle Focus Mode" },
"command.panel.focusMode.exit": { "translation": "Exit Focus Mode" },
"command.editor.open": { "translation": "Open Editor" },
"command.editor.close": { "translation": "Close Editor" },
"command.workspace.tab.next": { "translation": "Next Workspace Tab" },
"command.workspace.tab.previous": { "translation": "Previous Workspace Tab" },
"command.sidebar.section.1": { "translation": "Sidebar: Section 1" },
"command.sidebar.section.2": { "translation": "Sidebar: Section 2" },
"command.sidebar.section.3": { "translation": "Sidebar: Section 3" },
"command.sidebar.section.4": { "translation": "Sidebar: Section 4" },
"command.sidebar.section.5": { "translation": "Sidebar: Section 5" }
} }
@@ -1,5 +1,8 @@
{ {
"notice.title": { "translation": "Extension management is coming" }, "notice.title": { "translation": "Extension management is coming" },
"notice.body": { "translation": "Installing, enabling, and disabling extensions arrives with third-party (Lua) extension support. For now the built-in extensions are always on." }, "notice.body": { "translation": "Installing, enabling, and disabling extensions arrives with third-party (Lua) extension support. For now the built-in extensions are always on." },
"notice.tracked": { "translation": "Tracked in T-8 (Tier 6) · D-16" } "notice.tracked": { "translation": "Tracked in T-8 (Tier 6) · D-16" },
"settings.extensions.title": { "translation": "Extensions" },
"settings.extensions.section.notice": { "translation": "" },
"settings.extensions.field.notice.label": { "translation": "" }
} }
@@ -0,0 +1,14 @@
{
"command.preset.default": { "translation": "Keymap: Default" },
"command.preset.vim": { "translation": "Keymap: Vim" },
"command.preset.vscode": { "translation": "Keymap: VS Code" },
"command.preset.jetbrains": { "translation": "Keymap: JetBrains" },
"settings.keymap.title": { "translation": "Keymap" },
"settings.keymap.section.preset": { "translation": "Preset" },
"settings.keymap.field.preset.label": { "translation": "Active preset" },
"settings.keymap.field.preset.help": { "translation": "Keyboard layout for the whole app." },
"settings.keymap.field.preset.option.default": { "translation": "Default" },
"settings.keymap.field.preset.option.vim": { "translation": "Vim" },
"settings.keymap.field.preset.option.vscode": { "translation": "VS Code" },
"settings.keymap.field.preset.option.jetbrains": { "translation": "JetBrains" }
}
@@ -18,5 +18,9 @@
"dialog.openProject.body": { "translation": "Enter the path to a git repository." }, "dialog.openProject.body": { "translation": "Enter the path to a git repository." },
"dialog.openProject.error": { "translation": "Not a git repository" }, "dialog.openProject.error": { "translation": "Not a git repository" },
"dialog.notRepo.title": { "translation": "No git repo found" }, "dialog.notRepo.title": { "translation": "No git repo found" },
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." } "dialog.notRepo.body": { "translation": "A clide project root requires a git repository." },
"command.file.openFolder": { "translation": "File: Open Folder…" },
"command.file.newWindow": { "translation": "File: New Window" },
"command.file.closeWorkspace": { "translation": "File: Close Project" },
"command.help.about": { "translation": "Help: About clide" }
} }
@@ -10,5 +10,6 @@
"a11y.jumpToLatest": { "translation": "jump to latest" }, "a11y.jumpToLatest": { "translation": "jump to latest" },
"jump.label": { "translation": "Jump to latest ↓" }, "jump.label": { "translation": "Jump to latest ↓" },
"a11y.toggleDock": { "translation": "toggle output dock" }, "a11y.toggleDock": { "translation": "toggle output dock" },
"dock.label": { "translation": "Output" } "dock.label": { "translation": "Output" },
"command.dock.toggle": { "translation": "Toggle output dock" }
} }
@@ -1,4 +1,5 @@
{ {
"command.open": { "translation": "Settings…" },
"modal.title": { "translation": "Settings" }, "modal.title": { "translation": "Settings" },
"modal.close": { "translation": "Close" }, "modal.close": { "translation": "Close" },
"modal.close.hint": { "translation": "Close settings without changing anything" }, "modal.close.hint": { "translation": "Close settings without changing anything" },
@@ -1,9 +1,22 @@
{ {
"command.pick": { "translation": "Theme: Pick…" }, "command.pick": { "translation": "Theme…" },
"modal.title": { "translation": "Select theme" }, "modal.title": { "translation": "Select theme" },
"modal.cancel": { "translation": "Cancel" }, "modal.cancel": { "translation": "Cancel" },
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" }, "modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
"row.select.hint": { "translation": "Activate this theme" }, "row.select.hint": { "translation": "Activate this theme" },
"section.appearance": { "translation": "Appearance" }, "section.appearance": { "translation": "Appearance" },
"toggle.highContrast": { "translation": "High contrast" } "toggle.highContrast": { "translation": "High contrast" },
"settings.appearance.title": { "translation": "Appearance" },
"settings.appearance.section.theme": { "translation": "Theme" },
"settings.appearance.section.typography": { "translation": "Typography" },
"settings.appearance.field.theme.label": { "translation": "Theme" },
"settings.appearance.field.theme.help": { "translation": "Color theme; high contrast switches to the accessible variant." },
"settings.appearance.field.uiFont.label": { "translation": "UI font" },
"settings.appearance.field.uiFont.help": { "translation": "Typeface for the app interface; applies live." },
"settings.appearance.field.uiFont.option.josefinSans": { "translation": "Josefin Sans" },
"settings.appearance.field.uiFont.option.inter": { "translation": "Inter" },
"settings.appearance.field.monoFont.label": { "translation": "Monospace font" },
"settings.appearance.field.monoFont.help": { "translation": "Terminal, diffs, code, and IDs; applies live." },
"settings.appearance.field.monoFont.option.jetBrainsMono": { "translation": "JetBrains Mono" },
"settings.appearance.field.monoFont.option.firaMono": { "translation": "Fira Mono" }
} }
@@ -0,0 +1,5 @@
{
"command.view.zoomIn": { "translation": "View: Zoom In" },
"command.view.zoomOut": { "translation": "View: Zoom Out" },
"command.view.zoomReset": { "translation": "View: Reset Zoom" }
}
@@ -1,5 +1,8 @@
{ {
"mode.normal": { "translation": "NORMAL" }, "mode.normal": { "translation": "NORMAL" },
"mode.insert": { "translation": "INSERT" }, "mode.insert": { "translation": "INSERT" },
"mode.visual": { "translation": "VISUAL" } "mode.visual": { "translation": "VISUAL" },
"command.vim.mode.normal": { "translation": "Vim: Normal mode" },
"command.vim.mode.insert": { "translation": "Vim: Insert mode" },
"command.vim.mode.visual": { "translation": "Vim: Visual mode" }
} }
@@ -30,5 +30,6 @@
"button.opening": { "translation": "Opening…" }, "button.opening": { "translation": "Opening…" },
"button.ok": { "translation": "OK" }, "button.ok": { "translation": "OK" },
"dialog.notRepo.title": { "translation": "No git repo found" }, "dialog.notRepo.title": { "translation": "No git repo found" },
"dialog.notRepo.body": { "translation": "A clide project root requires a git repository." } "dialog.notRepo.body": { "translation": "A clide project root requires a git repository." },
"command.workspace.open-project": { "translation": "Workspace: Open project…" }
} }