Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/cli/tui/i18n/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ func Load() Catalog {
return Default()
}

// LoadFrom reads the Catalog from a specific YAML file path.
// Avoids CWD manipulation in tests — pass the YAML file path directly.
// Returns Default() (en) when the file is absent, unreadable, or specifies an unknown language.
func LoadFrom(yamlPath string) Catalog {
lang := readLangFromFile(yamlPath)
if lang == "" {
return Default()
}
if cat, ok := Catalogs[lang]; ok {
return cat
}
return Default()
}

// loadLang resolves the conversation_language string from the YAML config.
// Returns empty string on any error or missing key.
func loadLang() string {
Expand Down
94 changes: 28 additions & 66 deletions internal/cli/tui/i18n/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,99 +7,61 @@ import (
"testing"
)

// TestI18N_Loader_LoadsKoFromYaml verifies that Load() reads ko catalog when
// language.yaml specifies conversation_language: ko. REQ-CLITUI3-001
func TestI18N_Loader_LoadsKoFromYaml(t *testing.T) {
t.Parallel()

// Create a temp dir with a language.yaml file.
dir := t.TempDir()
// writeYAML writes a minimal language.yaml to dir/.moai/config/sections/ and returns the path.
func writeYAML(t *testing.T, dir, lang string) string {
t.Helper()
configDir := filepath.Join(dir, ".moai", "config", "sections")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
t.Fatalf("MkdirAll: %v", err)
}
yamlContent := "language:\n conversation_language: ko\n"
yamlPath := filepath.Join(configDir, "language.yaml")
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("failed to write language.yaml: %v", err)
content := "language:\n conversation_language: " + lang + "\n"
if err := os.WriteFile(yamlPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return yamlPath
}

// Set CWD to the temp dir so Load() finds the yaml.
origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
if err := os.Chdir(dir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
// TestI18N_Loader_LoadsKoFromYaml verifies that LoadFrom() reads ko catalog when
// language.yaml specifies conversation_language: ko. REQ-CLITUI3-001
func TestI18N_Loader_LoadsKoFromYaml(t *testing.T) {
t.Parallel()

dir := t.TempDir()
yamlPath := writeYAML(t, dir, "ko")

cat := Load()
cat := LoadFrom(yamlPath)
if cat.Lang != "ko" {
t.Errorf("Load().Lang = %q, want %q", cat.Lang, "ko")
t.Errorf("LoadFrom().Lang = %q, want %q", cat.Lang, "ko")
}
}

// TestI18N_Loader_DefaultsToEnglish verifies that Load() returns en catalog
// when no language.yaml is found. REQ-CLITUI3-001
// TestI18N_Loader_DefaultsToEnglish verifies that LoadFrom() returns en catalog
// when the YAML file does not exist. REQ-CLITUI3-001
func TestI18N_Loader_DefaultsToEnglish(t *testing.T) {
t.Parallel()

// Use a temp dir with no yaml file.
dir := t.TempDir()

origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
if err := os.Chdir(dir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
absent := filepath.Join(t.TempDir(), "nonexistent.yaml")

cat := Load()
cat := LoadFrom(absent)
if cat.Lang != "en" {
t.Errorf("Load().Lang = %q, want %q (default fallback)", cat.Lang, "en")
t.Errorf("LoadFrom(absent).Lang = %q, want %q (default)", cat.Lang, "en")
}
if cat != Catalogs["en"] {
t.Error("Load() must return en catalog when yaml is absent")
t.Error("LoadFrom(absent) must return en catalog")
}
}

// TestI18N_Loader_UnknownLangFallback verifies that Load() returns en catalog
// TestI18N_Loader_UnknownLangFallback verifies that LoadFrom() returns en catalog
// when the yaml specifies an unknown language code. REQ-CLITUI3-001
func TestI18N_Loader_UnknownLangFallback(t *testing.T) {
t.Parallel()

dir := t.TempDir()
configDir := filepath.Join(dir, ".moai", "config", "sections")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
// "fr" is not in Catalogs.
yamlContent := "language:\n conversation_language: fr\n"
yamlPath := filepath.Join(configDir, "language.yaml")
if err := os.WriteFile(yamlPath, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("failed to write language.yaml: %v", err)
}

origDir, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
if err := os.Chdir(dir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
yamlPath := writeYAML(t, dir, "fr") // "fr" is not in Catalogs

cat := Load()
cat := LoadFrom(yamlPath)
if cat.Lang != "en" {
t.Errorf("Load().Lang = %q, want %q (fallback for unknown lang)", cat.Lang, "en")
t.Errorf("LoadFrom(fr).Lang = %q, want %q (fallback)", cat.Lang, "en")
}
}
131 changes: 131 additions & 0 deletions internal/cli/tui/snapshot_i18n_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Package tui provides i18n snapshot tests for 4 TUI surfaces in en and ko.
// AC-CLITUI3-009 (ko), AC-CLITUI3-010 (en)
package tui

import (
"testing"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/modu-ai/goose/internal/cli/tui/i18n"
"github.com/modu-ai/goose/internal/cli/tui/permission"
"github.com/modu-ai/goose/internal/cli/tui/sessionmenu"
"github.com/modu-ai/goose/internal/cli/tui/snapshots"
)

// newI18NModel creates a model pre-configured for i18n snapshot tests.
// noColor=true ensures ASCII-only output for deterministic snapshots.
func newI18NModel(lang string) *Model {
m := NewModel(nil, "test", true /* noColor */)
m.width = 80
m.height = 24
m.viewport.Width = 80
m.viewport.Height = 21
m.clock = snapshots.FixedClock(time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC))
m.catalog = i18n.Catalogs[lang]
return m
}

// --- Surface 1: Statusbar idle ---

// TestSnapshot_I18N_StatusbarIdle_Ko verifies the statusbar idle string in Korean.
// AC-CLITUI3-009
func TestSnapshot_I18N_StatusbarIdle_Ko(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("ko")
snapshots.RequireSnapshot(t, "statusbar_idle_ko", []byte(m.View()))
}

// TestSnapshot_I18N_StatusbarIdle_En verifies the statusbar idle string in English.
// AC-CLITUI3-010
func TestSnapshot_I18N_StatusbarIdle_En(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("en")
snapshots.RequireSnapshot(t, "statusbar_idle_en", []byte(m.View()))
}

// --- Surface 2: /help response ---

// TestSnapshot_I18N_SlashHelp_Ko verifies the /help output header in Korean.
// AC-CLITUI3-009
func TestSnapshot_I18N_SlashHelp_Ko(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("ko")

// Execute /help via Update so slash handling uses the Korean catalog.
m.input.SetValue("/help")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*Model)

snapshots.RequireSnapshot(t, "slash_help_ko", []byte(m.View()))
}

// TestSnapshot_I18N_SlashHelp_En verifies the /help output header in English.
// AC-CLITUI3-010
func TestSnapshot_I18N_SlashHelp_En(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("en")

// Execute /help via Update so slash handling uses the English catalog.
m.input.SetValue("/help")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*Model)

snapshots.RequireSnapshot(t, "slash_help_en", []byte(m.View()))
}

// --- Surface 3: Permission modal ---

// TestSnapshot_I18N_PermissionModal_Ko verifies the permission modal in Korean.
// AC-CLITUI3-009
func TestSnapshot_I18N_PermissionModal_Ko(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("ko")
m.permissionState = permission.PermissionModel{
Active: true,
ToolName: "Bash",
ToolUseID: "t1",
}
snapshots.RequireSnapshot(t, "permission_modal_ko", []byte(m.View()))
}

// TestSnapshot_I18N_PermissionModal_En verifies the permission modal in English.
// AC-CLITUI3-010
func TestSnapshot_I18N_PermissionModal_En(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("en")
m.permissionState = permission.PermissionModel{
Active: true,
ToolName: "Bash",
ToolUseID: "t1",
}
snapshots.RequireSnapshot(t, "permission_modal_en", []byte(m.View()))
}

// --- Surface 4: Session menu open ---

// TestSnapshot_I18N_SessionMenuOpen_Ko verifies the sessionmenu overlay header in Korean.
// AC-CLITUI3-009
func TestSnapshot_I18N_SessionMenuOpen_Ko(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("ko")
m.sessionMenuState = sessionmenu.Open([]sessionmenu.Entry{
{Name: "session-c"},
{Name: "session-b"},
{Name: "session-a"},
})
snapshots.RequireSnapshot(t, "session_menu_open_ko", []byte(m.View()))
}

// TestSnapshot_I18N_SessionMenuOpen_En verifies the sessionmenu overlay header in English.
// AC-CLITUI3-010
func TestSnapshot_I18N_SessionMenuOpen_En(t *testing.T) {
snapshots.SetupAsciiTermenv()
m := newI18NModel("en")
m.sessionMenuState = sessionmenu.Open([]sessionmenu.Entry{
{Name: "session-c"},
{Name: "session-b"},
{Name: "session-a"},
})
snapshots.RequireSnapshot(t, "session_menu_open_en", []byte(m.View()))
}
37 changes: 37 additions & 0 deletions internal/cli/tui/snapshot_sessionmenu_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Package tui provides snapshot tests for the sessionmenu overlay.
// AC-CLITUI3-004
package tui

import (
"testing"
"time"

"github.com/modu-ai/goose/internal/cli/tui/sessionmenu"
"github.com/modu-ai/goose/internal/cli/tui/snapshots"
)

// TestSnapshot_SessionMenuOpen verifies that the session menu overlay renders
// correctly with 3 mock entries in mtime desc order.
// AC-CLITUI3-004
func TestSnapshot_SessionMenuOpen(t *testing.T) {
// Force ASCII profile for deterministic snapshot output.
snapshots.SetupAsciiTermenv()

m := NewModel(nil, "test-session", true /* noColor */)
m.clock = snapshots.FixedClock(time.Date(2026, 5, 5, 12, 0, 0, 0, time.UTC))
m.width = 80
m.height = 24
m.viewport.Width = 80
m.viewport.Height = 21

// Open sessionmenu with 3 mock entries already in mtime desc order.
entries := []sessionmenu.Entry{
{Name: "session-c"},
{Name: "session-b"},
{Name: "session-a"},
}
m.sessionMenuState = sessionmenu.Open(entries)

output := []byte(m.View())
snapshots.RequireSnapshot(t, "session_menu_open", output)
}
36 changes: 36 additions & 0 deletions internal/cli/tui/testdata/snapshots/permission_modal_en.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Session: test | Daemon: | Messages: 0





















> > Type a message...
+----------------------------------+
| Permission Request: Bash |
+----------------------------------+
| |
| Allow this tool call? |
| > Allow once |
| Allow always (this tool) |
| Deny once |
| Deny always (this tool) |
| |
| [Up/Down] navigate [Enter] select|
| [Esc] deny once |
+----------------------------------+
36 changes: 36 additions & 0 deletions internal/cli/tui/testdata/snapshots/permission_modal_ko.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
세션: test | 데몬: | 메시지: 0





















> > Type a message...
+----------------------------------+
| Permission Request: Bash |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

KO 권한 모달에 미번역 영어 문자열 존재 (AC-009 i18n 커버리지 불완전)

Line 25의 Permission Request: Bash 헤더와 Lines 34~35의 [Up/Down] navigate [Enter] select / [Esc] deny once 내비게이션 힌트가 KO 골든에서 영어 그대로 노출됩니다. SPEC-GOOSE-CLI-TUI-003 AC-009가 이 표면의 완전한 한국어 번역을 요구한다면, i18n 카탈로그에 해당 키가 누락된 것입니다.

의도적으로 일부만 번역한 것이라면 SPEC/AC를 업데이트해서 범위를 명시해 주세요.

#!/bin/bash
# i18n 카탈로그에서 "Permission Request" 및 nav hint 키 존재 여부 확인
rg -rn "Permission Request\|navigate\|deny once" --type go
rg -rn "Permission Request\|navigate\|deny once" --glob "*.yaml" --glob "*.json" --glob "*.toml"

Also applies to: 34-35

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/tui/testdata/snapshots/permission_modal_ko.golden` at line 25,
The KO golden shows untranslated strings "Permission Request: Bash" and
navigation hints "[Up/Down] navigate [Enter] select" / "[Esc] deny once" — add
corresponding Korean entries to the i18n catalog used by the TUI permission
modal (search for the exact strings to find the keys), e.g. add translations for
the permission modal header key (the key that formats "Permission Request: %s")
and the nav-hint keys in your YAML/JSON/TOML locales, ensure the localization
loader picks up the new entries, run the provided ripgrep commands to verify the
keys exist, and update the KO golden/test snapshot if needed (or update SPEC/AC
if the untranslated text was intentional).

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# permission_modal_en/ko 골든의 Line 25 실제 바이트 길이 확인
echo "=== permission_modal_en.golden line 25 ==="
fd "permission_modal_en.golden" --type f --exec sed -n '25p' {} \; | cat -A | head -1
echo "=== permission_modal_ko.golden line 25 ==="
fd "permission_modal_ko.golden" --type f --exec sed -n '25p' {} \; | cat -A | head -1
echo "=== border line (line 24) for comparison ==="
fd "permission_modal_en.golden" --type f --exec sed -n '24p' {} \; | cat -A | head -1

Repository: modu-ai/goose

Length of output: 300


Permission Request: Bash 헤더 라인 너비 불일치 확인됨 — 수정 권장

경계선 +----------------------------------+은 내부 너비 34 컬럼(총 36자)을 정의하지만, | Permission Request: Bash | 콘텐츠 라인은 35자입니다. 양쪽 끝 |를 포함한 실제 측정 결과이므로, 오른쪽에 스페이스 1개를 추가하여 36자로 맞추는 것이 좋습니다. 이 문제는 permission_modal_en.golden Line 25에도 동일하게 존재합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/tui/testdata/snapshots/permission_modal_ko.golden` at line 25,
The header content line "| Permission Request: Bash        |" is one character
shorter than the border "+----------------------------------+" (internal width
34), so update the header line by adding one trailing space to the right side so
it becomes "| Permission Request: Bash         |" (total 36 chars including the
pipes); make the same adjustment in the corresponding file
permission_modal_en.golden to keep both snapshots' header widths consistent.

+----------------------------------+
| |
| 이 도구 호출을 허용... |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

KO/EN 권한 모달 질문 문장 부호 불일치

Line 28: KO 골든은 이 도구 호출을 허용...(줄임표)을, EN 골든(permission_modal_en.golden Line 28)은 Allow this tool call?(물음표)를 사용합니다. 의도적인 언어별 표현 차이라면 무방하지만, 카탈로그 문자열이 실수로 다를 경우 UX 일관성이 깨질 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/tui/testdata/snapshots/permission_modal_ko.golden` at line 28,
The Korean snapshot string "이 도구 호출을 허용..." uses an ellipsis while the English
snapshot "Allow this tool call?" uses a question mark; update the KO snapshot to
use a question mark for consistency (change "이 도구 호출을 허용..." to "이 도구 호출을 허용?")
in the permission modal golden so both locales use the same interrogative
punctuation.

| > 이번만 허용 |
| 항상 허용 (이 도구) |
| 이번만 거부 |
| 항상 거부 (이 도구) |
| |
| [Up/Down] navigate [Enter] select|
| [Esc] deny once |
+----------------------------------+
Loading
Loading