From 9a873c17df40ca8e5eb4427d48db56c3e428a5fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=8E=E9=87=91=E5=90=8C=E5=AD=A6?= Date: Mon, 21 Sep 2026 05:41:06 +0000 Subject: [PATCH 1/2] feat(web): add Simplified Chinese (zh-CN) UI locale Add catalog-backed i18n for web chrome with English as the source locale and a complete zh-CN catalog. Language can be set via ?lang=, cookie, config locale, or Accept-Language, plus a top-nav switcher. --- CHANGELOG.md | 3 + CLAUDE.md | 1 + CONTRIBUTING.md | 1 + README.md | 1 + docs/devlog/2026-09-21-web-i18n-zh.org | 41 ++ internal/cmd/config.go | 2 + internal/cmd/root.go | 1 + internal/config/config.go | 6 + internal/config/settings.go | 2 + internal/i18n/detect.go | 98 ++++ internal/i18n/en.go | 308 ++++++++++++ internal/i18n/i18n.go | 168 +++++++ internal/i18n/i18n_test.go | 170 +++++++ internal/i18n/zh.go | 308 ++++++++++++ internal/web/locale.go | 132 +++++ internal/web/locale_test.go | 114 +++++ internal/web/server.go | 57 ++- internal/web/sessions.go | 169 ++++--- internal/web/static/style.css | 29 ++ internal/web/templates.go | 651 ++++++++++++++----------- internal/web/timeline.go | 10 +- 21 files changed, 1873 insertions(+), 399 deletions(-) create mode 100644 docs/devlog/2026-09-21-web-i18n-zh.org create mode 100644 internal/i18n/detect.go create mode 100644 internal/i18n/en.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/i18n/zh.go create mode 100644 internal/web/locale.go create mode 100644 internal/web/locale_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 68fbf53..a1b0df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +### Added +- **Web UI Simplified Chinese.** The session viewer chrome — nav, sidebar, project/session lists, search, settings, memory, insights, session dock, info panel, 404 — is translated. Pick 中文 in the language switcher, pass `?lang=zh-CN`, set `locale: zh-CN` in `~/.config/ccx/config.yaml`, or let `Accept-Language` choose. English stays the default. Traditional Chinese is not a catalog yet. + ## [0.17.0] - 2026-09-14 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index f805908..8e72852 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ ccx/ parser/ JSONL parsing + tree building render/ Output formats (HTML, MD, Org, JSON) web/ HTTP server + embedded templates + i18n/ Web UI catalogs (en, zh-CN) db/ SQLite persistence (stars) config/ CLAUDE_CODE_HOME logic ``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ad0548..e217ce9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ internal/ ├── parser/ JSONL parsing + tree building ├── render/ Output formats (HTML, MD, Org) ├── web/ HTTP server + templates +├── i18n/ Web UI catalogs (en, zh-CN) ├── db/ SQLite persistence (stars, cache) └── config/ Configuration paths ``` diff --git a/README.md b/README.md index d6b210e..b612f3c 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ ccx doctor # Check setup ```yaml # ~/.config/ccx/config.yaml theme: dark +locale: auto # auto | en | zh-CN show_thinking: collapsed default_format: html providers: diff --git a/docs/devlog/2026-09-21-web-i18n-zh.org b/docs/devlog/2026-09-21-web-i18n-zh.org new file mode 100644 index 0000000..5cd7239 --- /dev/null +++ b/docs/devlog/2026-09-21-web-i18n-zh.org @@ -0,0 +1,41 @@ +* [2026-09-21] Dev Log: Web UI Simplified Chinese :WEB:I18N: + +** Context +The web UI was English-only: chrome lived as string literals in +=internal/web/templates.go= and friends. The user asked to finish +multilingual support, specifically Chinese. + +** Why +A Chinese-speaking operator should be able to browse projects, +sessions, search, and the session dock without translating the +chrome in their head. Session *content* stays as stored. + +** What +- =FEAT= =internal/i18n= catalog: English is the source locale; + =zh-CN= covers the same keys. Tests fail on missing or empty + translations. +- =FEAT= Language detection: =?lang==, =ccx-lang= cookie, config + =locale=, then =Accept-Language=. Default remains English. +- =FEAT= Language switcher in the top nav (English / 简体中文). +- =CHANGE= HTML =lang= and =window.CCX_I18N= so client JS uses the + same catalog. + +Traditional Chinese is not a catalog. CLI help is still English. + +** How +Request-scoped =loc= translator. Top-level renderers are methods on +=loc=; package-level wrappers keep English for existing tests that +call them directly. + +** Decisions +| Decision | Alternatives | Rationale | DRI | Timestamp | +| Catalog maps in Go | gettext / x/text/message | Zero new deps; single binary | — | 2026-09-21 | +| zh-CN only | Also zh-TW | No existing Traditional surface | — | 2026-09-21 | +| Web UI first | Also CLI cobra help | CLI is not on the same catalog | — | 2026-09-21 | + +** Notes +- =html.EscapeString= turns apostrophes into ='=. Catalog text + in element bodies uses a lighter escape so "Can't" stays readable. +- =zh-TW= / =zh-Hant= do not fall through to Simplified Chinese. +- Pages emit =translate="no"= and =google= =notranslate= so browser + auto-translate does not fight the in-app locale. diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 0c377ce..dce335b 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -24,6 +24,7 @@ var configShowCmd = &cobra.Command{ fmt.Printf("claude_code_home: %s\n", config.ClaudeHome()) fmt.Printf("codex_home: %s\n", config.CodexHome()) fmt.Printf("theme: %s\n", config.Theme()) + fmt.Printf("locale: %s\n", config.Locale()) fmt.Printf("rendering.syntax_highlight: %v\n", config.SyntaxHighlight()) fmt.Printf("rendering.show_thinking: %s\n", config.ShowThinking()) fmt.Printf("rendering.code_theme: %s\n", config.CodeTheme()) @@ -79,6 +80,7 @@ var configInitCmd = &cobra.Command{ # codex_home: ~/.codex theme: dark # dark | light | auto +locale: auto # auto | en | zh-CN rendering: syntax_highlight: true diff --git a/internal/cmd/root.go b/internal/cmd/root.go index df02c7b..196faa5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -116,6 +116,7 @@ func initConfig() { viper.SetDefault("codex_home", config.DefaultCodexHome()) viper.SetDefault("grok_home", config.DefaultGrokHome()) viper.SetDefault("theme", "dark") + viper.SetDefault("locale", "auto") viper.SetDefault("rendering.syntax_highlight", true) viper.SetDefault("rendering.show_thinking", "collapsed") viper.SetDefault("rendering.code_theme", "monokai") diff --git a/internal/config/config.go b/internal/config/config.go index 29f7e2c..1e77935 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,12 @@ func Theme() string { return viper.GetString("theme") } +// Locale is the configured UI language: auto, en, or zh-CN. +// Empty and "auto" mean "detect from the request". +func Locale() string { + return viper.GetString("locale") +} + func SyntaxHighlight() bool { return viper.GetBool("rendering.syntax_highlight") } diff --git a/internal/config/settings.go b/internal/config/settings.go index 5037415..62df784 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -8,6 +8,7 @@ type Settings struct { GrokHome string Theme string + Locale string SyntaxHighlight bool ShowThinking string CodeTheme string @@ -54,6 +55,7 @@ func Load() *Settings { CodexHome: CodexHome(), GrokHome: GrokHome(), Theme: viper.GetString("theme"), + Locale: viper.GetString("locale"), SyntaxHighlight: viper.GetBool("rendering.syntax_highlight"), ShowThinking: viper.GetString("rendering.show_thinking"), CodeTheme: viper.GetString("rendering.code_theme"), diff --git a/internal/i18n/detect.go b/internal/i18n/detect.go new file mode 100644 index 0000000..fd0268b --- /dev/null +++ b/internal/i18n/detect.go @@ -0,0 +1,98 @@ +package i18n + +import ( + "net/http" + "strconv" + "strings" +) + +// Detect picks a locale for an HTTP request. +// +// Precedence (most specific first): +// 1. ?lang= query — explicit user action, also persisted as a cookie +// 2. ccx-lang cookie — last explicit choice +// 3. configured locale, when it is a real catalog (not auto/empty) +// 4. Accept-Language +// 5. English +// +// fromQuery is true only when the query param selected the locale, +// so the caller can persist the cookie. +func Detect(r *http.Request, configured string) (t *Translator, fromQuery bool) { + if r != nil { + if raw := strings.TrimSpace(r.URL.Query().Get(QueryParam)); raw != "" { + if code := Normalize(raw); code != "" { + return For(code), true + } + } + if c, err := r.Cookie(CookieName); err == nil && c.Value != "" { + if code := Normalize(c.Value); code != "" { + return For(code), false + } + } + } + if code := Normalize(configured); code != "" { + return For(code), false + } + if r != nil { + if code := matchAcceptLanguage(r.Header.Get("Accept-Language")); code != "" { + return For(code), false + } + } + return Default(), false +} + +// matchAcceptLanguage walks a standard Accept-Language header and +// returns the first tag we have a catalog for. q-values are honored +// so "en;q=0.8, zh-CN;q=0.9" selects zh-CN. +func matchAcceptLanguage(header string) string { + if header == "" { + return "" + } + type tagged struct { + code string + q float64 + } + var tags []tagged + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + name, rest, _ := strings.Cut(part, ";") + q := 1.0 + rest = strings.TrimSpace(rest) + if strings.HasPrefix(rest, "q=") { + if v, err := strconv.ParseFloat(strings.TrimSpace(rest[2:]), 64); err == nil { + q = v + } + } + code := Normalize(strings.TrimSpace(name)) + if code == "" { + // Prefix match: zh-HK → no Traditional catalog, skip. + // zh → already handled by Normalize. + continue + } + tags = append(tags, tagged{code: code, q: q}) + } + bestQ := -1.0 + best := "" + for _, t := range tags { + if t.q > bestQ { + bestQ = t.q + best = t.code + } + } + return best +} + +// Cookie builds the persistence cookie for an explicit language choice. +func Cookie(code string) *http.Cookie { + return &http.Cookie{ + Name: CookieName, + Value: Normalize(code), + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + SameSite: http.SameSiteLaxMode, + HttpOnly: false, // language switcher is a `) + for _, opt := range i18n.Locales() { + sel := "" + if opt.Code == l.Code() { + sel = " selected" + } + // Labels stay in their own script so the switcher is recognizable + // regardless of the active locale (English / 简体中文). + label := opt.Label + if opt.Code == i18n.ZhCN { + label = l.T("nav.lang_zh") + } else { + label = l.T("nav.lang_en") + } + b.WriteString(``) + } + b.WriteString(``) + return b.String() +} + +func htmlText(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + return s +} + +// ui escapes catalog text for HTML body content. Apostrophes stay +// literal so tests and readers see "Can't" rather than "Can't". +func ui(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} diff --git a/internal/web/locale_test.go b/internal/web/locale_test.go new file mode 100644 index 0000000..176f44e --- /dev/null +++ b/internal/web/locale_test.go @@ -0,0 +1,114 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/thevibeworks/ccx/internal/i18n" +) + +func TestIndexPage_ChineseQuery(t *testing.T) { + dir := setupTestDir(t) + setTestBackend(dir) + + req := httptest.NewRequest("GET", "/?lang=zh-CN", nil) + w := httptest.NewRecorder() + localeMiddleware(http.HandlerFunc(handleIndex)).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + body := w.Body.String() + for _, want := range []string{ + `lang="zh-CN"`, + `translate="no"`, + "项目", + "会话", + "设置", + "简体中文", + `id="lang-switch"`, + `window.CCX_LOCALE="zh-CN"`, + } { + if !strings.Contains(body, want) { + t.Errorf("Chinese index missing %q", want) + } + } + if strings.Contains(body, "

