feat(i18n): i18n key fields on command + settings contributions (T-462)

Lets manifest labels (command-palette/menu titles, settings labels) localize,
not just displayed widget strings. Adds optional titleKey/i18nNamespace to
CommandContribution and labelKey/helpKey/titleKey + a category i18nNamespace to
the settings schema. The command palette and menu bar now resolve titles via a
shared localizedCommandTitle helper — and the palette's fuzzy search matches
the localized title too (PaletteController.titleResolver). No behaviour change
until the per-extension keys + catalog entries land (placeholder == English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 09:44:35 +02:00
co-authored by Claude Opus 4.8
parent 33a8a74240
commit 198c8b18b5
9 changed files with 91 additions and 11 deletions
@@ -5944,3 +5944,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YCY2WGSG8W46RHQ5Z9MW', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:13:50', '2026-06-18 20:13:50.352', '2026-06-18 20:13:50.352', NULL, 'a885fb7b56c053669a8ef5f0e6032feb', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA0YYCJ0NK8C4HMDYKJHRDG', 'status', 'in_progress', 'done', NULL, '2026-06-18 20:16:03', '2026-06-18 20:16:03.747', '2026-06-18 20:16:03.747', NULL, '5e67beaa758d779f1b9212aecb82e050', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDA10DRQE3SED10JAW2CZDCR', 'status', 'backlog', 'done', NULL, '2026-06-18 21:21:27', '2026-06-18 21:21:27.304', '2026-06-18 21:21:27.304', NULL, '0ab235b21c54d46de3e3e4d1529de041', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'description', NULL, 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', NULL, '2026-06-19 07:42:08', '2026-06-19 07:42:08.761', '2026-06-19 07:42:08.761', NULL, '04b83ed9bd668d2cb22e75d18d1c2c94', 2) ON CONFLICT(hash) DO NOTHING;
+2
View File
@@ -300,3 +300,5 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDTCGG5Z0KNQKF89W1VZSQ8', 'T-472', '2026-06-17 18:48:11.649', '2026-06-17 18:48:11.649', NULL, '097120cff851cb2dac9f54937f4c7b17', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5GVH3FTCVDEC1QAFACY4', 'T-473', '2026-06-17 19:00:20.828', '2026-06-17 19:00:20.828', NULL, '647782edc3fbb4e7285e4f82a1fb84be', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDDX5KEAJ8KRWVV7ZSG6H7A0', 'T-474', '2026-06-17 19:00:21.490', '2026-06-17 19:00:21.490', NULL, '79576f18553ed885d3a2b745becdf2f5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDEMY3DQWDNY535PMMSB0CSW', 'T-475', '2026-06-17 20:44:11.501', '2026-06-17 20:44:11.501', NULL, 'bb2b56ea9a76d967123238f588c70aba', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'T-476', '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, 'f75a0a8c00b0ec40c110c1169e42fe2c', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+2
View File
@@ -7364,3 +7364,5 @@ Hotspots from the audit:
Open question to settle in this task: do framework widgets get a ''core''/''kernel'' namespace, or does each caller pass its owning extension''s namespace down? Pick one and note it (small D- or Q- record if it sets precedent). Single-char icon glyphs (''+'', '':'') are exempt note any left.
Acceptance: real user-facing sentences in lib/widgets + lib/kernel resolve through i18n under a documented namespace; glyph exemptions noted.', 'done', 'low', NULL, NULL, 'D-21', '2026-06-17 09:57:53.221', '2026-06-18 21:21:27.304', NULL, '9817d717fa77916d07547d9674d14fcb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.735', NULL, '35baee58ff25f10d9ba81902bd4072b8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FDXN3ZBRS6JK7Q8G6JVSPXFC', 'story', NULL, 'Per-repo Claude account login (not system-wide)', 'Today clide uses Claude Code''s system-wide login (the CLI''s global auth), so every repo/workspace shares one Claude account. Allow selecting/logging into a separate Claude account per repo, so different projects can run under different accounts (e.g. personal vs work). Needs: per-workspace auth storage + a way for the spawned Claude session to use that repo''s account instead of the global one; UI to sign in/switch per repo; fall back to the system login when none is set.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-19 07:42:08.735', '2026-06-19 07:42:08.761', NULL, '65bb4a1083e1ecfba38bfd1c3837e133', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+7 -1
View File
@@ -72,7 +72,13 @@ class MenuBar extends StatelessWidget {
return ListenableBuilder(
listenable: Listenable.merge([controller, kernel.commands, kernel.project]),
builder: (ctx, _) {
final menus = resolveMenus(buildClideMenuTree(), kernel.commands, kernel, bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id));
final menus = resolveMenus(
buildClideMenuTree(),
kernel.commands,
kernel,
bindingLabel: (id) => keymapBindingLabel(kernel.keymap, id),
localizeTitle: (c) => localizedCommandTitle(context, c),
);
controller.setMnemonics([for (final m in menus) m.title[m.mnemonic].toLowerCase()]);
return Row(
mainAxisSize: MainAxisSize.min,
+8 -2
View File
@@ -108,7 +108,13 @@ String? keymapBindingLabel(KeymapService keymap, String commandId) {
/// menus. [bindingLabel] supplies the keybinding string for a command id
/// (typically [keymapBindingLabel] bound to the keymap); when it returns null
/// the command's own `defaultBinding` is used as a fallback.
List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, KernelServices services, {String? Function(String commandId)? bindingLabel}) {
List<ResolvedMenu> resolveMenus(
List<TopMenu> tree,
CommandRegistry registry,
KernelServices services, {
String? Function(String commandId)? bindingLabel,
String Function(CommandContribution cmd)? localizeTitle,
}) {
final placed = <String>{
for (final m in tree)
for (final n in m.nodes)
@@ -130,7 +136,7 @@ List<ResolvedMenu> resolveMenus(List<TopMenu> tree, CommandRegistry registry, Ke
ResolvedItem resolveItem(MenuCommandItem item) {
final cmd = registry.get(item.commandId);
final enabled = cmd != null && (item.enabledWhen?.call(services) ?? true);
final raw = cmd?.title ?? item.fallbackTitle ?? item.commandId;
final raw = (cmd != null ? (localizeTitle?.call(cmd) ?? cmd.title) : null) ?? item.fallbackTitle ?? item.commandId;
return ResolvedItem(commandId: item.commandId, title: _stripCategory(raw), enabled: enabled, keybinding: label(item.commandId));
}
+19 -1
View File
@@ -93,10 +93,28 @@ class ToolbarButtonContribution extends ContributionPoint {
/// A command extensions register with [CommandRegistry]. Surfaced by the
/// command palette, the keybinding resolver, and `clide` CLI subcommands.
class CommandContribution extends ContributionPoint {
const CommandContribution({required super.id, required this.command, required this.run, this.title, this.defaultBinding, this.bindingWhen});
const CommandContribution({
required super.id,
required this.command,
required this.run,
this.title,
this.titleKey,
this.i18nNamespace,
this.defaultBinding,
this.bindingWhen,
});
final String command; // e.g. "git.commit"
final String? title; // "Git: Commit staged"
/// When set, the command palette / menu resolve the display title via
/// `i18n.string(titleKey, namespace: i18nNamespace, placeholder: title)`
/// (T-462). [title] stays the English fallback (also used in logs/CLI).
final String? titleKey;
/// The i18n namespace to look up [titleKey] in — usually the contributing
/// extension's `id`. Required when [titleKey] is set.
final String? i18nNamespace;
final String? defaultBinding; // e.g. "ctrl+shift+g"
/// Optional when-clause guarding [defaultBinding] (same grammar as keymap
+8 -2
View File
@@ -8,6 +8,12 @@ class PaletteController extends ChangeNotifier {
final CommandRegistry _registry;
/// Resolves a command's display title (the widget sets this to an i18n-aware
/// resolver so both the rendered title AND fuzzy search use the localized
/// string, T-462). Defaults to the English title/id.
String Function(CommandContribution cmd)? titleResolver;
String _titleOf(CommandContribution c) => titleResolver?.call(c) ?? c.title ?? c.command;
bool _open = false;
String _filter = '';
int _selectedIndex = 0;
@@ -103,7 +109,7 @@ class PaletteController extends ChangeNotifier {
final scored = <({CommandContribution cmd, int score})>[];
for (final c in all) {
final s = fuzzyScore((c.title ?? c.command).toLowerCase(), q);
final s = fuzzyScore(_titleOf(c).toLowerCase(), q);
if (s != null) scored.add((cmd: c, score: s));
}
scored.sort((a, b) {
@@ -111,7 +117,7 @@ class PaletteController extends ChangeNotifier {
if (byScore != 0) return byScore;
final byRecent = rank(a.cmd.command).compareTo(rank(b.cmd.command));
if (byRecent != 0) return byRecent;
return (a.cmd.title ?? a.cmd.command).toLowerCase().compareTo((b.cmd.title ?? b.cmd.command).toLowerCase());
return _titleOf(a.cmd).toLowerCase().compareTo(_titleOf(b.cmd).toLowerCase());
});
return [for (final s in scored) s.cmd];
}
+28 -4
View File
@@ -34,13 +34,17 @@ enum SettingsFieldKind {
/// One choice in a [SettingsFieldKind.select] field.
class SettingsOption {
const SettingsOption({required this.value, required this.label});
const SettingsOption({required this.value, required this.label, this.labelKey});
/// Stored value.
final String value;
/// Human label shown in the picker.
/// Human label shown in the picker (English fallback).
final String label;
/// Optional i18n key for [label], resolved in the owning category's namespace
/// (T-462).
final String? labelKey;
}
/// One editable setting. [key] is a `SettingsStore` key — its `app.`/
@@ -52,7 +56,9 @@ class SettingsField {
required this.key,
required this.kind,
required this.label,
this.labelKey,
this.help,
this.helpKey,
this.defaultValue,
this.options = const [],
this.min,
@@ -66,9 +72,15 @@ class SettingsField {
final SettingsFieldKind kind;
final String label;
/// Optional i18n key for [label], resolved in the category's namespace (T-462).
final String? labelKey;
/// Optional one-line help shown under the label.
final String? help;
/// Optional i18n key for [help], resolved in the category's namespace (T-462).
final String? helpKey;
/// Value shown / restored when the key is unset (reset-to-default target).
final Object? defaultValue;
@@ -97,9 +109,12 @@ class SettingsField {
/// A carded group of fields (surface.md "sectioned cards"). [label] is the
/// small-caps header rendered just above the card.
class SettingsSection {
const SettingsSection({required this.label, required this.fields});
const SettingsSection({required this.label, required this.fields, this.labelKey});
final String label;
/// Optional i18n key for [label], resolved in the category's namespace (T-462).
final String? labelKey;
final List<SettingsField> fields;
}
@@ -107,11 +122,20 @@ class SettingsSection {
/// shows. Subsystems register these via `SettingsCategoryContribution`; the
/// renderer draws them.
class SettingsCategory {
const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0});
const SettingsCategory({required this.id, required this.title, required this.sections, this.iconName, this.priority = 0, this.titleKey, this.i18nNamespace});
final String id;
final String title;
/// Optional i18n key for [title] (the rail label), looked up in
/// [i18nNamespace] (T-462).
final String? titleKey;
/// The i18n namespace for this category's [titleKey] and every section/field/
/// option key beneath it — usually the contributing extension's `id`. The
/// renderer threads it down so the whole category localizes from one catalog.
final String? i18nNamespace;
/// Phosphor glyph name, resolved via `PhosphorIcons.byName` at render (T-314).
final String? iconName;
+16 -1
View File
@@ -1,9 +1,22 @@
import 'package:clide/extension/src/contribution.dart' show CommandContribution;
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/src/clide_settings.dart';
import 'package:clide/widgets/src/clide_text.dart';
import 'package:clide/widgets/src/typography.dart';
import 'package:flutter/widgets.dart';
/// The localized display title for a command (T-462): resolves [titleKey] in
/// [i18nNamespace] when both are set, else the English title (then id). Shared
/// by the command palette and the menu bar so both localize identically.
String localizedCommandTitle(BuildContext context, CommandContribution cmd) {
final k = cmd.titleKey;
final ns = cmd.i18nNamespace;
if (k != null && ns != null) {
return ClideSettings.i18n.string(context, k, namespace: ns, placeholder: cmd.title ?? cmd.command);
}
return cmd.title ?? cmd.command;
}
class ClidePalette extends StatefulWidget {
const ClidePalette({super.key});
@@ -109,6 +122,8 @@ class _ClidePaletteState extends State<ClidePalette> {
listenable: kernel.palette,
builder: (ctx, _) {
if (!kernel.palette.isOpen) return const SizedBox.shrink();
// Localize titles for both the fuzzy filter and the rendered rows (T-462).
kernel.palette.titleResolver = (cmd) => localizedCommandTitle(ctx, cmd);
final filtered = kernel.palette.filtered();
final selected = kernel.palette.selectedIndex;
return Positioned(
@@ -164,7 +179,7 @@ class _ClidePaletteState extends State<ClidePalette> {
final key = _itemKeys.putIfAbsent(i, () => GlobalKey());
return _PaletteItem(
key: key,
title: cmd.title ?? cmd.command,
title: localizedCommandTitle(ctx, cmd),
command: cmd.command,
binding: cmd.defaultBinding,
highlighted: i == selected,