Merge pull request #24 from awizemann/multi-language

feat(i18n): close silently un-localizable sites (Phase 1b)
This commit is contained in:
Alan Wizemann
2026-04-20 17:52:18 -07:00
committed by GitHub
28 changed files with 227 additions and 101 deletions
+9 -47
View File
@@ -47,56 +47,18 @@ For the three supported non-English locales we use Xcode's built-in AI translati
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog. Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
## Outstanding audit follow-ups ## Audit status
The [initial i18n PR](#) enabled the catalog infrastructure and migrated clear locale-bug formatter sites. The following patterns remain in the codebase and should be refactored **before** translations ship in Phase 2 — otherwise their English copy will leak through regardless of locale: Phase 1b (the `multi-language` PR) closed every tracked site from the original audit:
### Category A — UI copy held in a `String` variable (needs `LocalizedStringResource`) - **Category A high-priority (ternary UI copy)** — converted to `Text`-ternary form so each branch routes through `LocalizedStringKey`.
- **Category A medium-priority (enum `.rawValue` displays)** — each enum now exposes `displayName: LocalizedStringResource` and call sites use it. `LogEntry.LogLevel` (technical jargon) stays verbatim.
- **Category A lower-priority (displayName passthroughs)** — wrapped with `Text(verbatim:)` for proper nouns / user data (`HermesToolPlatform`, `ServerRegistry.Entry`, `MCPServerPreset`). `MCPTransport.displayName` promoted to `LocalizedStringResource`.
- **Category B (composite format strings)** — migrated to `Text("\(arg) suffix")` with `LocalizedStringKey` or to `.percent` / `.currency` FormatStyle.
- **Category C (hard-coded day names)** — replaced with `Calendar.current.shortWeekdaySymbols`, re-indexed to match the existing Mon=0 data model.
- **Category D (`.help(stringVar)` sites)** — `ConnectionStatusPill` now returns `Text` from its `labelText` / `tooltipText` properties.
High-priority (ternary UI copy, visible status chrome): If you spot a new silently-un-localizable site during translation review, prefer the patterns in the table above over one-off workarounds.
- `Features/Health/Views/HealthView.swift:135``"Hermes Running"` / `"Hermes Stopped"` ternary
- `Features/Chat/Views/ChatView.swift:125``"Active"` fallback
- `Features/Chat/Views/ChatView.swift:241``"Voice On" / "Voice Off"`
- `Features/Chat/Views/ChatView.swift:256``"TTS On" / "TTS Off"`
- `Features/Chat/Views/ChatView.swift:271``"Recording..." / "Push to Talk"`
- `Features/MCPServers/Views/MCPServerTestResultView.swift:13``"Test passed" / "Test failed"`
- `Features/Profiles/Views/ProfilesView.swift:145``"Active profile" / "Inactive"`
- `Features/Platforms/Views/PlatformSetup/SignalSetupView.swift:54``"signal-cli is available on PATH"` / not-found message
- `Features/QuickCommands/Views/QuickCommandsView.swift:148``"Add Quick Command"` / `"Edit /\(name)"` ternary
- `Features/MCPServers/Views/MCPServersView.swift:131``.help("\(count) tools")` vs `"Test failed"` ternary
- `Features/MCPServers/Views/MCPServerDetailView.swift:157, 185` — masked-value placeholder
Medium-priority (enum `.rawValue` → display):
- `Features/Logs/Views/LogsView.swift:30,38,48,69` — file / component / level display
- `Features/Projects/Views/ProjectsView.swift:153` — tab display
- `Features/Skills/Views/SkillsView.swift:37` — tab display
- `Features/Insights/Views/InsightsView.swift:40, 201` — period picker, day-of-week names (see also Category C)
- `Features/CredentialPools/Views/CredentialPoolsView.swift:265` — type display
- `Features/Activity/Views/ActivityView.swift:117` — kind display
Lower-priority (displayName passthroughs from services that could be wrapped once at the source):
- `Features/Platforms/Views/PlatformsView.swift:43, 91`, `Features/Tools/Views/ToolsView.swift:46``platform.displayName`
- `Features/Gateway/Views/GatewayView.swift:105``platform.name.capitalized`
- `Features/MCPServers/Views/*``transport.displayName`, preset names / descriptions
- `Features/Servers/Views/ServerSwitcherToolbar.swift:40`, `ManageServersView.swift:96``server.displayName`
### Category B — Composite format strings with translatable suffixes
- `Features/Settings/Views/Components/ModelPickerSheet.swift:105``ctx + " ctx"` — literal " ctx" needs keying
- `Features/Insights/Views/InsightsView.swift:93``formatTokens(...) + " tokens"` — literal " tokens" needs keying
- `Features/MCPServers/Views/MCPServerTestResultView.swift:15``"%.1fs · %d tools"` — needs splitting into a `String(localized: "\(elapsed)s · \(count) tools")`
- `Features/Insights/Views/InsightsView.swift:167``"%.1f%%"` — needs `.formatted(.percent)` after verifying the input range (0…1 vs 0…100)
### Category C — Hard-coded day / month name arrays
- `Features/Insights/Views/InsightsView.swift:196``["Mon", "Tue", …]` literal — use `Calendar.current.shortWeekdaySymbols` (locale-aware).
### Category D — `.help(stringVar)` sites
- `Features/Servers/Views/ConnectionStatusPill.swift:42``.help(tooltip)`; refactor `tooltip` to return `LocalizedStringKey` / `LocalizedStringResource`.
### Non-blocking (intentional verbatim) ### Non-blocking (intentional verbatim)
@@ -6,7 +6,7 @@ enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
var id: String { rawValue } var id: String { rawValue }
var displayName: String { var displayName: LocalizedStringResource {
switch self { switch self {
case .stdio: return "Local (stdio)" case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)" case .http: return "Remote (HTTP)"
@@ -99,6 +99,17 @@ enum ToolKind: String, Sendable, CaseIterable {
case browser case browser
case other case other
var displayName: LocalizedStringResource {
switch self {
case .read: return "Read"
case .edit: return "Edit"
case .execute: return "Execute"
case .fetch: return "Fetch"
case .browser: return "Browser"
case .other: return "Other"
}
}
var icon: String { var icon: String {
switch self { switch self {
case .read: return "doc.text.magnifyingglass" case .read: return "doc.text.magnifyingglass"
@@ -114,7 +114,7 @@ struct ActivityView: View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(entry.toolName) Text(entry.toolName)
.font(.title3.bold().monospaced()) .font(.title3.bold().monospaced())
Text(entry.kind.rawValue.capitalized) Text(entry.kind.displayName)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -122,7 +122,7 @@ struct ChatView: View {
Circle() Circle()
.fill(.green) .fill(.green)
.frame(width: 6, height: 6) .frame(width: 6, height: 6)
Text(viewModel.acpStatus.isEmpty ? "Active" : viewModel.acpStatus) (viewModel.acpStatus.isEmpty ? Text("Active") : Text(viewModel.acpStatus))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -238,7 +238,7 @@ struct ChatView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash") Image(systemName: viewModel.voiceEnabled ? "mic.fill" : "mic.slash")
.foregroundStyle(viewModel.voiceEnabled ? .green : .secondary) .foregroundStyle(viewModel.voiceEnabled ? .green : .secondary)
Text(viewModel.voiceEnabled ? "Voice On" : "Voice Off") (viewModel.voiceEnabled ? Text("Voice On") : Text("Voice Off"))
.font(.caption) .font(.caption)
.foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary) .foregroundStyle(viewModel.voiceEnabled ? .primary : .secondary)
} }
@@ -253,7 +253,7 @@ struct ChatView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash") Image(systemName: viewModel.ttsEnabled ? "speaker.wave.2.fill" : "speaker.slash")
.foregroundStyle(viewModel.ttsEnabled ? .green : .secondary) .foregroundStyle(viewModel.ttsEnabled ? .green : .secondary)
Text(viewModel.ttsEnabled ? "TTS On" : "TTS Off") (viewModel.ttsEnabled ? Text("TTS On") : Text("TTS Off"))
.font(.caption) .font(.caption)
.foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary) .foregroundStyle(viewModel.ttsEnabled ? .primary : .secondary)
} }
@@ -268,7 +268,7 @@ struct ChatView: View {
Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle") Image(systemName: viewModel.isRecording ? "waveform.circle.fill" : "waveform.circle")
.foregroundStyle(viewModel.isRecording ? .red : Color.accentColor) .foregroundStyle(viewModel.isRecording ? .red : Color.accentColor)
.symbolEffect(.pulse, isActive: viewModel.isRecording) .symbolEffect(.pulse, isActive: viewModel.isRecording)
Text(viewModel.isRecording ? "Recording..." : "Push to Talk") (viewModel.isRecording ? Text("Recording…") : Text("Push to Talk"))
.font(.caption) .font(.caption)
} }
} }
@@ -194,6 +194,13 @@ private struct AddCredentialSheet: View {
case apiKey = "API Key" case apiKey = "API Key"
case oauth = "OAuth" case oauth = "OAuth"
var id: String { rawValue } var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .apiKey: return "API Key"
case .oauth: return "OAuth"
}
}
} }
@State private var providerID: String = "" @State private var providerID: String = ""
@@ -262,7 +269,7 @@ private struct AddCredentialSheet: View {
Text("Credential Type").font(.caption).foregroundStyle(.secondary) Text("Credential Type").font(.caption).foregroundStyle(.secondary)
Picker("", selection: $authType) { Picker("", selection: $authType) {
ForEach(AuthType.allCases) { type in ForEach(AuthType.allCases) { type in
Text(type.rawValue).tag(type) Text(type.displayName).tag(type)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -102,7 +102,7 @@ struct GatewayView: View {
Image(systemName: platform.icon) Image(systemName: platform.icon)
.font(.title2) .font(.title2)
.foregroundStyle(platform.isConnected ? Color.accentColor : .secondary) .foregroundStyle(platform.isConnected ? Color.accentColor : .secondary)
Text(platform.name.capitalized) Text(verbatim: platform.name.capitalized)
.font(.caption.bold()) .font(.caption.bold())
StatusBadge( StatusBadge(
label: platform.state, label: platform.state,
@@ -132,7 +132,7 @@ struct HealthView: View {
Circle() Circle()
.fill(viewModel.hermesRunning ? .green : .red) .fill(viewModel.hermesRunning ? .green : .red)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(viewModel.hermesRunning ? "Hermes Running" : "Hermes Stopped") (viewModel.hermesRunning ? Text("Hermes Running") : Text("Hermes Stopped"))
.font(.caption.bold()) .font(.caption.bold())
if let pid = viewModel.hermesPID { if let pid = viewModel.hermesPID {
Text("PID \(pid)") Text("PID \(pid)")
@@ -8,6 +8,15 @@ enum InsightsPeriod: String, CaseIterable, Identifiable {
var id: String { rawValue } var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .week: return "7 Days"
case .month: return "30 Days"
case .quarter: return "90 Days"
case .all: return "All Time"
}
}
var sinceDate: Date { var sinceDate: Date {
let calendar = Calendar.current let calendar = Calendar.current
switch self { switch self {
@@ -37,7 +37,7 @@ struct InsightsView: View {
private var periodPicker: some View { private var periodPicker: some View {
Picker("Period", selection: $viewModel.period) { Picker("Period", selection: $viewModel.period) {
ForEach(InsightsPeriod.allCases) { period in ForEach(InsightsPeriod.allCases) { period in
Text(period.rawValue).tag(period) Text(period.displayName).tag(period)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -90,7 +90,7 @@ struct InsightsView: View {
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text("\(model.sessions) sessions") Text("\(model.sessions) sessions")
.font(.caption) .font(.caption)
Text(formatTokens(model.totalTokens) + " tokens") Text("\(formatTokens(model.totalTokens)) tokens")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -164,7 +164,7 @@ struct InsightsView: View {
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 40, alignment: .trailing) .frame(width: 40, alignment: .trailing)
Text(String(format: "%.1f%%", tool.percentage)) Text((tool.percentage / 100).formatted(.percent.precision(.fractionLength(1))))
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
.frame(width: 50, alignment: .trailing) .frame(width: 50, alignment: .trailing)
@@ -193,12 +193,12 @@ struct InsightsView: View {
Text("By Day") Text("By Day")
.font(.caption.bold()) .font(.caption.bold())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] let dayNames = Calendar.current.shortWeekdaySymbols
let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1) let maxVal = max(1, viewModel.dailyActivity.values.max() ?? 1)
ForEach(0..<7, id: \.self) { day in ForEach(0..<7, id: \.self) { day in
let count = viewModel.dailyActivity[day] ?? 0 let count = viewModel.dailyActivity[day] ?? 0
HStack(spacing: 6) { HStack(spacing: 6) {
Text(dayNames[day]) Text(verbatim: dayNames[(day + 1) % 7])
.font(.caption.monospaced()) .font(.caption.monospaced())
.frame(width: 30, alignment: .trailing) .frame(width: 30, alignment: .trailing)
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
@@ -23,6 +23,14 @@ final class LogsViewModel {
case gateway = "gateway.log" case gateway = "gateway.log"
var id: String { rawValue } var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .agent: return "Agent"
case .errors: return "Errors"
case .gateway: return "Gateway"
}
}
} }
private func path(for file: LogFile) -> String { private func path(for file: LogFile) -> String {
@@ -43,6 +51,17 @@ final class LogsViewModel {
var id: String { rawValue } var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .all: return "All"
case .gateway: return "Gateway"
case .agent: return "Agent"
case .tools: return "Tools"
case .cli: return "CLI"
case .cron: return "Cron"
}
}
var loggerPrefix: String? { var loggerPrefix: String? {
switch self { switch self {
case .all: return nil case .all: return nil
@@ -27,7 +27,7 @@ struct LogsView: View {
set: { file in Task { await viewModel.switchLogFile(file) } } set: { file in Task { await viewModel.switchLogFile(file) } }
)) { )) {
ForEach(LogsViewModel.LogFile.allCases) { file in ForEach(LogsViewModel.LogFile.allCases) { file in
Text(file.rawValue).tag(file) Text(file.displayName).tag(file)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -35,7 +35,7 @@ struct LogsView: View {
Picker("Component", selection: $viewModel.selectedComponent) { Picker("Component", selection: $viewModel.selectedComponent) {
ForEach(LogsViewModel.LogComponent.allCases) { component in ForEach(LogsViewModel.LogComponent.allCases) { component in
Text(component.rawValue).tag(component) Text(component.displayName).tag(component)
} }
} }
.frame(maxWidth: 140) .frame(maxWidth: 140)
@@ -45,7 +45,7 @@ struct LogsView: View {
Picker("Level", selection: $viewModel.filterLevel) { Picker("Level", selection: $viewModel.filterLevel) {
Text("All Levels").tag(LogEntry.LogLevel?.none) Text("All Levels").tag(LogEntry.LogLevel?.none)
ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in ForEach(LogEntry.LogLevel.allCases, id: \.rawValue) { level in
Text(level.rawValue).tag(LogEntry.LogLevel?.some(level)) Text(verbatim: level.rawValue).tag(LogEntry.LogLevel?.some(level))
} }
} }
.frame(maxWidth: 150) .frame(maxWidth: 150)
@@ -66,7 +66,7 @@ struct LogsView: View {
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading) .frame(width: 140, alignment: .leading)
Text(entry.level.rawValue) Text(verbatim: entry.level.rawValue)
.font(.caption.monospaced().bold()) .font(.caption.monospaced().bold())
.foregroundStyle(colorForLevel(entry.level)) .foregroundStyle(colorForLevel(entry.level))
.frame(width: 60, alignment: .leading) .frame(width: 60, alignment: .leading)
@@ -154,7 +154,7 @@ struct MCPServerDetailView: View {
Text(key) Text(key)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
Spacer() Spacer()
Text(String(repeating: "", count: 10)) Text("••••••••••")
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -182,7 +182,7 @@ struct MCPServerDetailView: View {
Text(key) Text(key)
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
Spacer() Spacer()
Text(String(repeating: "", count: 10)) Text("••••••••••")
.font(.caption.monospaced()) .font(.caption.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -33,9 +33,9 @@ struct MCPServerPresetPickerView: View {
} }
} }
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(selectedPreset?.displayName ?? "Add from Preset") (selectedPreset.map { Text(verbatim: $0.displayName) } ?? Text("Add from Preset"))
.font(.headline) .font(.headline)
Text(selectedPreset?.description ?? "Pick an MCP server to add.") (selectedPreset.map { Text(verbatim: $0.description) } ?? Text("Pick an MCP server to add."))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -83,14 +83,14 @@ struct MCPServerPresetPickerView: View {
Image(systemName: preset.iconSystemName) Image(systemName: preset.iconSystemName)
.font(.title3) .font(.title3)
.foregroundStyle(Color.accentColor) .foregroundStyle(Color.accentColor)
Text(preset.displayName) Text(verbatim: preset.displayName)
.font(.body.bold()) .font(.body.bold())
Spacer() Spacer()
Image(systemName: preset.transport == .http ? "network" : "terminal") Image(systemName: preset.transport == .http ? "network" : "terminal")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Text(preset.description) Text(verbatim: preset.description)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(3) .lineLimit(3)
@@ -10,9 +10,9 @@ struct MCPServerTestResultView: View {
Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill") Image(systemName: result.succeeded ? "checkmark.seal.fill" : "xmark.seal.fill")
.foregroundStyle(result.succeeded ? .green : .red) .foregroundStyle(result.succeeded ? .green : .red)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(result.succeeded ? "Test passed" : "Test failed") (result.succeeded ? Text("Test passed") : Text("Test failed"))
.font(.subheadline.bold()) .font(.subheadline.bold())
Text(String(format: "%.1fs · %d tools", result.elapsed, result.tools.count)) Text("\(result.elapsed.formatted(.number.precision(.fractionLength(1))))s · \(result.tools.count) tools")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -20,8 +20,12 @@ struct MCPServerTestResultView: View {
Button { Button {
showOutput.toggle() showOutput.toggle()
} label: { } label: {
Label(showOutput ? "Hide Output" : "Show Output", systemImage: showOutput ? "chevron.up" : "chevron.down") Label {
.font(.caption) showOutput ? Text("Hide Output") : Text("Show Output")
} icon: {
Image(systemName: showOutput ? "chevron.up" : "chevron.down")
}
.font(.caption)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
@@ -128,7 +128,7 @@ struct MCPServersView: View {
} else if let result = viewModel.testResults[server.name] { } else if let result = viewModel.testResults[server.name] {
Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill") Image(systemName: result.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(result.succeeded ? .green : .red) .foregroundStyle(result.succeeded ? .green : .red)
.help(result.succeeded ? "\(result.tools.count) tools" : "Test failed") .help(result.succeeded ? Text("\(result.tools.count) tools") : Text("Test failed"))
} }
} }
} }
@@ -51,7 +51,9 @@ struct SignalSetupView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") Image(systemName: viewModel.signalCLIInstalled ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange) .foregroundStyle(viewModel.signalCLIInstalled ? .green : .orange)
Text(viewModel.signalCLIInstalled ? "signal-cli is available on PATH" : "signal-cli not found on PATH — install it first") (viewModel.signalCLIInstalled
? Text("signal-cli is available on PATH")
: Text("signal-cli not found on PATH — install it first"))
.font(.caption) .font(.caption)
.foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange) .foregroundStyle(viewModel.signalCLIInstalled ? Color.primary : Color.orange)
Spacer() Spacer()
@@ -40,7 +40,7 @@ struct PlatformsView: View {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: KnownPlatforms.icon(for: platform.name)) Image(systemName: KnownPlatforms.icon(for: platform.name))
.frame(width: 20) .frame(width: 20)
Text(platform.displayName) Text(verbatim: platform.displayName)
Spacer() Spacer()
Circle() Circle()
.fill(statusColor(viewModel.connectivity(for: platform))) .fill(statusColor(viewModel.connectivity(for: platform)))
@@ -88,7 +88,7 @@ struct PlatformsView: View {
Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name)) Image(systemName: KnownPlatforms.icon(for: viewModel.selected.name))
.font(.title) .font(.title)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(viewModel.selected.displayName) Text(verbatim: viewModel.selected.displayName)
.font(.title2.bold()) .font(.title2.bold())
Text(statusDescription(viewModel.connectivity(for: viewModel.selected))) Text(statusDescription(viewModel.connectivity(for: viewModel.selected)))
.font(.caption) .font(.caption)
@@ -142,7 +142,7 @@ struct ProfilesView: View {
.font(.title) .font(.title)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(profile.name).font(.title2.bold()) Text(profile.name).font(.title2.bold())
Text(profile.isActive ? "Active profile" : "Inactive") (profile.isActive ? Text("Active profile") : Text("Inactive"))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -3,6 +3,13 @@ import SwiftUI
private enum DashboardTab: String, CaseIterable { private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard" case dashboard = "Dashboard"
case site = "Site" case site = "Site"
var displayName: LocalizedStringResource {
switch self {
case .dashboard: return "Dashboard"
case .site: return "Site"
}
}
} }
struct ProjectsView: View { struct ProjectsView: View {
@@ -150,7 +157,7 @@ struct ProjectsView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe") Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
.font(.caption) .font(.caption)
Text(tab.rawValue) Text(tab.displayName)
.font(.subheadline) .font(.subheadline)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -145,7 +145,7 @@ private struct QuickCommandEditor: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(initial == nil ? "Add Quick Command" : "Edit /\(initial!.name)") (initial == nil ? Text("Add Quick Command") : Text("Edit /\(initial!.name)"))
.font(.headline) .font(.headline)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("Name (no leading slash)") Text("Name (no leading slash)")
@@ -31,7 +31,7 @@ struct ConnectionStatusPill: View {
Image(systemName: iconName) Image(systemName: iconName)
.foregroundStyle(color) .foregroundStyle(color)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
Text(label) labelText
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(1) .lineLimit(1)
@@ -39,7 +39,7 @@ struct ConnectionStatusPill: View {
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help(tooltip) .help(tooltipText)
.popover(isPresented: $showDetails, arrowEdge: .bottom) { .popover(isPresented: $showDetails, arrowEdge: .bottom) {
errorDetails.frame(width: 400) errorDetails.frame(width: 400)
} }
@@ -70,27 +70,27 @@ struct ConnectionStatusPill: View {
} }
} }
private var label: String { private var labelText: Text {
switch status.status { switch status.status {
case .connected: return "Connected" case .connected: return Text("Connected")
case .degraded: return "Connected — can't read Hermes state" case .degraded: return Text("Connected — can't read Hermes state")
case .idle: return "Checking…" case .idle: return Text("Checking…")
case .error(let message, _): return message case .error(let message, _): return Text(verbatim: message)
} }
} }
private var tooltip: String { private var tooltipText: Text {
switch status.status { switch status.status {
case .connected: case .connected:
if let ts = status.lastSuccess { if let ts = status.lastSuccess {
let fmt = RelativeDateTimeFormatter() let fmt = RelativeDateTimeFormatter()
return "Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))" return Text("Last probe: \(fmt.localizedString(for: ts, relativeTo: Date()))")
} }
return "Connected" return Text("Connected")
case .degraded(let reason): case .degraded(let reason):
return "SSH works but \(reason). Click for diagnostics." return Text("SSH works but \(reason). Click for diagnostics.")
case .idle: return "Waiting for first probe" case .idle: return Text("Waiting for first probe")
case .error(_, _): return "Click for details" case .error: return Text("Click for details")
} }
} }
@@ -93,7 +93,7 @@ struct ManageServersView: View {
Image(systemName: "server.rack") Image(systemName: "server.rack")
.foregroundStyle(.blue) .foregroundStyle(.blue)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(entry.displayName).font(.body) Text(verbatim: entry.displayName).font(.body)
if case .ssh(let config) = entry.kind { if case .ssh(let config) = entry.kind {
Text(summary(for: config)) Text(summary(for: config))
.font(.caption) .font(.caption)
@@ -37,7 +37,7 @@ struct ServerSwitcherToolbar: View {
Circle() Circle()
.fill(current.isRemote ? Color.blue : Color.green) .fill(current.isRemote ? Color.blue : Color.green)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(current.displayName) Text(verbatim: current.displayName)
.font(.callout) .font(.callout)
.lineLimit(1) .lineLimit(1)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
@@ -102,7 +102,7 @@ struct ModelPickerSheet: View {
.font(.system(.body, design: .default, weight: .medium)) .font(.system(.body, design: .default, weight: .medium))
Spacer() Spacer()
if let ctx = model.contextDisplay { if let ctx = model.contextDisplay {
Text(ctx + " ctx") Text("\(ctx) ctx")
.font(.caption2.monospaced()) .font(.caption2.monospaced())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -14,6 +14,14 @@ struct SkillsView: View {
case hub = "Browse Hub" case hub = "Browse Hub"
case updates = "Updates" case updates = "Updates"
var id: String { rawValue } var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .installed: return "Installed"
case .hub: return "Browse Hub"
case .updates: return "Updates"
}
}
} }
var body: some View { var body: some View {
@@ -34,7 +42,7 @@ struct SkillsView: View {
HStack { HStack {
Picker("", selection: $currentTab) { Picker("", selection: $currentTab) {
ForEach(Tab.allCases) { tab in ForEach(Tab.allCases) { tab in
Text(tab.rawValue).tag(tab) Text(tab.displayName).tag(tab)
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
@@ -43,7 +43,7 @@ struct ToolsView: View {
} label: { } label: {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name)) Image(systemName: KnownPlatforms.icon(for: viewModel.selectedPlatform.name))
Text(viewModel.selectedPlatform.displayName) Text(verbatim: viewModel.selectedPlatform.displayName)
.fontWeight(.medium) .fontWeight(.medium)
statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured) statusDot(for: viewModel.connectivity[viewModel.selectedPlatform.name] ?? .notConfigured)
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
+98 -1
View File
@@ -6,6 +6,9 @@
}, },
"#%lld" : { "#%lld" : {
},
"%@ ctx" : {
}, },
"%@ in / %@ out" : { "%@ in / %@ out" : {
"localizations" : { "localizations" : {
@@ -19,6 +22,9 @@
}, },
"%@ reasoning" : { "%@ reasoning" : {
},
"%@ tokens" : {
}, },
"%@ · %@" : { "%@ · %@" : {
"localizations" : { "localizations" : {
@@ -40,6 +46,16 @@
} }
} }
}, },
"%@s · %lld tools" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@s · %2$lld tools"
}
}
}
},
"%lld" : { "%lld" : {
}, },
@@ -117,6 +133,15 @@
}, },
"22" : { "22" : {
},
"30 Days" : {
},
"7 Days" : {
},
"90 Days" : {
}, },
"<%@>" : { "<%@>" : {
@@ -132,6 +157,9 @@
}, },
"Actions" : { "Actions" : {
},
"Active" : {
}, },
"Active profile" : { "Active profile" : {
@@ -186,12 +214,21 @@
}, },
"After approving in your browser, the provider shows a code. Paste it below and submit." : { "After approving in your browser, the provider shows a code. Paste it below and submit." : {
},
"Agent" : {
},
"All" : {
}, },
"All Levels" : { "All Levels" : {
}, },
"All Sessions" : { "All Sessions" : {
},
"All Time" : {
}, },
"All installed hub skills are up to date." : { "All installed hub skills are up to date." : {
@@ -243,18 +280,27 @@
}, },
"Browse" : { "Browse" : {
},
"Browse Hub" : {
}, },
"Browse the Hub" : { "Browse the Hub" : {
}, },
"Browse..." : { "Browse..." : {
},
"Browser" : {
}, },
"By Day" : { "By Day" : {
}, },
"By Hour" : { "By Hour" : {
},
"CLI" : {
}, },
"Call timeout" : { "Call timeout" : {
@@ -285,6 +331,9 @@
}, },
"Check for Updates…" : { "Check for Updates…" : {
},
"Checking…" : {
}, },
"Choose a cron job from the list" : { "Choose a cron job from the list" : {
@@ -315,6 +364,9 @@
}, },
"Click Add to connect to a remote Hermes installation over SSH." : { "Click Add to connect to a remote Hermes installation over SSH." : {
},
"Click for details" : {
}, },
"Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next." : { "Clicking Start OAuth opens the provider's authorization page in your browser. After you approve, copy the code the provider displays and paste it back into the terminal that appears next." : {
@@ -357,6 +409,9 @@
}, },
"Connected" : { "Connected" : {
},
"Connected — can't read Hermes state" : {
}, },
"Connection" : { "Connection" : {
@@ -420,6 +475,9 @@
}, },
"Credentials" : { "Credentials" : {
},
"Cron" : {
}, },
"Cron Jobs" : { "Cron Jobs" : {
@@ -528,9 +586,15 @@
}, },
"Error" : { "Error" : {
},
"Errors" : {
}, },
"Exclude" : { "Exclude" : {
},
"Execute" : {
}, },
"Expected at %@" : { "Expected at %@" : {
@@ -552,6 +616,9 @@
}, },
"Feishu Setup Docs" : { "Feishu Setup Docs" : {
},
"Fetch" : {
}, },
"Files" : { "Files" : {
@@ -663,6 +730,9 @@
}, },
"Install signal-cli" : { "Install signal-cli" : {
},
"Installed" : {
}, },
"Interact" : { "Interact" : {
@@ -681,6 +751,9 @@
}, },
"Last Output" : { "Last Output" : {
},
"Last probe: %@" : {
}, },
"Last run: %@" : { "Last run: %@" : {
@@ -939,6 +1012,9 @@
}, },
"Optionally focus the summary on a specific topic. Leave blank to compress evenly." : { "Optionally focus the summary on a specific topic. Leave blank to compress evenly." : {
},
"Other" : {
}, },
"Output" : { "Output" : {
@@ -975,6 +1051,9 @@
}, },
"Personalities" : { "Personalities" : {
},
"Pick an MCP server to add." : {
}, },
"Pick one from the list, or add a new server from the toolbar." : { "Pick one from the list, or add a new server from the toolbar." : {
@@ -1044,6 +1123,9 @@
}, },
"Re-run" : { "Re-run" : {
},
"Read" : {
}, },
"Reasoning" : { "Reasoning" : {
@@ -1054,7 +1136,7 @@
"Reconnect" : { "Reconnect" : {
}, },
"Recording..." : { "Recording" : {
}, },
"Refresh" : { "Refresh" : {
@@ -1185,6 +1267,9 @@
}, },
"SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context." : { "SOUL.md describes the agent's voice, values, and personality at ~/.hermes/SOUL.md. It is injected into every session's context." : {
},
"SSH works but %@. Click for diagnostics." : {
}, },
"Save" : { "Save" : {
@@ -1314,6 +1399,9 @@
}, },
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : { "Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
},
"Site" : {
}, },
"Skills" : { "Skills" : {
@@ -1498,6 +1586,9 @@
}, },
"Updated: %@" : { "Updated: %@" : {
},
"Updates" : {
}, },
"Upload" : { "Upload" : {
@@ -1537,6 +1628,9 @@
}, },
"Waiting for authorization URL…" : { "Waiting for authorization URL…" : {
},
"Waiting for first probe" : {
}, },
"Waiting for hermes to prompt for the code…" : { "Waiting for hermes to prompt for the code…" : {
@@ -1684,6 +1778,9 @@
}, },
"•" : { "•" : {
},
"••••••••••" : {
} }
}, },
"version" : "1.0" "version" : "1.0"