Projects

") { + t.Error("Chinese index still has English Projects heading") + } + + cookies := w.Result().Cookies() + var got string + for _, c := range cookies { + if c.Name == i18n.CookieName { + got = c.Value + } + } + if got != i18n.ZhCN { + t.Errorf("cookie = %q, want %s", got, i18n.ZhCN) + } +} + +func TestIndexPage_AcceptLanguage(t *testing.T) { + dir := setupTestDir(t) + setTestBackend(dir) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8") + w := httptest.NewRecorder() + handleIndex(w, req) + + if !strings.Contains(w.Body.String(), "项目") { + t.Error("Accept-Language zh-CN should render 项目") + } +} + +func TestIndexPage_CookieBeatsAcceptLanguage(t *testing.T) { + dir := setupTestDir(t) + setTestBackend(dir) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept-Language", "zh-CN") + req.AddCookie(&http.Cookie{Name: i18n.CookieName, Value: i18n.En}) + w := httptest.NewRecorder() + handleIndex(w, req) + + body := w.Body.String() + if !strings.Contains(body, "

Projects

") { + t.Error("cookie en should keep English heading") + } + if strings.Contains(body, "

项目

") { + t.Error("cookie en should not render Chinese heading") + } +} + +func TestSessionsPage_Chinese(t *testing.T) { + dir := setupMultiProjectDir(t) + setTestBackend(dir) + + req := httptest.NewRequest("GET", "/sessions?lang=zh", nil) + w := httptest.NewRecorder() + handleSessionsPage(w, req) + + body := w.Body.String() + for _, want := range []string{"会话", "全部提供方", "更多筛选"} { + if !strings.Contains(body, want) { + t.Errorf("Chinese sessions page missing %q", want) + } + } +} + +func TestDefaultLocaleStaysEnglish(t *testing.T) { + dir := setupTestDir(t) + setTestBackend(dir) + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handleIndex(w, req) + + if !strings.Contains(w.Body.String(), "

Projects

") { + t.Error("default locale should stay English") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index d0b480e..8518038 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -3,6 +3,7 @@ package web import ( "encoding/json" "fmt" + "html" "log" "net/http" "os" @@ -116,8 +117,9 @@ func Serve(addr string, backend provider.Backend) error { // File content API (for agents/skills) mux.HandleFunc("/api/file", handleAPIFile) - // Wrap with logging middleware - handler := logRequest(mux) + // Locale first so every page (and the cookie) sees the same choice, + // then request logging. + handler := localeMiddleware(logRequest(mux)) server := &http.Server{ Addr: addr, @@ -211,7 +213,7 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderIndexPage(projects, totalSessions, search, sortBy)) + fmt.Fprint(w, locFrom(r).renderIndexPage(projects, totalSessions, search, sortBy)) } func handleProject(w http.ResponseWriter, r *http.Request) { @@ -270,7 +272,7 @@ func handleProject(w http.ResponseWriter, r *http.Request) { memFiles := loadProjectMemory(project.EncodedName) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderProjectPage(project, sessions, allProjects, memFiles, search, sortBy)) + fmt.Fprint(w, locFrom(r).renderProjectPage(project, sessions, allProjects, memFiles, search, sortBy)) } func handleSession(w http.ResponseWriter, r *http.Request) { @@ -336,7 +338,7 @@ func handleSession(w http.ResponseWriter, r *http.Request) { memCount := len(loadProjectMemory(projectName)) w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderSessionPage(fullSession, projectName, allSessions, memCount, showThinking, showTools, loadAll, theme, traceTurns, turnTarget)) + fmt.Fprint(w, locFrom(r).renderSessionPage(fullSession, projectName, allSessions, memCount, showThinking, showTools, loadAll, theme, traceTurns, turnTarget)) } // resolveTurnTarget maps a ?turn= value ("54" or "54.10", the trace @@ -378,7 +380,7 @@ func handleSettings(w http.ResponseWriter, r *http.Request) { skills := loadSkills() w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderSettingsPage(settings, globalConfig, configFiles, agents, skills)) + fmt.Fprint(w, locFrom(r).renderSettingsPage(settings, globalConfig, configFiles, agents, skills)) } func loadAgents() []AgentInfo { @@ -437,7 +439,7 @@ func loadSkills() []SkillInfo { func handleMemory(w http.ResponseWriter, r *http.Request) { data := loadMemories() w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderMemoryPage(data)) + fmt.Fprint(w, locFrom(r).renderMemoryPage(data)) } func loadMemories() *MemoryData { @@ -1129,7 +1131,7 @@ func handleSearchPage(w http.ResponseWriter, r *http.Request) { query := q.Get("q") w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderSearchPage(strings.Join(providerHomes, ", "), query)) + fmt.Fprint(w, locFrom(r).renderSearchPage(strings.Join(providerHomes, ", "), query)) } // exportSafePermalinks rewrites turn permalinks for standalone files: @@ -1181,7 +1183,7 @@ func handleAPIExport(w http.ResponseWriter, r *http.Request) { case "html": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.html", truncate(sessionID, 8))) - page := renderSessionPage(fullSession, projectName, nil, 0, true, true, true, "light", trace.Analyze(fullSession).Turns, "") + page := locFrom(r).renderSessionPage(fullSession, projectName, nil, 0, true, true, true, "light", trace.Analyze(fullSession).Turns, "") fmt.Fprint(w, exportSafePermalinks(page)) case "md", "markdown": w.Header().Set("Content-Type", "text/markdown; charset=utf-8") @@ -1681,19 +1683,23 @@ func getFirstTextContent(msg *parser.Message) string { } func formatAge(t time.Time) string { + return defaultLoc().age(t) +} + +func (l loc) age(t time.Time) string { if t.IsZero() { - return "N/A" + return l.T("time.na") } d := time.Since(t) switch { case d < time.Minute: - return "just now" + return l.T("time.just_now") case d < time.Hour: - return fmt.Sprintf("%dm ago", int(d.Minutes())) + return l.T("time.age_min", int(d.Minutes())) case d < 24*time.Hour: - return fmt.Sprintf("%dh ago", int(d.Hours())) + return l.T("time.age_hour", int(d.Hours())) case d < 7*24*time.Hour: - return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + return l.T("time.age_day", int(d.Hours()/24)) default: return t.Format("2006-01-02") } @@ -1710,22 +1716,25 @@ func handleInsights(w http.ResponseWriter, r *http.Request) { theme = config.Theme() } + l := locFrom(r) var b strings.Builder - b.WriteString(pageHeader("Insights", theme)) - b.WriteString(renderTopNav("", "")) + b.WriteString(l.pageHeader(l.T("title.insights"), theme)) + b.WriteString(l.renderTopNav("", "")) b.WriteString(`
`) - b.WriteString(renderSidebar("insights")) + b.WriteString(l.renderSidebar("insights")) b.WriteString(`
`) - b.WriteString(``) + b.WriteString(``) if len(reports) == 0 { - b.WriteString(`

No insight reports yet. Generate one with: ccx insight --scope week --all

`) + b.WriteString(`

` + l.T("insights.empty") + `

`) } else { + th := `style="text-align:left;padding:6px 8px;border-bottom:2px solid #e5e5e0;font-size:11px;text-transform:uppercase;color:#6b7280"` + thRight := `style="text-align:right;padding:6px 8px;border-bottom:2px solid #e5e5e0;font-size:11px;text-transform:uppercase;color:#6b7280"` b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) - b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) + b.WriteString(``) for _, rpt := range reports { size := fmt.Sprintf("%.0fKB", float64(rpt.Size)/1024) b.WriteString(fmt.Sprintf(``, @@ -1734,7 +1743,7 @@ func handleInsights(w http.ResponseWriter, r *http.Request) { rpt.Name, rpt.Name)) b.WriteString(fmt.Sprintf(``, rpt.Scope)) b.WriteString(fmt.Sprintf(``, size)) - b.WriteString(fmt.Sprintf(``, formatAge(rpt.CreatedAt))) + b.WriteString(fmt.Sprintf(``, l.age(rpt.CreatedAt))) b.WriteString(``) } b.WriteString(`
ReportScopeSizeCreated
` + html.EscapeString(l.T("insights.report")) + `` + html.EscapeString(l.T("insights.scope")) + `` + html.EscapeString(l.T("insights.size")) + `` + html.EscapeString(l.T("insights.created")) + `
%s%s%s%s
`) diff --git a/internal/web/sessions.go b/internal/web/sessions.go index 3b0a779..5e03f84 100644 --- a/internal/web/sessions.go +++ b/internal/web/sessions.go @@ -372,7 +372,7 @@ func handleSessionsPage(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, renderSessionsPage(projects, view)) + fmt.Fprint(w, locFrom(r).renderSessionsPage(projects, view)) } type sessionsView struct { @@ -406,81 +406,85 @@ func formatCount(n int) string { } func renderSessionsPage(projects []*parser.Project, v sessionsView) string { + return defaultLoc().renderSessionsPage(projects, v) +} + +func (l loc) renderSessionsPage(projects []*parser.Project, v sessionsView) string { sq := v.Query var b strings.Builder - b.WriteString(pageHeader("Sessions - ccx", ccxconfig.Theme())) - b.WriteString(renderTopNav("", "")) + b.WriteString(l.pageHeader(l.T("title.sessions"), ccxconfig.Theme())) + b.WriteString(l.renderTopNav("", "")) b.WriteString(`
`) - b.WriteString(renderSidebar("sessions")) + b.WriteString(l.renderSidebar("sessions")) b.WriteString(`
`) b.WriteString(``) - b.WriteString(renderSessionsControls(projects, sq)) - b.WriteString(renderFilterChips(projects, sq)) + b.WriteString(l.renderSessionsControls(projects, sq)) + b.WriteString(l.renderFilterChips(projects, sq)) if v.Shown == 0 { if sq.Filtered() { - b.WriteString(`
No sessions match these filters.
Clear filters
`) + b.WriteString(`
` + html.EscapeString(l.T("sessions.empty_filtered")) + `
` + html.EscapeString(l.T("sessions.clear_filters")) + `
`) } else { - b.WriteString(`
No sessions found. ccx reads ~/.claude, ~/.codex and ~/.grok — start an agent session first.
`) + b.WriteString(`
` + html.EscapeString(l.T("sessions.empty")) + `
`) } } else { for _, g := range v.Groups { b.WriteString(`
`) if g.Label != "" { - renderGroupHead(&b, g, sq.GroupBy) + l.renderGroupHead(&b, g, sq.GroupBy) } b.WriteString(`
`) for _, e := range g.Entries { - renderSessionRow(&b, e, sq.GroupBy) + l.renderSessionRow(&b, e, sq.GroupBy) } b.WriteString(`
`) } } - b.WriteString(renderSessionsFooter(sq, v)) + b.WriteString(l.renderSessionsFooter(sq, v)) b.WriteString(`
`) b.WriteString(`
`) b.WriteString(renderFooter()) - b.WriteString(indexJS()) + b.WriteString(l.indexJS()) b.WriteString(sessionsJS()) - b.WriteString(pageFooter()) + b.WriteString(l.pageFooter()) return b.String() } -func renderSessionsControls(projects []*parser.Project, sq sessionsQuery) string { +func (l loc) renderSessionsControls(projects []*parser.Project, sq sessionsQuery) string { var b strings.Builder prov := sq.Filter.Filter.Provider b.WriteString(`
`) b.WriteString(`
`) - b.WriteString(fmt.Sprintf(``, html.EscapeString(sq.Search))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("sessions.filter")), html.EscapeString(sq.Search))) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) - b.WriteString(fmt.Sprintf(` + - `, selected(prov, "claude-code"), selected(prov, "codex"), selected(prov, "grok"))) + `, html.EscapeString(l.T("project.filter_provider")), html.EscapeString(l.T("sessions.all_providers")), selected(prov, "claude-code"), selected(prov, "codex"), selected(prov, "grok"))) - b.WriteString(``, html.EscapeString(l.T("sessions.group_project")), html.EscapeString(l.T("sessions.all_projects")))) for _, p := range projects { b.WriteString(fmt.Sprintf(``, html.EscapeString(p.EncodedName), selected(sq.Project, p.EncodedName), @@ -488,34 +492,34 @@ func renderSessionsControls(projects []*parser.Project, sq sessionsQuery) string } b.WriteString(``) - b.WriteString(`Group:`) + b.WriteString(`` + html.EscapeString(l.T("sessions.group")) + ``) b.WriteString(fmt.Sprintf(``, selected(sq.GroupBy, "project"), selected(sq.GroupBy, "day"), selected(sq.GroupBy, "provider"), selected(sq.GroupBy, "model"))) - - b.WriteString(`Sort:`) + + + + + + `, html.EscapeString(l.T("sessions.group_none")), selected(sq.GroupBy, "project"), html.EscapeString(l.T("sessions.group_project")), selected(sq.GroupBy, "day"), html.EscapeString(l.T("sessions.group_day")), selected(sq.GroupBy, "provider"), html.EscapeString(l.T("sessions.group_provider")), selected(sq.GroupBy, "model"), html.EscapeString(l.T("sessions.group_model")))) + + b.WriteString(`` + html.EscapeString(l.T("projects.sort")) + ``) b.WriteString(fmt.Sprintf(``, selected(sq.SortBy, "messages"), selected(sq.SortBy, "prompts"), selected(sq.SortBy, "tokens"))) + + + + + `, html.EscapeString(l.T("projects.sort_recent")), selected(sq.SortBy, "messages"), html.EscapeString(l.T("project.sort_messages")), selected(sq.SortBy, "prompts"), html.EscapeString(l.T("sessions.sort_prompts")), selected(sq.SortBy, "tokens"), html.EscapeString(l.T("sessions.sort_tokens")))) - b.WriteString(``) + b.WriteString(``) b.WriteString(`
`) open := "" if sq.Model != "" || sq.After != "" || sq.Before != "" { open = " open" } - b.WriteString(fmt.Sprintf(`
More filters
`, open)) - b.WriteString(fmt.Sprintf(``, html.EscapeString(sq.Model))) - b.WriteString(fmt.Sprintf(``, html.EscapeString(sq.After))) - b.WriteString(fmt.Sprintf(``, html.EscapeString(sq.Before))) + b.WriteString(fmt.Sprintf(`
%s
`, open, html.EscapeString(l.T("sessions.more_filters")))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("sessions.model")), html.EscapeString(l.T("sessions.model_ph")), html.EscapeString(sq.Model))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("sessions.after")), html.EscapeString(sq.After))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("sessions.before")), html.EscapeString(sq.Before))) b.WriteString(`
`) b.WriteString(``) @@ -524,7 +528,7 @@ func renderSessionsControls(projects []*parser.Project, sq sessionsQuery) string // renderFilterChips makes every active filter visible and clearable // without JS — each x is a link to the same URL minus that param. -func renderFilterChips(projects []*parser.Project, sq sessionsQuery) string { +func (l loc) renderFilterChips(projects []*parser.Project, sq sessionsQuery) string { type chip struct{ param, label, value string } var chips []chip if sq.Search != "" { @@ -559,16 +563,16 @@ func renderFilterChips(projects []*parser.Project, sq sessionsQuery) string { var b strings.Builder b.WriteString(`
`) for _, c := range chips { - b.WriteString(fmt.Sprintf(`%s: %sx`, + b.WriteString(fmt.Sprintf(`%s: %sx`, c.label, html.EscapeString(c.value), - html.EscapeString(sessionsPageURL(sq, c.param, "")), c.label)) + html.EscapeString(sessionsPageURL(sq, c.param, "")), html.EscapeString(l.T("sessions.remove_filter", c.label)))) } - b.WriteString(`Clear all`) + b.WriteString(`` + html.EscapeString(l.T("sessions.clear_all")) + ``) b.WriteString(`
`) return b.String() } -func renderGroupHead(b *strings.Builder, g *sessionGroup, mode string) { +func (l loc) renderGroupHead(b *strings.Builder, g *sessionGroup, mode string) { b.WriteString(`
`) if mode == "provider" { b.WriteString(providerBadgeHTML(g.Key)) @@ -579,9 +583,9 @@ func renderGroupHead(b *strings.Builder, g *sessionGroup, mode string) { yesterday := time.Now().Local().AddDate(0, 0, -1).Format("2006-01-02") switch g.Key { case today: - label += ` · today` + label += ` · ` + html.EscapeString(l.T("sessions.today")) + `` case yesterday: - label += ` · yesterday` + label += ` · ` + html.EscapeString(l.T("sessions.yesterday")) + `` } } if g.Href != "" { @@ -590,24 +594,24 @@ func renderGroupHead(b *strings.Builder, g *sessionGroup, mode string) { b.WriteString(fmt.Sprintf(`%s`, label)) } - count := "1 session" + count := l.T("sessions.session_one") if n := len(g.Entries); n != 1 { - count = fmt.Sprintf("%s sessions", formatCount(n)) + count = l.T("sessions.session_other", formatCount(n)) } // Everything past the count collapses at the 700px breakpoint. var extra []string if mode != "project" && g.ProjectCount() > 0 { if g.ProjectCount() == 1 { - extra = append(extra, "1 project") + extra = append(extra, l.T("sessions.project_one")) } else { - extra = append(extra, fmt.Sprintf("%d projects", g.ProjectCount())) + extra = append(extra, l.T("sessions.project_other", g.ProjectCount())) } } if g.Tokens > 0 { - extra = append(extra, formatTokens(g.Tokens)+" tok") + extra = append(extra, formatTokens(g.Tokens)+" "+l.T("sessions.tok")) } if mode == "project" && !g.Latest.IsZero() { - extra = append(extra, formatAge(g.Latest)) + extra = append(extra, l.age(g.Latest)) } b.WriteString(`` + count) if len(extra) > 0 { @@ -626,10 +630,17 @@ func sessionRowLabel(s *parser.Session) string { if s.Summary != "" { return s.Summary } - return "(no summary)" + return "" +} + +func (l loc) sessionRowLabel(s *parser.Session) string { + if label := sessionRowLabel(s); label != "" { + return label + } + return l.T("sessions.no_summary") } -func renderSessionRow(b *strings.Builder, e sessionEntry, groupBy string) { +func (l loc) renderSessionRow(b *strings.Builder, e sessionEntry, groupBy string) { s := e.Session enc := "" if e.Project != nil { @@ -647,19 +658,19 @@ func renderSessionRow(b *strings.Builder, e sessionEntry, groupBy string) { s.EndTime.Format("15:04"), formatDuration(dur)) if s.Stats.ToolCalls > 0 { - tip += fmt.Sprintf(" | %d tool calls", s.Stats.ToolCalls) + tip += " | " + l.T("sessions.tool_calls", s.Stats.ToolCalls) } - tip += " | " + sessionRowLabel(s) + tip += " | " + l.sessionRowLabel(s) stat := func(n int, unit, extraClass string) string { if n <= 0 { - return fmt.Sprintf(`-`, extraClass) + return fmt.Sprintf(`-`, extraClass, html.EscapeString(l.T("sessions.not_reported"))) } return fmt.Sprintf(`%s %s`, extraClass, formatCount(n), unit) } - tokCell := `-` + tokCell := fmt.Sprintf(`-`, html.EscapeString(l.T("sessions.not_reported"))) if tok := s.Stats.InputTokens + s.Stats.OutputTokens; tok > 0 { - tokCell = fmt.Sprintf(`%s tok`, formatTokens(tok)) + tokCell = fmt.Sprintf(`%s %s`, formatTokens(tok), html.EscapeString(l.T("sessions.tok"))) } var meta strings.Builder @@ -677,10 +688,10 @@ func renderSessionRow(b *strings.Builder, e sessionEntry, groupBy string) { } meta.WriteString(fmt.Sprintf(`%s`, html.EscapeString(model))) } - meta.WriteString(stat(s.Stats.MessageCount, "msg", "")) - promptUnit := "prompts" + meta.WriteString(stat(s.Stats.MessageCount, l.T("sessions.msg"), "")) + promptUnit := l.T("sessions.prompt_other") if s.Stats.UserPrompts == 1 { - promptUnit = "prompt" + promptUnit = l.T("sessions.prompt_one") } meta.WriteString(stat(s.Stats.UserPrompts, promptUnit, " srow-stat-prompts")) meta.WriteString(tokCell) @@ -693,32 +704,36 @@ func renderSessionRow(b *strings.Builder, e sessionEntry, groupBy string) { html.EscapeString(enc), html.EscapeString(s.ID), html.EscapeString(tip), providerBadgeHTML(s.Provider), - html.EscapeString(sessionRowLabel(s)), + html.EscapeString(l.sessionRowLabel(s)), s.EndTime.Format("2006-01-02 15:04"), - formatRelativeTime(s.EndTime), + l.relTime(s.EndTime), meta.String()) } // renderSessionsFooter states the truncation honestly: what is // shown, what matched, and the links to widen the window. func renderSessionsFooter(sq sessionsQuery, v sessionsView) string { - matching := " matching" - if !sq.Filtered() { - matching = "" + return defaultLoc().renderSessionsFooter(sq, v) +} + +func (l loc) renderSessionsFooter(sq sessionsQuery, v sessionsView) string { + matching := "" + if sq.Filtered() { + matching = l.T("sessions.matching") } var b strings.Builder b.WriteString(``) return b.String() diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 92caff2..601b1e7 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -287,6 +287,35 @@ code, pre, .session-id, .model-badge { } .icon-btn:hover { color: var(--text); border-color: var(--text-muted); } +.lang-switch-wrap { display: flex; align-items: center; } +.lang-switch { + appearance: none; + background: var(--bg); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: 0; + font-family: var(--font-mono); + font-size: 11px; + padding: 4px 8px; + cursor: pointer; +} +.lang-switch:hover, .lang-switch:focus { + color: var(--text); + border-color: var(--text-muted); + outline: none; +} +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .site-footer { padding: 16px 24px; background: var(--bg-secondary); diff --git a/internal/web/templates.go b/internal/web/templates.go index 19ffb45..1e431c7 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -106,40 +106,44 @@ func parseProviderQuery(q string) (provider, query string) { } func renderIndexPage(projects []*parser.Project, totalSessions int, search, sortBy string) string { + return defaultLoc().renderIndexPage(projects, totalSessions, search, sortBy) +} + +func (l loc) renderIndexPage(projects []*parser.Project, totalSessions int, search, sortBy string) string { var b strings.Builder - b.WriteString(pageHeader("ccx", ccxconfig.Theme())) - b.WriteString(renderTopNav("", "")) + b.WriteString(l.pageHeader("ccx", ccxconfig.Theme())) + b.WriteString(l.renderTopNav("", "")) b.WriteString(`
`) - b.WriteString(renderSidebar("projects")) + b.WriteString(l.renderSidebar("projects")) b.WriteString(`
`) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) - b.WriteString(fmt.Sprintf(``, html.EscapeString(search))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("projects.search")), html.EscapeString(search))) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) - b.WriteString(`Sort:`) + b.WriteString(`` + html.EscapeString(l.T("projects.sort")) + ``) b.WriteString(fmt.Sprintf(``, selected(sortBy, "time"), selected(sortBy, "name"), selected(sortBy, "sessions"))) + + + + `, selected(sortBy, "time"), html.EscapeString(l.T("projects.sort_recent")), selected(sortBy, "name"), html.EscapeString(l.T("projects.sort_name")), selected(sortBy, "sessions"), html.EscapeString(l.T("projects.sort_sessions")))) b.WriteString(`
`) b.WriteString(`
`) b.WriteString(`
`) for _, p := range projects { - sessionsLabel := "sessions" + sessionsLabel := l.T("projects.session_other") if len(p.Sessions) == 1 { - sessionsLabel = "session" + sessionsLabel = l.T("projects.session_one") } displayName := parser.GetProjectDisplayName(p.EncodedName) badges := providerBadgesHTML(projectProviders(p)) @@ -159,29 +163,33 @@ func renderIndexPage(projects []*parser.Project, totalSessions int, search, sort · %s
-`, html.EscapeString(p.EncodedName), provAttr, html.EscapeString(displayName), badges, len(p.Sessions), sessionsLabel, formatAge(p.LastModified))) +`, html.EscapeString(p.EncodedName), provAttr, html.EscapeString(displayName), badges, len(p.Sessions), sessionsLabel, l.age(p.LastModified))) } b.WriteString(`
`) b.WriteString(`
`) b.WriteString(`
`) b.WriteString(renderFooter()) - b.WriteString(indexJS()) - b.WriteString(pageFooter()) + b.WriteString(l.indexJS()) + b.WriteString(l.pageFooter()) return b.String() } func renderProjectPage(project *parser.Project, sessions []*parser.Session, allProjects []*parser.Project, memFiles []MemoryFile, search, sortBy string) string { + return defaultLoc().renderProjectPage(project, sessions, allProjects, memFiles, search, sortBy) +} + +func (l loc) renderProjectPage(project *parser.Project, sessions []*parser.Session, allProjects []*parser.Project, memFiles []MemoryFile, search, sortBy string) string { var b strings.Builder - b.WriteString(pageHeader(project.Name+" - ccx", ccxconfig.Theme())) - b.WriteString(renderTopNav(project.EncodedName, "")) + b.WriteString(l.pageHeader(project.Name+" - ccx", ccxconfig.Theme())) + b.WriteString(l.renderTopNav(project.EncodedName, "")) b.WriteString(`
`) // Left panel: Projects list b.WriteString(`
`) b.WriteString(renderFooter()) - b.WriteString(indexJS()) + b.WriteString(l.indexJS()) if len(memFiles) > 0 { b.WriteString(memSectionCSS()) - b.WriteString(fileCardJS()) + b.WriteString(l.fileCardJS()) } - b.WriteString(pageFooter()) + b.WriteString(l.pageFooter()) return b.String() } func renderSessionPage(session *parser.Session, projectName string, allSessions []*parser.Session, memCount int, showThinking, showTools, loadAll bool, theme string, traceTurns []trace.Turn, turnTarget string) string { + return defaultLoc().renderSessionPage(session, projectName, allSessions, memCount, showThinking, showTools, loadAll, theme, traceTurns, turnTarget) +} + +func (l loc) renderSessionPage(session *parser.Session, projectName string, allSessions []*parser.Session, memCount int, showThinking, showTools, loadAll bool, theme string, traceTurns []trace.Turn, turnTarget string) string { var b strings.Builder // Turn evidence keyed by the user anchor UUID; renderThread attaches @@ -302,8 +314,8 @@ func renderSessionPage(session *parser.Session, projectName string, allSessions if len(idPrefix) > 8 { idPrefix = idPrefix[:8] } - title := fmt.Sprintf("Session %s - ccx", idPrefix) - b.WriteString(pageHeader(title, theme)) + title := l.T("title.session", idPrefix) + b.WriteString(l.pageHeader(title, theme)) // Hint the current session's provider to the CSS layer so the // loading spinner can pick up a provider-specific accent (e.g. // green for Codex). Non-session pages never set this attribute. @@ -314,16 +326,16 @@ func renderSessionPage(session *parser.Session, projectName string, allSessions if turnTarget != "" { b.WriteString(fmt.Sprintf(``, turnTarget)) } - b.WriteString(renderTopNav(projectName, session.ID)) + b.WriteString(l.renderTopNav(projectName, session.ID)) // Context bar: where am I, and how do I move sideways. This // replaces the old hover-expanding session rail — one navigation // rail (the outline) remains; session switching is a breadcrumb // concern, not a second rail. b.WriteString(`
`) - b.WriteString(``) - b.WriteString(`
`) b.WriteString(``) b.WriteString(renderFooter()) - b.WriteString(sessionJS(projectName, session.ID)) - b.WriteString(pageFooter()) + b.WriteString(l.sessionJS(projectName, session.ID)) + b.WriteString(l.pageFooter()) return b.String() } @@ -619,13 +631,17 @@ func splitByUserPrompts(messages []*parser.Message, chunkSize int) [][]*parser.M } func renderMessages(b *strings.Builder, messages []*parser.Message, depth int, showThinking, showTools, loadAll bool, turnMap map[string]*trace.Turn) { + defaultLoc().renderMessages(b, messages, depth, showThinking, showTools, loadAll, turnMap) +} + +func (l loc) renderMessages(b *strings.Builder, messages []*parser.Message, depth int, showThinking, showTools, loadAll bool, turnMap map[string]*trace.Turn) { allMsgs := flattenMessages(messages) mainMsgs := filterMainConversation(allMsgs) sidechainGroups := groupSidechainsByAgent(allMsgs) scMap := matchSidechainsToToolUse(mainMsgs, sidechainGroups) if !loadAll && len(mainMsgs) > progressiveLoadThreshold { - renderMessagesProgressive(b, mainMsgs, showThinking, showTools, scMap, turnMap) + l.renderMessagesProgressive(b, mainMsgs, showThinking, showTools, scMap, turnMap) return } @@ -643,24 +659,24 @@ func renderMessages(b *strings.Builder, messages []*parser.Message, depth int, s if isAnchor { if inThread && len(currentThread) > 0 { - renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) + l.renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) } currentThread = []*parser.Message{msg} inThread = true } else if inThread { currentThread = append(currentThread, msg) } else { - renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) + l.renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) } } if inThread && len(currentThread) > 0 { - renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) + l.renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) } } // renderMessagesProgressive renders large conversations with lazy loading -func renderMessagesProgressive(b *strings.Builder, allMsgs []*parser.Message, showThinking, showTools bool, scMap map[string]sidechainGroup, turnMap map[string]*trace.Turn) { +func (l loc) renderMessagesProgressive(b *strings.Builder, allMsgs []*parser.Message, showThinking, showTools bool, scMap map[string]sidechainGroup, turnMap map[string]*trace.Turn) { sections := splitByCompactBoundaries(allMsgs) // If no compact boundaries, fall back to splitting by user prompts @@ -685,7 +701,7 @@ func renderMessagesProgressive(b *strings.Builder, allMsgs []*parser.Message, sh if startSection > 0 { b.WriteString(fmt.Sprintf(`
`, startSection)) b.WriteString(`
`) } @@ -711,19 +727,19 @@ func renderMessagesProgressive(b *strings.Builder, allMsgs []*parser.Message, sh if isAnchor { if inThread && len(currentThread) > 0 { - renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) + l.renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) } currentThread = []*parser.Message{msg} inThread = true } else if inThread { currentThread = append(currentThread, msg) } else { - renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) + l.renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) } } if inThread && len(currentThread) > 0 { - renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) + l.renderThread(b, currentThread, showThinking, showTools, toolResults, scMap, turnMap) } } @@ -818,8 +834,8 @@ func matchSidechainsToToolUse(mainMsgs []*parser.Message, groups []sidechainGrou return out } -func renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, showTools bool) { - label := "Agent" +func (l loc) renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, showTools bool) { + label := l.T("sidechain.agent") if g.AgentType != "" { label = g.AgentType } @@ -830,13 +846,13 @@ func renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, s var meta strings.Builder if r := g.Result; r != nil { if r.TotalTokens > 0 { - fmt.Fprintf(&meta, "%s tokens", formatTokens(r.TotalTokens)) + fmt.Fprintf(&meta, "%s", l.T("sidechain.tokens", formatTokens(r.TotalTokens))) } if r.TotalToolUseCount > 0 { if meta.Len() > 0 { meta.WriteString(", ") } - fmt.Fprintf(&meta, "%d tools", r.TotalToolUseCount) + fmt.Fprintf(&meta, "%s", l.T("sidechain.tools", r.TotalToolUseCount)) } if r.TotalDurationMs > 0 { if meta.Len() > 0 { @@ -848,11 +864,11 @@ func renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, s if meta.Len() > 0 { meta.WriteString(", ") } - fmt.Fprintf(&meta, "+%d lines", r.ToolStats.LinesAdded) + fmt.Fprintf(&meta, "%s", l.T("sidechain.lines", r.ToolStats.LinesAdded)) } } if meta.Len() == 0 { - fmt.Fprintf(&meta, "%d messages", len(g.Messages)) + fmt.Fprintf(&meta, "%s", l.T("sidechain.messages", len(g.Messages))) } b.WriteString(fmt.Sprintf(`
`, html.EscapeString(g.AgentID))) @@ -865,14 +881,14 @@ func renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, s if msg.Kind == parser.KindToolResult { continue } - renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) + l.renderTurnMessage(b, msg, showThinking, showTools, 0, toolResults) } b.WriteString(`
`) } // renderThread renders a conversation thread anchored by a USER message -func renderThread(b *strings.Builder, thread []*parser.Message, showThinking, showTools bool, toolResults map[string]parser.ContentBlock, scMap map[string]sidechainGroup, turnMap map[string]*trace.Turn) { +func (l loc) renderThread(b *strings.Builder, thread []*parser.Message, showThinking, showTools bool, toolResults map[string]parser.ContentBlock, scMap map[string]sidechainGroup, turnMap map[string]*trace.Turn) { if len(thread) == 0 { return } @@ -883,11 +899,11 @@ func renderThread(b *strings.Builder, thread []*parser.Message, showThinking, sh b.WriteString(`
`) b.WriteString(`
`) - renderTurnMessage(b, anchor, showThinking, showTools, 0, toolResults) + l.renderTurnMessage(b, anchor, showThinking, showTools, 0, toolResults) b.WriteString(`
`) if turn := turnMap[anchor.UUID]; turn != nil { - renderTurnEvidence(b, turn, thread, scMap) + l.renderTurnEvidence(b, turn, thread, scMap) } if len(responses) > 0 { @@ -897,11 +913,11 @@ func renderThread(b *strings.Builder, thread []*parser.Message, showThinking, sh if msg.IsSidechain { level = 2 } - renderTurnMessage(b, msg, showThinking, showTools, level, toolResults) + l.renderTurnMessage(b, msg, showThinking, showTools, level, toolResults) for _, block := range msg.Content { if block.Type == "tool_use" && subagentToolSet[block.ToolName] { if sc, ok := scMap[block.ToolID]; ok { - renderInlineSidechain(b, sc, showThinking, showTools) + l.renderInlineSidechain(b, sc, showThinking, showTools) } } } @@ -920,6 +936,10 @@ func renderThread(b *strings.Builder, thread []*parser.Message, showThinking, sh // is the same turn `ccx trace --turn 54` prints and the same citation // an audit report writes. func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.Message, scMap map[string]sidechainGroup) { + defaultLoc().renderTurnEvidence(b, turn, thread, scMap) +} + +func (l loc) renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.Message, scMap map[string]sidechainGroup) { // Mutations grouped per edited file, in first-touch order. type fileEdit struct { path string @@ -974,21 +994,21 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M b.WriteString(fmt.Sprintf(`
`, turn.Index)) b.WriteString(``) - b.WriteString(fmt.Sprintf(`#%d`, turn.Index, turn.Index, turn.Index)) + b.WriteString(fmt.Sprintf(`#%d`, turn.Index, html.EscapeString(l.T("turn.permalink", turn.Index)), turn.Index)) if n := len(turn.Steps); n > 0 { - b.WriteString(fmt.Sprintf(`%d steps`, n)) + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(l.T("turn.steps", n)))) } if toolTotal > 0 { - b.WriteString(fmt.Sprintf(`%d tools`, toolTotal)) + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(l.T("turn.tools", toolTotal)))) } if n := len(turn.FilesEdited); n > 0 { - b.WriteString(fmt.Sprintf(`%d files`, n)) + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(l.T("turn.files", n)))) } if len(agents) > 0 { - b.WriteString(fmt.Sprintf(`%d agents`, len(agents))) + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(l.T("turn.agents", len(agents))))) } if turn.Errors > 0 { - b.WriteString(fmt.Sprintf(`%d errors`, turn.Errors)) + b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(l.T("turn.errors", turn.Errors)))) } if turn.CostUSD > 0 { b.WriteString(fmt.Sprintf(`%s`, formatCost(turn.CostUSD))) @@ -1002,7 +1022,7 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M b.WriteString(`
`) if len(fileEdits) > 0 { - b.WriteString(`
Edited
`) + b.WriteString(`
` + html.EscapeString(l.T("turn.edited")) + `
`) for _, fe := range fileEdits { b.WriteString(`
`) b.WriteString(fmt.Sprintf(`%s`, @@ -1026,12 +1046,12 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M } if len(agents) > 0 { - b.WriteString(`
Agents
`) + b.WriteString(`
` + html.EscapeString(l.T("turn.agents_section")) + `
`) for _, a := range agents { g := a.group name := g.AgentType if name == "" { - name = "agent" + name = l.T("turn.agent_fallback") } b.WriteString(`
`) b.WriteString(fmt.Sprintf(`%s`, @@ -1048,7 +1068,7 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M } if toolTotal > 0 { - b.WriteString(`
Tools
`) + b.WriteString(`
` + html.EscapeString(l.T("turn.tools_section")) + `
`) names := make([]string, 0, len(turn.ToolCounts)) for name := range turn.ToolCounts { names = append(names, name) @@ -1067,7 +1087,7 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M } if len(failed) > 0 { - b.WriteString(`
Failed calls
`) + b.WriteString(`
` + html.EscapeString(l.T("turn.failed")) + `
`) for _, m := range failed { label := m.Name if len(m.Paths) > 0 { @@ -1084,7 +1104,7 @@ func renderTurnEvidence(b *strings.Builder, turn *trace.Turn, thread []*parser.M b.WriteString(`
`) } -func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, showTools bool, level int, toolResults map[string]parser.ContentBlock) { +func (l loc) renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, showTools bool, level int, toolResults map[string]parser.ContentBlock) { // Level class for indentation levelClass := "" if level > 0 { @@ -1100,7 +1120,7 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh case parser.KindCompactSummary: // Compacted context: collapsible summary b.WriteString(fmt.Sprintf(`
`, levelClass)) - b.WriteString(`◇ Context Compacted`) + b.WriteString(`◇ ` + html.EscapeString(l.T("turn.compacted")) + ``) b.WriteString(`
`) for _, block := range msg.Content { if block.Type == "text" { @@ -1115,10 +1135,10 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh case parser.KindMeta: // Meta/system instructions: collapsible b.WriteString(fmt.Sprintf(`
`, levelClass)) - b.WriteString(`▽ System Instructions`) + b.WriteString(`▽ ` + html.EscapeString(l.T("turn.system")) + ``) b.WriteString(`
`) for _, block := range msg.Content { - renderBlock(b, block, showThinking, showTools, toolResults) + l.renderBlock(b, block, showThinking, showTools, toolResults) } b.WriteString(`
`) return @@ -1150,13 +1170,13 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh turnClass := "turn" + levelClass icon := "●" - role := "ASSISTANT" + role := l.T("turn.assistant") switch msg.Kind { case parser.KindUserPrompt: turnClass += " turn-user" icon = "▶" - role = "USER" + role = l.T("turn.user") case parser.KindAssistant: turnClass += " turn-assistant" default: @@ -1166,7 +1186,7 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh if isAgent { turnClass += " turn-agent" icon = "◆" - role = "AGENT" + role = l.T("turn.agent") } // Store raw content for copy/raw toggle @@ -1181,11 +1201,11 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh b.WriteString(fmt.Sprintf(`%s`, role)) b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(preview))) b.WriteString(fmt.Sprintf(`%s`, msg.Timestamp.Format("15:04:05"))) - b.WriteString(``) + b.WriteString(``) b.WriteString(``) b.WriteString(fmt.Sprintf(`
`, html.EscapeString(rawContent))) for _, block := range msg.Content { - renderBlock(b, block, showThinking, showTools, toolResults) + l.renderBlock(b, block, showThinking, showTools, toolResults) } b.WriteString(`
`) b.WriteString(`
`) @@ -1201,19 +1221,19 @@ func renderTurnMessage(b *strings.Builder, msg *parser.Message, showThinking, sh if msg.Model != "" { b.WriteString(fmt.Sprintf(`%s`, html.EscapeString(msg.Model))) } - b.WriteString(``) + b.WriteString(``) b.WriteString(`
`) b.WriteString(fmt.Sprintf(`
`, html.EscapeString(rawContent))) for _, block := range msg.Content { - renderBlock(b, block, showThinking, showTools, toolResults) + l.renderBlock(b, block, showThinking, showTools, toolResults) } b.WriteString(`
`) b.WriteString(``) } -func renderBlock(b *strings.Builder, block parser.ContentBlock, showThinking, showTools bool, toolResults map[string]parser.ContentBlock) { +func (l loc) renderBlock(b *strings.Builder, block parser.ContentBlock, showThinking, showTools bool, toolResults map[string]parser.ContentBlock) { switch block.Type { case "text": if block.Text != "" { @@ -1228,7 +1248,7 @@ func renderBlock(b *strings.Builder, block parser.ContentBlock, showThinking, sh openAttr = " open" } b.WriteString(fmt.Sprintf(`
`, openAttr)) - b.WriteString(`∴ Thinking...`) + b.WriteString(`∴ ` + html.EscapeString(l.T("turn.thinking")) + ``) b.WriteString(`
`) b.WriteString(html.EscapeString(block.Text)) b.WriteString(`
`) @@ -1242,8 +1262,8 @@ func renderBlock(b *strings.Builder, block parser.ContentBlock, showThinking, sh // Compact preview for common tools preview := compactToolPreview(block.ToolName, block.ToolInput) b.WriteString(fmt.Sprintf(`
`, sanitizeID(block.ToolID), html.EscapeString(block.ToolID), openAttr)) - b.WriteString(fmt.Sprintf(`● %s%s`, - html.EscapeString(block.ToolName), html.EscapeString(preview))) + b.WriteString(fmt.Sprintf(`● %s%s`, + html.EscapeString(block.ToolName), html.EscapeString(preview), html.EscapeString(l.T("common.raw")), html.EscapeString(l.T("common.copy")))) // Tool input section if block.ToolInput != nil { @@ -1964,31 +1984,35 @@ func getRawContentJSON(msg *parser.Message) string { } func formatRelativeTime(t time.Time) string { + return defaultLoc().relTime(t) +} + +func (l loc) relTime(t time.Time) string { if t.IsZero() { return "" } d := time.Since(t) switch { case d < time.Minute: - return "just now" + return l.T("time.just_now") case d < time.Hour: m := int(d.Minutes()) if m == 1 { - return "1 min ago" + return l.T("time.min_one") } - return fmt.Sprintf("%d mins ago", m) + return l.T("time.min_other", m) case d < 24*time.Hour: h := int(d.Hours()) if h == 1 { - return "1 hour ago" + return l.T("time.hour_one") } - return fmt.Sprintf("%d hours ago", h) + return l.T("time.hour_other", h) case d < 7*24*time.Hour: days := int(d.Hours() / 24) if days == 1 { - return "yesterday" + return l.T("time.yesterday") } - return fmt.Sprintf("%d days ago", days) + return l.T("time.days_other", days) default: return t.Format("Jan 2") } @@ -2081,7 +2105,7 @@ func hasBillableUsage(turns []*parser.Exchange) bool { // Turns are sorted by cost desc (most expensive first) so quota hogs // surface immediately. Rows link to #msg- which composes with // the load-earlier hash-nav fix shipped in #3. -func renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { +func (l loc) renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { var b strings.Builder // Sort by cost desc (stable — ties break by original turn index). @@ -2097,7 +2121,7 @@ func renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { }) b.WriteString(`
`) - b.WriteString(`
Per-turn spend
`) + b.WriteString(`
` + html.EscapeString(l.T("spend.header")) + `
`) priced := 0 for _, t := range sorted { @@ -2108,7 +2132,7 @@ func renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { if priced == 0 { // Tokens present but no pricing matched any model (unknown model). // Still useful to show token-only breakdown; label the limitation. - b.WriteString(`
No pricing match for model — showing token totals.
`) + b.WriteString(`
` + html.EscapeString(l.T("spend.no_pricing")) + `
`) } b.WriteString(`
`) @@ -2126,15 +2150,13 @@ func renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { // A turn with tokens and no priced cost is unpriced, not $0.00. costLabel := formatCost(t.CostUSD) if t.CostUSD == 0 { - costLabel = "n/a" + costLabel = l.T("spend.na") } b.WriteString(fmt.Sprintf( - `%s%s`, + `%s%s`, html.EscapeString(sanitizeID(t.AnchorID)), - t.Index, - tokensLabel, - costLabel, + html.EscapeString(l.T("spend.jump", t.Index, tokensLabel, costLabel)), html.EscapeString(label), costLabel, )) @@ -2143,10 +2165,7 @@ func renderSpendSection(turns []*parser.Exchange, sessionTotal float64) string { // Footer: total cost summary + known-models link if sessionTotal > 0 { - b.WriteString(fmt.Sprintf( - ``, - formatCost(sessionTotal), - )) + b.WriteString(``) } b.WriteString(`
`) @@ -2186,6 +2205,10 @@ func buildStepMap(turnMap map[string]*trace.Turn) map[string]*trace.Step { } func renderConversationNav(b *strings.Builder, messages []*parser.Message, stepMap map[string]*trace.Step) { + defaultLoc().renderConversationNav(b, messages, stepMap) +} + +func (l loc) renderConversationNav(b *strings.Builder, messages []*parser.Message, stepMap map[string]*trace.Step) { flat := flattenMessages(messages) allMsgs := filterMainConversation(flat) scGroups := groupSidechainsByAgent(flat) @@ -2265,8 +2288,8 @@ func renderConversationNav(b *strings.Builder, messages []*parser.Message, stepM } b.WriteString(fmt.Sprintf(``) // .nav-group @@ -2305,7 +2328,7 @@ func renderConversationNav(b *strings.Builder, messages []*parser.Message, stepM } -func renderNavSidechainEntries(b *strings.Builder, msg *parser.Message, scMap map[string]sidechainGroup) { +func (l loc) renderNavSidechainEntries(b *strings.Builder, msg *parser.Message, scMap map[string]sidechainGroup) { if msg == nil || len(scMap) == 0 { return } @@ -2354,7 +2377,7 @@ func getNavPreview(msg *parser.Message) string { // stated intent — because "response / Bash / response" is a list of message // kinds and tells an auditor nothing. The tool name stays as the title // attribute so the detail is still one hover away. -func renderNavChild(b *strings.Builder, msg *parser.Message, stepMap map[string]*trace.Step) { +func (l loc) renderNavChild(b *strings.Builder, msg *parser.Message, stepMap map[string]*trace.Step) { switch msg.Kind { case parser.KindAssistant: hasTool := false @@ -2369,7 +2392,7 @@ func renderNavChild(b *strings.Builder, msg *parser.Message, stepMap map[string] } } if step := stepMap[msg.UUID]; step != nil && strings.TrimSpace(step.Narration) != "" { - renderNavStep(b, msg, step, hasTool, toolName, toolPreview) + l.renderNavStep(b, msg, step, hasTool, toolName, toolPreview) return } if hasTool { @@ -2397,7 +2420,7 @@ func renderNavChild(b *strings.Builder, msg *parser.Message, stepMap map[string] // The headline is the evidence; the counters beside it are what that step // actually did, so a reader can spot the expensive and the failing steps // without opening any of them. -func renderNavStep(b *strings.Builder, msg *parser.Message, step *trace.Step, hasTool bool, toolName, toolPreview string) { +func (l loc) renderNavStep(b *strings.Builder, msg *parser.Message, step *trace.Step, hasTool bool, toolName, toolPreview string) { id := sanitizeID(msg.UUID) title := toolName if toolPreview != "" { @@ -2445,22 +2468,26 @@ func stepToolCount(step *trace.Step) int { } func renderSearchPage(projectsDir, query string) string { + return defaultLoc().renderSearchPage(projectsDir, query) +} + +func (l loc) renderSearchPage(projectsDir, query string) string { var b strings.Builder - b.WriteString(pageHeader("Search - ccx", ccxconfig.Theme())) - b.WriteString(renderTopNav("", "")) + b.WriteString(l.pageHeader(l.T("title.search"), ccxconfig.Theme())) + b.WriteString(l.renderTopNav("", "")) b.WriteString(`
`) - b.WriteString(renderSidebar("search")) + b.WriteString(l.renderSidebar("search")) b.WriteString(`
`) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) - b.WriteString(fmt.Sprintf(``, html.EscapeString(query))) + b.WriteString(fmt.Sprintf(``, html.EscapeString(l.T("search.placeholder")), html.EscapeString(query))) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) @@ -2470,13 +2497,13 @@ func renderSearchPage(projectsDir, query string) string { b.WriteString(`
`) b.WriteString(`
`) b.WriteString(renderFooter()) - b.WriteString(searchJS(query)) - b.WriteString(pageFooter()) + b.WriteString(l.searchJS(query)) + b.WriteString(l.pageFooter()) return b.String() } -func searchJS(initialQuery string) string { +func (l loc) searchJS(initialQuery string) string { return fmt.Sprintf(` ` @@ -3069,7 +3104,7 @@ func memSectionCSS() string { ` } -func fileCardJS() string { +func (l loc) fileCardJS() string { return `` } func renderMemoryPage(data *MemoryData) string { + return defaultLoc().renderMemoryPage(data) +} + +func (l loc) renderMemoryPage(data *MemoryData) string { var b strings.Builder - b.WriteString(pageHeader("Memory - ccx", ccxconfig.Theme())) - b.WriteString(renderTopNav("", "")) + b.WriteString(l.pageHeader(l.T("title.memory"), ccxconfig.Theme())) + b.WriteString(l.renderTopNav("", "")) b.WriteString(`
`) - b.WriteString(renderSidebar("memory")) + b.WriteString(l.renderSidebar("memory")) b.WriteString(`
`) - b.WriteString(fmt.Sprintf(`

Memory (%d files)

`, data.TotalFiles)) + b.WriteString(fmt.Sprintf(`

%s %s

`, html.EscapeString(l.T("memory.heading")), html.EscapeString(l.T("memory.files_count", data.TotalFiles)))) idx := 0 // Section 1: Global Instructions if len(data.Global) > 0 { b.WriteString(`
`) - b.WriteString(fmt.Sprintf(`

◇ Global Instructions (%d)

`, len(data.Global))) + b.WriteString(fmt.Sprintf(`

◇ %s %s

`, html.EscapeString(l.T("memory.global")), html.EscapeString(l.T("settings.count", len(data.Global))))) b.WriteString(`
`) for _, f := range data.Global { - renderMemoryFileCard(&b, f, idx) + l.renderMemoryFileCard(&b, f, idx) idx++ } b.WriteString(`
`) @@ -3182,10 +3221,10 @@ func renderMemoryPage(data *MemoryData) string { // Section 2: User Rules if len(data.Rules) > 0 { b.WriteString(`
`) - b.WriteString(fmt.Sprintf(`

◆ User Rules (%d)

`, len(data.Rules))) + b.WriteString(fmt.Sprintf(`

◆ %s %s

`, html.EscapeString(l.T("memory.rules")), html.EscapeString(l.T("settings.count", len(data.Rules))))) b.WriteString(`
`) for _, f := range data.Rules { - renderMemoryFileCard(&b, f, idx) + l.renderMemoryFileCard(&b, f, idx) idx++ } b.WriteString(`
`) @@ -3198,7 +3237,7 @@ func renderMemoryPage(data *MemoryData) string { totalProjectFiles += len(p.Files) } b.WriteString(`
`) - b.WriteString(fmt.Sprintf(`

◈ Project Memory (%d projects, %d files)

`, len(data.Projects), totalProjectFiles)) + b.WriteString(fmt.Sprintf(`

◈ %s %s

`, html.EscapeString(l.T("memory.project")), html.EscapeString(l.T("memory.project_count", len(data.Projects), totalProjectFiles)))) for _, proj := range data.Projects { badge := `CC` @@ -3210,7 +3249,7 @@ func renderMemoryPage(data *MemoryData) string { html.EscapeString(proj.Name), badge, html.EscapeString(proj.Path))) b.WriteString(`
`) for _, f := range proj.Files { - renderMemoryFileCard(&b, f, idx) + l.renderMemoryFileCard(&b, f, idx) idx++ } b.WriteString(`
`) @@ -3221,30 +3260,30 @@ func renderMemoryPage(data *MemoryData) string { // Section 4: Codex Memories if len(data.CodexMem) > 0 { b.WriteString(`
`) - b.WriteString(fmt.Sprintf(`

◌ Codex Memories (%d)

`, len(data.CodexMem))) + b.WriteString(fmt.Sprintf(`

◌ %s %s

`, html.EscapeString(l.T("memory.codex")), html.EscapeString(l.T("settings.count", len(data.CodexMem))))) b.WriteString(`
`) for _, f := range data.CodexMem { - renderMemoryFileCard(&b, f, idx) + l.renderMemoryFileCard(&b, f, idx) idx++ } b.WriteString(`
`) } if data.TotalFiles == 0 { - b.WriteString(`
No memory or instruction files found.
`) + b.WriteString(`
` + html.EscapeString(l.T("memory.empty")) + `
`) } b.WriteString(``) b.WriteString(`
`) b.WriteString(renderFooter()) - b.WriteString(indexJS()) + b.WriteString(l.indexJS()) b.WriteString(memoryPageCSS()) - b.WriteString(pageFooter()) + b.WriteString(l.pageFooter()) return b.String() } -func renderMemoryFileCard(b *strings.Builder, f MemoryFile, idx int) { +func (l loc) renderMemoryFileCard(b *strings.Builder, f MemoryFile, idx int) { provClass := "mem-card-cc" if f.Provider == "codex" { provClass = "mem-card-cx" @@ -3253,8 +3292,8 @@ func renderMemoryFileCard(b *strings.Builder, f MemoryFile, idx int) { b.WriteString(fmt.Sprintf(`%s%s▶`, html.EscapeString(f.Name), html.EscapeString(f.FilePath))) b.WriteString(fmt.Sprintf(`
`, idx)) - b.WriteString(`
`) - b.WriteString(`
Loading...
`) + b.WriteString(fmt.Sprintf(`
`, html.EscapeString(l.T("common.fmt")), html.EscapeString(l.T("common.raw")), html.EscapeString(l.T("common.copy")))) + b.WriteString(`
` + html.EscapeString(l.T("common.loading")) + `
`) b.WriteString(`
`) } @@ -3279,25 +3318,26 @@ func memoryPageCSS() string { ` } -func renderTopNav(projectName, sessionID string) string { +func (l loc) renderTopNav(projectName, sessionID string) string { var b strings.Builder b.WriteString(`
`) b.WriteString(`
`) b.WriteString(``) b.WriteString(``) b.WriteString(``) b.WriteString(`
`) b.WriteString(`
`) @@ -3314,7 +3354,7 @@ func renderFooter() string { ` } -func renderSidebar(active string) string { +func (l loc) renderSidebar(active string) string { var b strings.Builder b.WriteString(`