diff --git a/go.mod b/go.mod index 0c0a76a..a38f291 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( charm.land/lipgloss/v2 v2.0.6 github.com/RandomCodeSpace/rig v0.2.0 github.com/charmbracelet/colorprofile v0.4.3 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 @@ -18,11 +20,10 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect diff --git a/go.sum b/go.sum index 23ea1cf..cf43c53 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= diff --git a/internal/ai/ai.go b/internal/ai/ai.go index b42e4fa..e5c7ae7 100644 --- a/internal/ai/ai.go +++ b/internal/ai/ai.go @@ -143,6 +143,13 @@ func endpoint(base string) (string, error) { return u.String(), nil } +// ValidateBaseURL applies the same endpoint validation used by model runs +// without constructing a client or reading a credential. +func ValidateBaseURL(base string) error { + _, err := endpoint(base) + return err +} + func usesMaxCompletionTokens(model string) bool { name := strings.ToLower(strings.TrimSpace(model)) if i := strings.LastIndex(name, "/"); i >= 0 { diff --git a/internal/server/forge.go b/internal/server/forge.go index 3751df2..10e5d68 100644 --- a/internal/server/forge.go +++ b/internal/server/forge.go @@ -1565,7 +1565,7 @@ func (s *server) handleTestIntegration(w http.ResponseWriter, r *http.Request, u return } - request, err := newForgeTestRequest(r.Context(), kind, target) + request, err := newForgeTestRequest(r.Context(), kind, target, "") if err != nil { writeJSON(w, forgeTestResponse{Error: err.Error()}) return @@ -1614,19 +1614,28 @@ func trimmedForgeProbeValue(value *string) string { return strings.TrimSpace(*value) } -func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget) (*http.Request, error) { +func newForgeTestRequest(ctx context.Context, kind string, target forgeTestTarget, project string) (*http.Request, error) { apiBase, err := forgeAPIBase(kind, target.baseURL) if err != nil { return nil, err } + project = strings.TrimSpace(project) endpoint := apiBase - switch kind { - case "gitlab": - endpoint += "/version" - case "github": - endpoint += "/user" - default: - return nil, errors.New("invalid forge kind") + if project != "" { + projectPath, err := forgeProjectPath(forgeRef{Kind: kind, Project: project}) + if err != nil { + return nil, err + } + endpoint += projectPath + } else { + switch kind { + case "gitlab": + endpoint += "/version" + case "github": + endpoint += "/user" + default: + return nil, errors.New("invalid forge kind") + } } request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -1649,21 +1658,26 @@ func setForgeTestHeaders(request *http.Request, kind, pat string) { } func (s *server) forgeConnectionOK(request *http.Request, user string) bool { - response, err := s.forgeClient.Do(request) + err := executeForgeTest(s.forgeClient, request) if err != nil { log.Printf("forge: connection test for %s failed: %v", user, err) return false } - drainErr := drainForgeResponse(response) - if drainErr != nil { - log.Printf("forge: connection test for %s failed while closing response: %v", user, drainErr) - return false + return true +} + +func executeForgeTest(client *http.Client, request *http.Request) error { + response, err := client.Do(request) + if err != nil { + return err + } + if err := drainForgeResponse(response); err != nil { + return fmt.Errorf("close response: %w", err) } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - log.Printf("forge: connection test for %s failed: upstream status %d", user, response.StatusCode) - return false + return fmt.Errorf("upstream status %d", response.StatusCode) } - return true + return nil } func drainForgeResponse(response *http.Response) error { diff --git a/internal/server/forge_probe.go b/internal/server/forge_probe.go new file mode 100644 index 0000000..3700048 --- /dev/null +++ b/internal/server/forge_probe.go @@ -0,0 +1,77 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/RandomCodeSpace/kb/internal/store" +) + +// ForgeProber tests stored or draft forge integration values without going +// through kb's HTTP API. It is shared by local frontends so they use the same guarded +// client, credential-origin rule, endpoint derivation, and response handling +// as the server handler. +type ForgeProber struct { + store *store.Store + client *http.Client +} + +// ForgeProbeConfig is one connection test candidate. Saved selects whether +// blank endpoint credentials may fall back to the named stored integration; +// drafts set it false and are tested entirely from these unsaved values. +// Project is probe-only and is never persisted with the integration. +type ForgeProbeConfig struct { + Name string + Kind string + BaseURL string + Project string + Token string + Saved bool +} + +// NewForgeProber constructs a direct-store forge connection prober. +func NewForgeProber(st *store.Store) *ForgeProber { + return &ForgeProber{store: st, client: newForgeClient()} +} + +// Probe tests one unsaved candidate. Persisted rows may retain blank stored +// endpoint values; drafts never touch the store. A stored token is never sent +// to a supplied origin that differs from the saved one. +func (p *ForgeProber) Probe(ctx context.Context, user string, config ForgeProbeConfig) error { + if strings.TrimSpace(config.Name) == "" { + return errors.New("integration name is required") + } + kind := strings.TrimSpace(config.Kind) + storedBase, storedToken := "", "" + if config.Saved { + storedKind, baseURL, token, err := p.store.ForgePAT(user, config.Name) + if err != nil { + return errors.New("integration unavailable") + } + if kind == "" { + kind = storedKind + } + storedBase, storedToken = baseURL, token + } + probe := forgeTestProbe{} + if strings.TrimSpace(config.BaseURL) != "" { + probe.BaseURL = &config.BaseURL + } + if strings.TrimSpace(config.Token) != "" { + probe.PAT = &config.Token + } + target, err := resolveForgeTestTarget(storedBase, storedToken, probe) + if err != nil { + return err + } + request, err := newForgeTestRequest(ctx, kind, target, strings.TrimSpace(config.Project)) + if err != nil { + return err + } + if err := executeForgeTest(p.client, request); err != nil { + return errors.New(connectionFailedMessage) + } + return nil +} diff --git a/internal/server/forge_prober_test.go b/internal/server/forge_prober_test.go new file mode 100644 index 0000000..7495bcd --- /dev/null +++ b/internal/server/forge_prober_test.go @@ -0,0 +1,93 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestForgeProberUsesUnsavedValuesWithoutSaving(t *testing.T) { + st := newTestStore(t) + storedBase, storedPAT := "https://stored.example", "stored-token" + if _, err := st.SetForgeSource("alice", "primary", "gitlab", &storedBase, &storedPAT); err != nil { + t.Fatal(err) + } + + const candidatePAT = "candidate-token" + var requests int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.URL.EscapedPath() != "/api/v4/projects/group%2Fproject" || r.Header.Get("PRIVATE-TOKEN") != candidatePAT { + t.Errorf("probe request = %s token=%q", r.URL.EscapedPath(), r.Header.Get("PRIVATE-TOKEN")) + } + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + prober := &ForgeProber{store: st, client: upstream.Client()} + draft := ForgeProbeConfig{ + Name: "unsaved", Kind: "gitlab", BaseURL: upstream.URL, + Project: "group/project", Token: candidatePAT, + } + if err := prober.Probe(context.Background(), "alice", draft); err != nil { + t.Fatalf("Probe: %v", err) + } + if requests != 1 { + t.Fatalf("probe requests = %d", requests) + } + if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{ + Name: "primary", Kind: "gitlab", BaseURL: upstream.URL, + Project: "group/project", Token: candidatePAT, Saved: true, + }); err != nil { + t.Fatalf("saved candidate Probe: %v", err) + } + if requests != 2 { + t.Fatalf("saved candidate probe requests = %d", requests) + } + if kind, base, pat, err := st.ForgePAT("alice", "primary"); err != nil || kind != "gitlab" || base != storedBase || pat != storedPAT { + t.Fatalf("probe changed store = %q/%q/%q, %v", kind, base, pat, err) + } + if sources, err := st.ForgeSources("alice"); err != nil || len(sources) != 1 || sources[0].Name != "primary" { + t.Fatalf("probe changed source list = %+v, %v", sources, err) + } + + if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{ + Name: "primary", Kind: "gitlab", BaseURL: upstream.URL, Saved: true, + }); err == nil || + err.Error() != "enter the token to test a different endpoint" || requests != 2 { + t.Fatalf("cross-origin stored token probe = %v requests=%d", err, requests) + } + if err := prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "missing", Saved: true}); err == nil || err.Error() != "integration unavailable" { + t.Fatalf("missing integration probe = %v", err) + } +} + +func TestForgeProberCollapsesNetworkFailuresAndHonorsCancellation(t *testing.T) { + st := newTestStore(t) + base := "https://forge.example" + if _, err := st.SetForgeSource("alice", "primary", "github", &base, nil); err != nil { + t.Fatal(err) + } + client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) { + <-request.Context().Done() + return nil, request.Context().Err() + })} + prober := &ForgeProber{store: st, client: client} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := prober.Probe(ctx, "alice", ForgeProbeConfig{Name: "primary", Saved: true}) + if err == nil || err.Error() != connectionFailedMessage || strings.Contains(err.Error(), base) { + t.Fatalf("cancelled probe error = %v", err) + } + + prober.client = &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("network exposed candidate-token") + })} + err = prober.Probe(context.Background(), "alice", ForgeProbeConfig{Name: "primary", Token: "candidate-token", Saved: true}) + if err == nil || err.Error() != connectionFailedMessage || strings.Contains(err.Error(), "candidate-token") { + t.Fatalf("network probe error = %v", err) + } +} diff --git a/internal/server/forge_sonar_coverage_test.go b/internal/server/forge_sonar_coverage_test.go index 2c7df13..5db7f54 100644 --- a/internal/server/forge_sonar_coverage_test.go +++ b/internal/server/forge_sonar_coverage_test.go @@ -284,7 +284,7 @@ func TestIntegrationProbeReportsRequestBuildErrorWithoutEgress(t *testing.T) { func TestNewForgeTestRequestRejectsNilContextBeforeEgress(t *testing.T) { //lint:ignore SA1012 Deliberately exercise the request constructor's nil-context error. - request, err := newForgeTestRequest(nil, "gitlab", forgeTestTarget{baseURL: "https://gitlab.example.test"}) + request, err := newForgeTestRequest(nil, "gitlab", forgeTestTarget{baseURL: "https://gitlab.example.test"}, "") if request != nil || err == nil || err.Error() != "invalid forge base URL" { t.Fatalf("nil-context request = (%v, %v), want nil request and construction error", request, err) } diff --git a/internal/tui/board_view.go b/internal/tui/board_view.go index 94806c0..03c6c4c 100644 --- a/internal/tui/board_view.go +++ b/internal/tui/board_view.go @@ -258,10 +258,39 @@ func (m Model) renderBoard() (string, []boardHit) { if m.boardView.showCancelled { cancelled = "on" } - footer := fitLine(state+" | j/k cards | h/l/tab columns | 1-4 jump | c cancelled:"+cancelled+" | q quit", width) + help := "j/k cards | h/l/tab columns | 1-4 jump | c cancelled:" + cancelled + " | q quit" + if m.settingsNew != nil { + footer := settingsBoardFooter(state, cancelled, width) + return strings.Join([]string{header, body, footer}, "\n"), hits + } + footer := fitLine(state+" | "+help, width) return strings.Join([]string{header, body, footer}, "\n"), hits } +func settingsBoardFooter(state, cancelled string, width int) string { + candidates := [][]string{ + {"s settings", "j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:" + cancelled, "q quit"}, + {"s settings", "j/k cards", "h/l/tab columns", "c cancelled:" + cancelled, "q quit"}, + {"s settings", "j/k cards", "h/l/tab columns", "q quit"}, + {"s settings", "j/k", "h/l/tab", "q quit"}, + {"s settings", "j/k h/l", "q quit"}, + {"s", "j/k h/l", "q quit"}, + {"s", "nav", "q quit"}, + {"s", "q quit"}, + {"q quit"}, + } + minimumStateWidth := min(ansi.StringWidth(state), 5) + for _, candidate := range candidates { + help := strings.Join(candidate, " | ") + stateWidth := width - ansi.StringWidth(help) - 3 + if stateWidth < minimumStateWidth { + continue + } + return fitLine(state, stateWidth) + " | " + help + } + return fitLine("q quit", width) +} + func (m Model) render() string { content, _ := m.renderBoard() return content diff --git a/internal/tui/board_view_test.go b/internal/tui/board_view_test.go index c5514a8..c5dd625 100644 --- a/internal/tui/board_view_test.go +++ b/internal/tui/board_view_test.go @@ -291,6 +291,65 @@ func TestBoardRenderResponsiveFullCardsAndMouse(t *testing.T) { } } +func TestBoardFooterPrioritizesProductionSettingsActions(t *testing.T) { + model := NewModel(stubBoardReader{}, nil, "alice") + model.loading = false + model.settingsNew = func() *settingsModel { return nil } + for _, test := range []struct { + width int + want []string + notWant []string + }{ + { + width: 80, + want: []string{"s settings", "j/k cards", "h/l/tab columns", "c cancelled:off", "q quit"}, + notWant: []string{ + "1-4 jump", + }, + }, + { + width: 40, + want: []string{"s settings", "j/k h/l", "q quit"}, + notWant: []string{"1-4 jump", "c cancelled:off"}, + }, + } { + model.width = test.width + content, _ := model.renderBoard() + lines := strings.Split(plain(content), "\n") + footer := lines[len(lines)-1] + for _, want := range test.want { + if !strings.Contains(footer, want) { + t.Errorf("width %d footer missing %q: %q", test.width, want, footer) + } + } + for _, notWant := range test.notWant { + if strings.Contains(footer, notWant) { + t.Errorf("width %d footer retained lower-priority %q: %q", test.width, notWant, footer) + } + } + if ansi.StringWidth(footer) > test.width { + t.Errorf("width %d footer rendered %d cells: %q", test.width, ansi.StringWidth(footer), footer) + } + } +} + +func TestBoardFooterWithoutSettingsKeepsBoardHints(t *testing.T) { + model := NewModel(stubBoardReader{}, nil, "alice") + model.loading = false + model.width = 80 + content, _ := model.renderBoard() + lines := strings.Split(plain(content), "\n") + footer := lines[len(lines)-1] + for _, want := range []string{"j/k cards", "h/l/tab columns", "1-4 jump", "c cancelled:off", "q quit"} { + if !strings.Contains(footer, want) { + t.Errorf("board-only footer missing %q: %q", want, footer) + } + } + if strings.Contains(footer, "s settings") { + t.Fatalf("board-only footer exposed unavailable settings: %q", footer) + } +} + func TestBoardRenderScrollsSelectionIntoShortColumn(t *testing.T) { now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) m := NewModel(stubBoardReader{}, nil, "u") diff --git a/internal/tui/model.go b/internal/tui/model.go index 1feca89..128f23c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -70,6 +70,8 @@ type Model struct { preferenceErr error prefSaving bool prefPending *bool + settings *settingsModel + settingsNew func() *settingsModel } // NewModel creates the root model for one local board owner. @@ -117,6 +119,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.stopped { return m, nil } + if m.settings != nil && isSettingsMessage(message) { + command := m.settings.Update(message) + if m.settings.closed { + m.settings = nil + } + return m, command + } var detailCmd tea.Cmd if m.detail.IsOpen() { switch msg := message.(type) { @@ -138,11 +147,31 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } switch msg := message.(type) { case tea.KeyPressMsg: + if msg.String() == "ctrl+c" { + if m.settings != nil { + m.settings.Close() + } + m.stopped = true + m.reloadPending = false + return m, tea.Quit + } + if m.settings != nil { + command := m.settings.Update(msg) + if m.settings.closed { + m.settings = nil + } + return m, command + } switch msg.String() { - case "q", "ctrl+c": + case "q": m.stopped = true m.reloadPending = false return m, tea.Quit + case "s": + if m.settingsNew != nil { + m.settings = m.settingsNew() + return m, m.settings.Init() + } case "enter": if task, ok := m.selectedTask(); ok { m.detail.Resize(m.width, m.height) @@ -300,10 +329,16 @@ func (m Model) View() tea.View { content = m.detail.Overlay(content, m.width, m.height) hits = nil } + if m.settings != nil { + content = m.settings.View(m.width, m.height) + hits = nil + } view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion - view.OnMouse = boardMouseHandler(hits) + if m.settings == nil { + view.OnMouse = boardMouseHandler(hits) + } return view } diff --git a/internal/tui/run.go b/internal/tui/run.go index 14a1cb2..91a0141 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -20,17 +20,40 @@ type watcherOpener func(context.Context, string) (versionWatcher, error) // Run opens the data-version watcher and runs the full-screen program. // Program options are accepted for deterministic tests; production passes none. func Run(st *store.Store, databasePath, user string, options ...tea.ProgramOption) (err error) { - return run(st, databasePath, user, func(ctx context.Context, path string) (versionWatcher, error) { + return runWithSettings(st, databasePath, user, func(ctx context.Context, path string) (versionWatcher, error) { return OpenDataVersionWatcher(ctx, path) }, options...) } +func runWithSettings( + st *store.Store, + databasePath string, + user string, + openWatcher watcherOpener, + options ...tea.ProgramOption, +) (err error) { + return runProgram(st, databasePath, user, openWatcher, func(ctx context.Context) *settingsModel { + return newSettingsModel(st, user, ctx) + }, options...) +} + func run( st boardReader, databasePath string, user string, openWatcher watcherOpener, options ...tea.ProgramOption, +) (err error) { + return runProgram(st, databasePath, user, openWatcher, nil, options...) +} + +func runProgram( + st boardReader, + databasePath string, + user string, + openWatcher watcherOpener, + settingsNew func(context.Context) *settingsModel, + options ...tea.ProgramOption, ) (err error) { ctx, cancel := context.WithCancel(context.Background()) watcher, err := openWatcher(ctx, databasePath) @@ -55,6 +78,9 @@ func run( } return saveCancelledPreference(preferencePath, show) } + if settingsNew != nil { + model.settingsNew = func() *settingsModel { return settingsNew(ctx) } + } if _, err := tea.NewProgram(model, options...).Run(); err != nil { return fmt.Errorf("tui: run: %w", err) } diff --git a/internal/tui/settings.go b/internal/tui/settings.go new file mode 100644 index 0000000..99fcdc2 --- /dev/null +++ b/internal/tui/settings.go @@ -0,0 +1,707 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + + kbai "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/server" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const settingsTestTimeout = 20 * time.Second + +type settingsStore interface { + AISettings(string) (store.AISettings, error) + SetAISettings(string, *string, *string, *string) (bool, error) + ForgeSources(string) ([]store.ForgeSource, error) + SetForgeSource(string, string, string, *string, *string) (bool, error) + DeleteForgeSource(string, string) error +} + +type aiConnectionProber interface { + Probe(context.Context, string, kbai.Config) error +} + +type forgeConnectionProber interface { + Probe(context.Context, string, server.ForgeProbeConfig) error +} + +type settingsLoadedMsg struct { + ai store.AISettings + sources []store.ForgeSource + err error +} + +type aiSettingsTestedMsg struct{ err error } +type aiSettingsSavedMsg struct { + keySet bool + keyCleared bool + err error +} +type forgeSettingsTestedMsg struct { + id string + err error +} +type forgeSettingsSavedMsg struct { + id string + source store.ForgeSource + tokenCleared bool + err error +} +type forgeSettingsRemovedMsg struct { + id string + err error +} + +type integrationSettingsRow struct { + id string + persisted bool + name textinput.Model + kind string + baseURL textinput.Model + project textinput.Model + token textinput.Model + hasToken bool +} + +type settingsModel struct { + ctx context.Context + store settingsStore + ai aiConnectionProber + forge forgeConnectionProber + user string + loaded bool + + aiBase textinput.Model + aiModel textinput.Model + aiKey textinput.Model + hasKey bool + + rows []integrationSettingsRow + nextDraft int + focus string + busy string + status string + statusIsError bool + armedRemove string + scroll int + testCancel context.CancelFunc + closed bool +} + +func newSettingsModel(st *store.Store, user string, ctx context.Context) *settingsModel { + return newSettingsModelWithBackends( + st, + kbai.NewRunner(st, "", nil, nil), + server.NewForgeProber(st), + user, + ctx, + ) +} + +func newSettingsModelWithBackends( + st settingsStore, + ai aiConnectionProber, + forge forgeConnectionProber, + user string, + ctx context.Context, +) *settingsModel { + if ctx == nil { + ctx = context.Background() + } + m := &settingsModel{ + ctx: ctx, + store: st, + ai: ai, + forge: forge, + user: user, + aiBase: settingsInput("https://api.openai.com/v1", false), + aiModel: settingsInput("model", false), + aiKey: settingsInput("blank keeps saved key", true), + focus: "ai:base", + } + m.applyFocus() + return m +} + +func settingsInput(placeholder string, secret bool) textinput.Model { + input := textinput.New() + input.Prompt = "" + input.Placeholder = placeholder + input.SetWidth(48) + if secret { + input.EchoMode = textinput.EchoPassword + input.EchoCharacter = '*' + } + return input +} + +func (m *settingsModel) Init() tea.Cmd { + return func() tea.Msg { + aiSettings, err := m.store.AISettings(m.user) + if err != nil { + return settingsLoadedMsg{err: err} + } + sources, err := m.store.ForgeSources(m.user) + return settingsLoadedMsg{ai: aiSettings, sources: sources, err: err} + } +} + +func (m *settingsModel) Close() { + if m.testCancel != nil { + m.testCancel() + m.testCancel = nil + } + m.closed = true +} + +func (m *settingsModel) Update(message tea.Msg) tea.Cmd { + switch msg := message.(type) { + case settingsLoadedMsg: + m.finishLoad(msg) + return nil + case aiSettingsTestedMsg: + if m.busy != "ai:test" { + return nil + } + m.finishTest(msg.err, m.aiKey.Value()) + return nil + case aiSettingsSavedMsg: + if m.busy != "ai:save" { + return nil + } + m.finishAISave(msg) + return nil + case forgeSettingsTestedMsg: + if m.busy != "forge:test:"+msg.id { + return nil + } + row := m.rowByID(msg.id) + secret := "" + if row != nil { + secret = row.token.Value() + } + m.finishTest(msg.err, secret) + return nil + case forgeSettingsSavedMsg: + if m.busy != "forge:save:"+msg.id { + return nil + } + m.finishForgeSave(msg) + return nil + case forgeSettingsRemovedMsg: + if m.busy != "forge:remove:"+msg.id { + return nil + } + m.finishForgeRemove(msg) + return nil + case tea.KeyPressMsg: + return m.updateKey(msg) + } + return nil +} + +func (m *settingsModel) finishLoad(msg settingsLoadedMsg) { + if msg.err != nil { + m.status = safeSettingsError(msg.err) + m.statusIsError = true + return + } + m.loaded = true + m.aiBase.SetValue(msg.ai.BaseURL) + m.aiModel.SetValue(msg.ai.Model) + m.hasKey = msg.ai.HasKey + m.rows = make([]integrationSettingsRow, 0, len(msg.sources)) + for _, source := range msg.sources { + m.rows = append(m.rows, persistedIntegrationRow(source)) + } + m.applyFocus() +} + +func persistedIntegrationRow(source store.ForgeSource) integrationSettingsRow { + row := integrationSettingsRow{ + id: "source:" + source.Name, + persisted: true, + name: settingsInput("source-name", false), + kind: source.Kind, + baseURL: settingsInput("forge.example.com", false), + project: settingsInput("owner/project (optional)", false), + token: settingsInput("blank keeps saved token", true), + hasToken: source.HasToken, + } + row.name.SetValue(source.Name) + row.baseURL.SetValue(source.BaseURL) + return row +} + +func (m *settingsModel) updateKey(msg tea.KeyPressMsg) tea.Cmd { + key := msg.String() + if key == "esc" { + if strings.Contains(m.busy, ":test") && m.testCancel != nil { + m.testCancel() + m.testCancel = nil + m.busy = "" + m.status = "connection test cancelled" + m.statusIsError = false + return nil + } + if m.busy == "" { + m.Close() + } + return nil + } + if !m.loaded || m.busy != "" { + return nil + } + switch key { + case "tab": + m.moveFocus(1) + return nil + case "shift+tab": + m.moveFocus(-1) + return nil + case "enter": + return m.activateFocus() + case "left", "right": + if row, control := m.focusedRow(); row != nil && control == "kind" { + m.disarmRemove() + if row.kind == "gitlab" { + row.kind = "github" + } else { + row.kind = "gitlab" + } + return nil + } + } + m.disarmRemove() + return m.updateFocusedInput(msg) +} + +func (m *settingsModel) moveFocus(delta int) { + m.disarmRemove() + targets := m.focusTargets() + if len(targets) == 0 { + return + } + index := 0 + for i, target := range targets { + if target == m.focus { + index = i + break + } + } + index = (index + delta + len(targets)) % len(targets) + m.focus = targets[index] + m.applyFocus() +} + +func (m *settingsModel) focusTargets() []string { + targets := []string{"ai:base", "ai:model", "ai:key", "ai:test", "ai:save"} + for i := range m.rows { + row := &m.rows[i] + prefix := "forge:" + row.id + ":" + if !row.persisted { + targets = append(targets, prefix+"kind", prefix+"name") + } + targets = append(targets, prefix+"base", prefix+"project", prefix+"token", prefix+"test") + targets = append(targets, prefix+"save", prefix+"remove") + } + return append(targets, "forge:add") +} + +func (m *settingsModel) applyFocus() tea.Cmd { + m.aiBase.Blur() + m.aiModel.Blur() + m.aiKey.Blur() + for i := range m.rows { + m.rows[i].name.Blur() + m.rows[i].baseURL.Blur() + m.rows[i].project.Blur() + m.rows[i].token.Blur() + } + switch m.focus { + case "ai:base": + return m.aiBase.Focus() + case "ai:model": + return m.aiModel.Focus() + case "ai:key": + return m.aiKey.Focus() + } + row, control := m.focusedRow() + if row == nil { + return nil + } + if row.persisted && control == "name" { + return nil + } + switch control { + case "name": + return row.name.Focus() + case "base": + return row.baseURL.Focus() + case "project": + return row.project.Focus() + case "token": + return row.token.Focus() + } + return nil +} + +func (m *settingsModel) updateFocusedInput(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + switch m.focus { + case "ai:base": + m.aiBase, cmd = m.aiBase.Update(msg) + case "ai:model": + m.aiModel, cmd = m.aiModel.Update(msg) + case "ai:key": + m.aiKey, cmd = m.aiKey.Update(msg) + default: + row, control := m.focusedRow() + if row == nil { + return nil + } + if row.persisted && control == "name" { + return nil + } + switch control { + case "name": + row.name, cmd = row.name.Update(msg) + case "base": + row.baseURL, cmd = row.baseURL.Update(msg) + case "project": + row.project, cmd = row.project.Update(msg) + case "token": + row.token, cmd = row.token.Update(msg) + } + } + return cmd +} + +func (m *settingsModel) focusedRow() (*integrationSettingsRow, string) { + if !strings.HasPrefix(m.focus, "forge:") || m.focus == "forge:add" { + return nil, "" + } + rest := strings.TrimPrefix(m.focus, "forge:") + for i := range m.rows { + prefix := m.rows[i].id + ":" + if strings.HasPrefix(rest, prefix) { + return &m.rows[i], strings.TrimPrefix(rest, prefix) + } + } + return nil, "" +} + +func (m *settingsModel) activateFocus() tea.Cmd { + switch m.focus { + case "ai:test": + return m.startAITest() + case "ai:save": + return m.startAISave() + case "forge:add": + m.addForgeDraft() + return nil + } + row, control := m.focusedRow() + if row == nil { + return nil + } + switch control { + case "kind": + if row.kind == "gitlab" { + row.kind = "github" + } else { + row.kind = "gitlab" + } + case "test": + return m.startForgeTest(row) + case "save": + return m.startForgeSave(row) + case "remove": + return m.startForgeRemove(row) + } + return nil +} + +func (m *settingsModel) startAITest() tea.Cmd { + m.disarmRemove() + ctx, cancel := context.WithTimeout(m.ctx, settingsTestTimeout) + m.testCancel = cancel + m.busy = "ai:test" + m.status = "testing AI connection..." + m.statusIsError = false + config := kbai.Config{ + BaseURL: strings.TrimSpace(m.aiBase.Value()), + Model: strings.TrimSpace(m.aiModel.Value()), + Key: m.aiKey.Value(), + } + return func() tea.Msg { + defer cancel() + return aiSettingsTestedMsg{err: m.ai.Probe(ctx, m.user, config)} + } +} + +func (m *settingsModel) finishTest(err error, secret string) { + if m.testCancel != nil { + m.testCancel() + m.testCancel = nil + } + m.busy = "" + m.statusIsError = err != nil + if err == nil { + m.status = "connection ok" + return + } + m.status = safeSettingsError(err, secret) +} + +func (m *settingsModel) startAISave() tea.Cmd { + m.disarmRemove() + base, model, key := optionalTrimmed(m.aiBase.Value()), optionalTrimmed(m.aiModel.Value()), optionalSecret(m.aiKey.Value()) + if base != nil { + if err := kbai.ValidateBaseURL(*base); err != nil { + m.status = safeSettingsError(err) + m.statusIsError = true + return nil + } + } + m.busy = "ai:save" + m.status = "saving AI settings..." + m.statusIsError = false + keySet := key != nil + return func() tea.Msg { + cleared, err := m.store.SetAISettings(m.user, base, model, key) + return aiSettingsSavedMsg{keySet: keySet, keyCleared: cleared, err: err} + } +} + +func (m *settingsModel) finishAISave(msg aiSettingsSavedMsg) { + m.busy = "" + if msg.err != nil { + m.status = safeSettingsError(msg.err, m.aiKey.Value()) + m.statusIsError = true + return + } + m.hasKey = msg.keySet || (m.hasKey && !msg.keyCleared) + m.aiKey.SetValue("") + m.statusIsError = false + if msg.keyCleared { + m.status = "saved; endpoint changed, re-enter the API key" + } else { + m.status = "AI settings saved" + } +} + +func (m *settingsModel) addForgeDraft() { + m.disarmRemove() + m.nextDraft++ + id := fmt.Sprintf("draft:%d", m.nextDraft) + m.rows = append(m.rows, integrationSettingsRow{ + id: id, + name: settingsInput("work-gitlab", false), + kind: "gitlab", + baseURL: settingsInput("gitlab.example.com", false), + project: settingsInput("owner/project (optional)", false), + token: settingsInput("personal access token", true), + }) + m.focus = "forge:" + id + ":name" + m.applyFocus() +} + +func (m *settingsModel) startForgeTest(row *integrationSettingsRow) tea.Cmd { + m.disarmRemove() + ctx, cancel := context.WithTimeout(m.ctx, settingsTestTimeout) + m.testCancel = cancel + m.busy = "forge:test:" + row.id + m.status = "testing " + row.name.Value() + "..." + m.statusIsError = false + id := row.id + config := server.ForgeProbeConfig{ + Name: row.name.Value(), Kind: row.kind, BaseURL: row.baseURL.Value(), + Project: row.project.Value(), Token: row.token.Value(), Saved: row.persisted, + } + return func() tea.Msg { + defer cancel() + err := m.forge.Probe(ctx, m.user, config) + return forgeSettingsTestedMsg{id: id, err: err} + } +} + +func (m *settingsModel) startForgeSave(row *integrationSettingsRow) tea.Cmd { + m.disarmRemove() + name := strings.TrimSpace(row.name.Value()) + if name == "" { + m.status, m.statusIsError = "integration name is required", true + return nil + } + for i := range m.rows { + other := &m.rows[i] + if other.id != row.id && strings.EqualFold(strings.TrimSpace(other.name.Value()), name) { + m.status, m.statusIsError = "integration name already exists", true + return nil + } + } + baseURL := optionalTrimmed(row.baseURL.Value()) + if !row.persisted && baseURL == nil { + m.status, m.statusIsError = "forge base URL is required", true + return nil + } + token := optionalSecret(row.token.Value()) + m.busy = "forge:save:" + row.id + m.status = "saving " + name + "..." + m.statusIsError = false + id, kind := row.id, row.kind + return func() tea.Msg { + cleared, err := m.store.SetForgeSource(m.user, name, kind, baseURL, token) + if err != nil { + return forgeSettingsSavedMsg{id: id, tokenCleared: cleared, err: err} + } + sources, err := m.store.ForgeSources(m.user) + if err != nil { + return forgeSettingsSavedMsg{id: id, tokenCleared: cleared, err: err} + } + for _, source := range sources { + if strings.EqualFold(source.Name, name) { + return forgeSettingsSavedMsg{id: id, source: source, tokenCleared: cleared} + } + } + return forgeSettingsSavedMsg{id: id, tokenCleared: cleared, err: errors.New("saved integration unavailable")} + } +} + +func (m *settingsModel) finishForgeSave(msg forgeSettingsSavedMsg) { + m.busy = "" + row := m.rowByID(msg.id) + if row == nil { + return + } + if msg.err != nil { + m.status = safeSettingsError(msg.err, row.token.Value()) + m.statusIsError = true + return + } + replacement := persistedIntegrationRow(msg.source) + *row = replacement + m.focus = "forge:" + replacement.id + ":save" + m.applyFocus() + m.statusIsError = false + if msg.tokenCleared { + m.status = "saved; endpoint changed, re-enter the token" + } else { + m.status = "integration saved" + } +} + +func (m *settingsModel) startForgeRemove(row *integrationSettingsRow) tea.Cmd { + if m.armedRemove != row.id { + m.armedRemove = row.id + m.status = "press enter again to remove " + row.name.Value() + m.statusIsError = false + return nil + } + m.armedRemove = "" + if !row.persisted { + m.removeRow(row.id) + m.status = "draft integration removed" + m.focus = "forge:add" + m.applyFocus() + return nil + } + m.busy = "forge:remove:" + row.id + m.status = "removing " + row.name.Value() + "..." + id, name := row.id, row.name.Value() + return func() tea.Msg { + return forgeSettingsRemovedMsg{id: id, err: m.store.DeleteForgeSource(m.user, name)} + } +} + +func (m *settingsModel) finishForgeRemove(msg forgeSettingsRemovedMsg) { + m.busy = "" + row := m.rowByID(msg.id) + if msg.err != nil { + secret := "" + if row != nil { + secret = row.token.Value() + } + m.status = safeSettingsError(msg.err, secret) + m.statusIsError = true + return + } + m.removeRow(msg.id) + m.focus = "forge:add" + m.applyFocus() + m.status = "integration removed" + m.statusIsError = false +} + +func (m *settingsModel) rowByID(id string) *integrationSettingsRow { + for i := range m.rows { + if m.rows[i].id == id { + return &m.rows[i] + } + } + return nil +} + +func (m *settingsModel) removeRow(id string) { + for i := range m.rows { + if m.rows[i].id == id { + m.rows = append(m.rows[:i], m.rows[i+1:]...) + return + } + } +} + +func (m *settingsModel) disarmRemove() { m.armedRemove = "" } + +func optionalTrimmed(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func optionalSecret(value string) *string { + if strings.TrimSpace(value) == "" { + return nil + } + return &value +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func safeSettingsError(err error, secrets ...string) string { + if err == nil { + return "" + } + message := strings.TrimSpace(err.Error()) + for _, secret := range secrets { + for _, candidate := range []string{secret, strings.TrimSpace(secret)} { + if candidate != "" { + message = strings.ReplaceAll(message, candidate, "[redacted]") + } + } + } + message = sanitizeTerminal(strings.Join(strings.Fields(message), " ")) + if message == "" { + message = "operation failed" + } + if utf8.RuneCountInString(message) > 160 { + message = string([]rune(message)[:160]) + } + return message +} diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go new file mode 100644 index 0000000..1b8853d --- /dev/null +++ b/internal/tui/settings_test.go @@ -0,0 +1,724 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + + kbai "github.com/RandomCodeSpace/kb/internal/ai" + "github.com/RandomCodeSpace/kb/internal/server" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const ( + settingsStoredAIKey = "stored-ai-secret" + settingsStoredPAT = "stored-forge-secret" + settingsUnsavedSecret = "unsaved-secret" +) + +type recordingAIProber struct { + user string + config kbai.Config + hadDeadline bool + err error +} + +func (p *recordingAIProber) Probe(ctx context.Context, user string, config kbai.Config) error { + p.user = user + p.config = config + _, p.hadDeadline = ctx.Deadline() + return p.err +} + +type recordingForgeProber struct { + user string + config server.ForgeProbeConfig + hadDeadline bool + err error +} + +type faultSettingsStore struct { + ai store.AISettings + sources []store.ForgeSource + aiErr error + forgeErr error + setAIErr error + setForgeErr error + deleteForgeErr error +} + +func (s *faultSettingsStore) AISettings(string) (store.AISettings, error) { + return s.ai, s.aiErr +} + +func (s *faultSettingsStore) SetAISettings(string, *string, *string, *string) (bool, error) { + return false, s.setAIErr +} + +func (s *faultSettingsStore) ForgeSources(string) ([]store.ForgeSource, error) { + return s.sources, s.forgeErr +} + +func (s *faultSettingsStore) SetForgeSource(string, string, string, *string, *string) (bool, error) { + return false, s.setForgeErr +} + +func (s *faultSettingsStore) DeleteForgeSource(string, string) error { + return s.deleteForgeErr +} + +func (p *recordingForgeProber) Probe(ctx context.Context, user string, config server.ForgeProbeConfig) error { + p.user, p.config = user, config + _, p.hadDeadline = ctx.Deadline() + return p.err +} + +func newSettingsTestStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(t.TempDir()+"/kb.db", []byte("settings-test-store-key")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func loadSettingsForTest(t *testing.T, model *settingsModel) { + t.Helper() + command := model.Init() + if command == nil { + t.Fatal("settings init command is nil") + } + model.Update(command()) + if !model.loaded { + t.Fatalf("settings did not load: %s", model.status) + } +} + +func runSettingsCommand(t *testing.T, model *settingsModel, command tea.Cmd) { + t.Helper() + if command == nil { + t.Fatal("settings command is nil") + } + model.Update(command()) +} + +func TestAISettingsUseUnsavedProbeAndStorePatchSemantics(t *testing.T) { + st := newSettingsTestStore(t) + storedBase, storedModel := "https://stored.example/v1", "stored-model" + if _, err := st.SetAISettings("alice", &storedBase, &storedModel, stringPointer(settingsStoredAIKey)); err != nil { + t.Fatal(err) + } + aiProbe := &recordingAIProber{} + model := newSettingsModelWithBackends(st, aiProbe, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + + if view := model.View(100, 30); strings.Contains(view, settingsStoredAIKey) { + t.Fatal("persisted AI key rendered in settings view") + } + model.aiBase.SetValue("https://candidate.example/v1") + model.aiModel.SetValue("candidate-model") + model.aiKey.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startAITest()) + if aiProbe.user != "alice" || aiProbe.config != (kbai.Config{ + BaseURL: "https://candidate.example/v1", + Model: "candidate-model", + Key: settingsUnsavedSecret, + }) || !aiProbe.hadDeadline { + t.Fatalf("AI probe = user:%q config:%+v deadline:%v", aiProbe.user, aiProbe.config, aiProbe.hadDeadline) + } + if key, err := st.AIKey("alice"); err != nil || key != settingsStoredAIKey { + t.Fatalf("unsaved probe changed stored key = %q, %v", key, err) + } + + // Blank base URL and key are nil patches; the model changes and the saved + // endpoint and credential remain intact. + model.aiBase.SetValue("") + model.aiModel.SetValue("candidate-model") + model.aiKey.SetValue("") + runSettingsCommand(t, model, model.startAISave()) + settings, err := st.AISettings("alice") + if err != nil || settings.BaseURL != storedBase || settings.Model != "candidate-model" || !settings.HasKey { + t.Fatalf("blank-preserving save = %+v, %v", settings, err) + } + if key, err := st.AIKey("alice"); err != nil || key != settingsStoredAIKey { + t.Fatalf("blank key did not preserve stored key = %q, %v", key, err) + } + + // A different origin with a blank key delegates to the store's atomic + // credential-clearing contract. + model.aiBase.SetValue("https://different.example/v1") + model.aiModel.SetValue("") + runSettingsCommand(t, model, model.startAISave()) + settings, err = st.AISettings("alice") + if err != nil || settings.BaseURL != "https://different.example/v1" || settings.Model != "candidate-model" || settings.HasKey { + t.Fatalf("origin-changing save = %+v, %v", settings, err) + } + if model.hasKey || !strings.Contains(model.status, "re-enter") { + t.Fatalf("cleared-key UI state = hasKey:%v status:%q", model.hasKey, model.status) + } +} + +func TestSettingsNeverRenderSecretBearingErrorsAndCancelTests(t *testing.T) { + st := newSettingsTestStore(t) + aiProbe := &recordingAIProber{err: errors.New("upstream rejected " + settingsUnsavedSecret)} + model := newSettingsModelWithBackends(st, aiProbe, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + model.aiKey.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startAITest()) + if view := model.View(80, 24); strings.Contains(view, settingsUnsavedSecret) || !strings.Contains(view, "[redacted]") { + t.Fatalf("secret-bearing error was not redacted:\n%s", view) + } + + model.aiKey.SetValue("another-secret") + command := model.startAITest() + if model.testCancel == nil { + t.Fatal("AI test did not retain a cancellation handle") + } + model.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + if model.busy != "" || model.testCancel != nil || !strings.Contains(model.status, "cancelled") { + t.Fatalf("cancel state = busy:%q cancel:%v status:%q", model.busy, model.testCancel != nil, model.status) + } + // The late result is ignored after cancellation. + model.Update(command()) + if !strings.Contains(model.status, "cancelled") { + t.Fatalf("late test result replaced cancellation: %q", model.status) + } +} + +func TestForgeSettingsTestSaveLockAndArmedRemoval(t *testing.T) { + st := newSettingsTestStore(t) + base := "https://forge.example" + if _, err := st.SetForgeSource("alice", "primary", "gitlab", &base, stringPointer(settingsStoredPAT)); err != nil { + t.Fatal(err) + } + forgeProbe := &recordingForgeProber{} + model := newSettingsModelWithBackends(st, &recordingAIProber{}, forgeProbe, "alice", context.Background()) + loadSettingsForTest(t, model) + row := model.rowByID("source:primary") + if row == nil { + t.Fatal("persisted forge row missing") + } + if view := model.View(100, 40); strings.Contains(view, settingsStoredPAT) { + t.Fatal("persisted forge token rendered in settings view") + } + + // Persisted name and kind have no focus targets and reject direct input too. + for _, target := range model.focusTargets() { + if target == "forge:source:primary:name" || target == "forge:source:primary:kind" { + t.Fatalf("persisted immutable field remained focusable: %q", target) + } + } + model.focus = "forge:source:primary:name" + model.applyFocus() + model.Update(tea.KeyPressMsg{Code: 'x'}) + if row.name.Value() != "primary" || row.kind != "gitlab" { + t.Fatalf("persisted identity changed to %q/%q", row.name.Value(), row.kind) + } + + row.baseURL.SetValue("https://unsaved.example") + row.project.SetValue("group/project") + row.token.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startForgeTest(row)) + if forgeProbe.user != "alice" || forgeProbe.config != (server.ForgeProbeConfig{ + Name: "primary", Kind: "gitlab", BaseURL: "https://unsaved.example", + Project: "group/project", Token: settingsUnsavedSecret, Saved: true, + }) || !forgeProbe.hadDeadline { + t.Fatalf("forge probe = %+v", forgeProbe) + } + if _, savedBase, savedPAT, err := st.ForgePAT("alice", "primary"); err != nil || savedBase != base || savedPAT != settingsStoredPAT { + t.Fatalf("unsaved forge test changed store = %q/%q, %v", savedBase, savedPAT, err) + } + + // Saving the changed origin with a blank token uses the store's clearing + // behavior and leaves the row identity locked. + row.token.SetValue("") + runSettingsCommand(t, model, model.startForgeSave(row)) + if _, savedBase, savedPAT, err := st.ForgePAT("alice", "primary"); err != nil || savedBase != "https://unsaved.example" || savedPAT != "" { + t.Fatalf("forge save = %q/%q, %v", savedBase, savedPAT, err) + } + if row.hasToken || !strings.Contains(model.status, "re-enter") { + t.Fatalf("cleared-token UI state = hasToken:%v status:%q", row.hasToken, model.status) + } + + model.focus = "forge:source:primary:remove" + if command := model.activateFocus(); command != nil || model.armedRemove != row.id { + t.Fatalf("first removal press = command:%v armed:%q", command, model.armedRemove) + } + model.moveFocus(1) + if model.armedRemove != "" { + t.Fatalf("navigation did not disarm removal: %q", model.armedRemove) + } + model.focus = "forge:source:primary:remove" + if command := model.activateFocus(); command != nil { + t.Fatal("re-arming removal returned a command") + } + runSettingsCommand(t, model, model.activateFocus()) + if sources, err := st.ForgeSources("alice"); err != nil || len(sources) != 0 || model.rowByID(row.id) != nil { + t.Fatalf("confirmed removal = sources:%+v row:%v err:%v", sources, model.rowByID(row.id), err) + } +} + +func TestForgeDraftBecomesPersistedAndImmutable(t *testing.T) { + st := newSettingsTestStore(t) + model := newSettingsModelWithBackends(st, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + model.addForgeDraft() + row := &model.rows[0] + row.name.SetValue("Work-GitHub") + row.kind = "github" + row.baseURL.SetValue("github.example") + row.token.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startForgeSave(row)) + if !row.persisted || row.id != "source:work-github" || row.name.Value() != "work-github" || + row.kind != "github" || row.baseURL.Value() != "https://github.example" || + row.project.Value() != "" || row.token.Value() != "" || !row.hasToken { + t.Fatalf("saved draft row = %+v", row) + } + if kind, savedBase, savedPAT, err := st.ForgePAT("alice", "work-github"); err != nil || kind != "github" || savedBase != "https://github.example" || savedPAT != settingsUnsavedSecret { + t.Fatalf("saved draft store = %q/%q/%q, %v", kind, savedBase, savedPAT, err) + } + for _, target := range model.focusTargets() { + if strings.HasSuffix(target, ":name") || strings.HasSuffix(target, ":kind") { + t.Fatalf("saved draft identity remained editable: %q", target) + } + } +} + +func TestForgeDraftTestsUnsavedValuesWithoutStoreMutation(t *testing.T) { + st := newSettingsTestStore(t) + forgeProbe := &recordingForgeProber{} + model := newSettingsModelWithBackends(st, &recordingAIProber{}, forgeProbe, "alice", context.Background()) + loadSettingsForTest(t, model) + model.addForgeDraft() + row := &model.rows[0] + row.name.SetValue("unsaved") + row.kind = "github" + row.baseURL.SetValue("https://candidate.example") + row.project.SetValue("owner/project") + row.token.SetValue(settingsUnsavedSecret) + + if !slicesContains(model.focusTargets(), "forge:"+row.id+":test") { + t.Fatal("draft test action is not focusable") + } + runSettingsCommand(t, model, model.startForgeTest(row)) + want := server.ForgeProbeConfig{ + Name: "unsaved", Kind: "github", BaseURL: "https://candidate.example", + Project: "owner/project", Token: settingsUnsavedSecret, + } + if forgeProbe.user != "alice" || forgeProbe.config != want || !forgeProbe.hadDeadline { + t.Fatalf("draft forge probe = user:%q config:%+v deadline:%v", forgeProbe.user, forgeProbe.config, forgeProbe.hadDeadline) + } + if sources, err := st.ForgeSources("alice"); err != nil || len(sources) != 0 { + t.Fatalf("draft probe mutated store: sources=%+v err=%v", sources, err) + } +} + +func slicesContains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} + +func TestSettingsViewportKeepsEveryFocusedControlVisible(t *testing.T) { + sources := make([]store.ForgeSource, 10) + for i := range sources { + sources[i] = store.ForgeSource{ + Name: fmt.Sprintf("source-%02d", i), Kind: "gitlab", BaseURL: "https://forge.example", + } + } + backend := &faultSettingsStore{ + ai: store.AISettings{BaseURL: "https://api.example", Model: "model"}, sources: sources, + } + model := newSettingsModelWithBackends(backend, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + + for _, target := range model.focusTargets() { + model.focus = target + model.applyFocus() + view := model.View(42, 7) + if !strings.Contains("\n"+view, "\n>") { + t.Fatalf("focused control %q is outside viewport:\n%s", target, view) + } + lines := strings.Split(view, "\n") + if len(lines) > 7 { + t.Fatalf("viewport height for %q = %d", target, len(lines)) + } + for _, line := range lines { + if ansi.StringWidth(line) > 42 { + t.Fatalf("viewport width for %q = %d: %q", target, ansi.StringWidth(line), line) + } + } + } + if model.scroll == 0 { + t.Fatal("long settings pane never scrolled") + } + model.focus = "ai:base" + model.applyFocus() + if view := model.View(42, 7); !strings.Contains(view, "> Base URL") { + t.Fatalf("viewport did not scroll back to AI focus:\n%s", view) + } +} + +func TestSettingsInputCursorAndHorizontalViewport(t *testing.T) { + input := settingsInput("placeholder", false) + input.SetValue("0123456789abcdef") + input.Focus() + for _, test := range []struct { + name string + position int + want string + }{ + {name: "start", position: 0, want: "|012345678"}, + {name: "middle", position: 5, want: "01234|5678"}, + {name: "end", position: 16, want: "789abcdef|"}, + } { + t.Run(test.name, func(t *testing.T) { + input.SetCursor(test.position) + if got := settingsInputDisplay(input, false, true, 10); got != test.want { + t.Fatalf("cursor projection at %d = %q, want %q", test.position, got, test.want) + } + }) + } + + input.CursorEnd() + end := settingsInputDisplay(input, false, true, 10) + input, _ = input.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + left := settingsInputDisplay(input, false, true, 10) + input, _ = input.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + right := settingsInputDisplay(input, false, true, 10) + if end != "789abcdef|" || left != "6789abcde|" || right != end { + t.Fatalf("horizontal cursor tracking = end:%q left:%q right:%q", end, left, right) + } + for _, view := range []string{end, left, right} { + if ansi.StringWidth(view) != 10 || !strings.Contains(view, "|") { + t.Fatalf("cursor viewport width/marker = %d/%q", ansi.StringWidth(view), view) + } + } +} + +func TestSettingsViewStripsTerminalControlsWithoutChangingValues(t *testing.T) { + hostile := "safe\x1b[31m-red\x1b[0m\x1b]2;owned\x07\x00\x9b31m" + secret := "token\x1b]52;c;stolen\x07\x9b2J" + backend := &faultSettingsStore{ + ai: store.AISettings{BaseURL: hostile, Model: hostile, HasKey: true}, + sources: []store.ForgeSource{{Name: hostile, Kind: hostile, BaseURL: hostile, HasToken: true}}, + } + aiProbe := &recordingAIProber{} + forgeProbe := &recordingForgeProber{} + model := newSettingsModelWithBackends(backend, aiProbe, forgeProbe, hostile, context.Background()) + loadSettingsForTest(t, model) + row := &model.rows[0] + model.aiKey.SetValue(secret) + row.project.SetValue(hostile) + row.token.SetValue(secret) + model.status = hostile + beforeAIBase, beforeAIKey := model.aiBase.Value(), model.aiKey.Value() + beforeName, beforeKind := row.name.Value(), row.kind + beforeBase, beforeProject, beforeToken := row.baseURL.Value(), row.project.Value(), row.token.Value() + + view := model.View(100, 30) + for _, r := range view { + if r == '\n' { + continue + } + if r <= 0x1f || (r >= 0x7f && r <= 0x9f) { + t.Fatalf("view contains terminal control U+%04X: %q", r, view) + } + } + if strings.Contains(view, "\x1b") || strings.Contains(view, secret) { + t.Fatalf("view contains escape or raw secret: %q", view) + } + if model.aiBase.Value() != beforeAIBase || model.aiKey.Value() != beforeAIKey || + row.name.Value() != beforeName || row.kind != beforeKind || row.baseURL.Value() != beforeBase || + row.project.Value() != beforeProject || row.token.Value() != beforeToken { + t.Fatalf("render sanitization changed values: aiBase=%q aiKey=%q name=%q kind=%q base=%q project=%q token=%q", + model.aiBase.Value(), model.aiKey.Value(), row.name.Value(), row.kind, + row.baseURL.Value(), row.project.Value(), row.token.Value()) + } + + runSettingsCommand(t, model, model.startAITest()) + if aiProbe.config.BaseURL != beforeAIBase || aiProbe.config.Model != model.aiModel.Value() || aiProbe.config.Key != beforeAIKey { + t.Fatalf("AI probe did not receive underlying values: %+v", aiProbe.config) + } + runSettingsCommand(t, model, model.startForgeTest(row)) + if forgeProbe.config.Name != beforeName || forgeProbe.config.Kind != beforeKind || + forgeProbe.config.BaseURL != beforeBase || forgeProbe.config.Project != beforeProject || + forgeProbe.config.Token != beforeToken { + t.Fatalf("forge probe did not receive underlying values: %+v", forgeProbe.config) + } +} + +func TestRootRoutesSettingsWithoutStoppingBoardPolling(t *testing.T) { + st := newSettingsTestStore(t) + direct := newSettingsModel(st, "alice", context.Background()) + if direct.store != st || direct.ai == nil || direct.forge == nil { + t.Fatal("production settings constructor did not wire direct backends") + } + settings := newSettingsModelWithBackends(st, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + root := NewModel(st, stubVersionReader{version: 2}, "alice") + root.settingsNew = func() *settingsModel { return settings } + command := updateTestModel(t, &root, tea.KeyPressMsg{Code: 's'}) + if root.settings == nil || command == nil { + t.Fatal("s did not open and load settings") + } + updateTestModel(t, &root, command()) + if !strings.Contains(root.View().Content, "AI SETTINGS") { + t.Fatalf("root did not render settings:\n%s", root.View().Content) + } + if poll := updateTestModel(t, &root, pollTickMsg{}); poll == nil { + t.Fatal("settings pane stopped the board poll chain") + } + updateTestModel(t, &root, tea.KeyPressMsg{Code: tea.KeyEscape}) + if root.settings != nil || !strings.Contains(root.View().Content, "kb / Board / alice") { + t.Fatal("escape did not return to board") + } +} + +func TestRootSettingsQuitAndClosedMessageRouting(t *testing.T) { + st := newSettingsTestStore(t) + settings := newSettingsModelWithBackends(st, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, settings) + if command := settings.startAITest(); command == nil || settings.testCancel == nil { + t.Fatal("settings test did not retain cancellable work") + } + root := NewModel(st, stubVersionReader{version: 1}, "alice") + root.settings = settings + root.reloadPending = true + quit := updateTestModel(t, &root, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + if quit == nil || !root.stopped || root.reloadPending || !settings.closed || settings.testCancel != nil { + t.Fatalf("ctrl+c settings quit = command:%v stopped:%v pending:%v closed:%v cancel:%v", + quit, root.stopped, root.reloadPending, settings.closed, settings.testCancel != nil) + } + + closed := newSettingsModelWithBackends(st, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + closed.closed = true + root = NewModel(st, stubVersionReader{version: 1}, "alice") + root.settings = closed + if command := updateTestModel(t, &root, settingsLoadedMsg{}); command != nil || root.settings != nil { + t.Fatalf("closed settings message = command:%v settings:%v", command, root.settings) + } +} + +func TestSettingsPaneGolden(t *testing.T) { + st := newSettingsTestStore(t) + base, modelName := "https://api.example/v1", "gpt-example" + if _, err := st.SetAISettings("alice", &base, &modelName, stringPointer(settingsStoredAIKey)); err != nil { + t.Fatal(err) + } + forgeBase := "https://gitlab.example" + if _, err := st.SetForgeSource("alice", "work", "gitlab", &forgeBase, stringPointer(settingsStoredPAT)); err != nil { + t.Fatal(err) + } + model := newSettingsModelWithBackends(st, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + model.focus = "ai:test" + model.applyFocus() + output := model.View(80, 30) + for _, secret := range []string{settingsStoredAIKey, settingsStoredPAT} { + if strings.Contains(output, secret) { + t.Fatalf("golden output contains persisted secret %q", secret) + } + } + golden.RequireEqual(t, output) +} + +func TestSettingsKeyboardAndFailureStateBranches(t *testing.T) { + loadFailure := errors.New("load failed") + broken := &faultSettingsStore{aiErr: loadFailure} + model := newSettingsModelWithBackends(broken, &recordingAIProber{}, &recordingForgeProber{}, "alice", nil) + model.Update(model.Init()()) + if model.loaded || !model.statusIsError || !strings.Contains(model.View(30, 6), "load failed") { + t.Fatalf("AI load failure = loaded:%v error:%v status:%q", model.loaded, model.statusIsError, model.status) + } + model.Close() // no active cancellation is also a supported close path. + + broken = &faultSettingsStore{ + ai: store.AISettings{BaseURL: "https://api.example", Model: "m"}, + forgeErr: loadFailure, + } + model = newSettingsModelWithBackends(broken, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + model.Update(model.Init()()) + if model.loaded || !strings.Contains(model.status, "load failed") { + t.Fatalf("forge load failure = loaded:%v status:%q", model.loaded, model.status) + } + + broken = &faultSettingsStore{ + ai: store.AISettings{BaseURL: "https://api.example", Model: "m"}, + sources: []store.ForgeSource{{ + Name: "saved", Kind: "gitlab", BaseURL: "https://forge.example", + }}, + } + aiProbe := &recordingAIProber{} + forgeProbe := &recordingForgeProber{} + model = newSettingsModelWithBackends(broken, aiProbe, forgeProbe, "alice", context.Background()) + loadSettingsForTest(t, model) + + // Every editable input is routed through the focused text input. The saved + // row's name is deliberately absent: it is immutable after save. + for _, target := range []string{"ai:base", "ai:model", "ai:key", "forge:source:saved:base", "forge:source:saved:project", "forge:source:saved:token"} { + model.focus = target + model.applyFocus() + model.Update(tea.KeyPressMsg(tea.Key{Code: 'z', Text: "z"})) + } + if !strings.HasSuffix(model.aiBase.Value(), "z") || !strings.HasSuffix(model.aiModel.Value(), "z") || + model.aiKey.Value() != "z" || !strings.HasSuffix(model.rows[0].baseURL.Value(), "z") || + model.rows[0].project.Value() != "z" || model.rows[0].token.Value() != "z" { + t.Fatalf("focused input routing failed: base=%q model=%q key=%q forge=%q project=%q token=%q", + model.aiBase.Value(), model.aiModel.Value(), model.aiKey.Value(), model.rows[0].baseURL.Value(), + model.rows[0].project.Value(), model.rows[0].token.Value()) + } + + model.focus = "forge:add" + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + draft := &model.rows[1] + model.focus = "forge:" + draft.id + ":kind" + model.Update(tea.KeyPressMsg{Code: tea.KeyLeft}) + if draft.kind != "github" { + t.Fatalf("left did not toggle kind: %q", draft.kind) + } + model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if draft.kind != "gitlab" { + t.Fatalf("enter did not toggle kind: %q", draft.kind) + } + model.Update(tea.KeyPressMsg{Code: tea.KeyRight}) + if draft.kind != "github" { + t.Fatalf("right did not toggle kind: %q", draft.kind) + } + for _, target := range []string{"forge:" + draft.id + ":name", "forge:" + draft.id + ":base", "forge:" + draft.id + ":project", "forge:" + draft.id + ":token"} { + model.focus = target + model.applyFocus() + model.Update(tea.KeyPressMsg(tea.Key{Code: 'x', Text: "x"})) + } + if draft.name.Value() != "x" || draft.baseURL.Value() != "x" || draft.project.Value() != "x" || draft.token.Value() != "x" { + t.Fatalf("draft input routing = %q/%q/%q/%q", draft.name.Value(), draft.baseURL.Value(), draft.project.Value(), draft.token.Value()) + } + + model.focus = "ai:test" + runSettingsCommand(t, model, model.activateFocus()) + model.focus = "forge:source:saved:test" + runSettingsCommand(t, model, model.activateFocus()) + if aiProbe.user != "alice" || forgeProbe.config.Name != "saved" { + t.Fatalf("action routing missed probes: AI=%q forge=%q", aiProbe.user, forgeProbe.config.Name) + } + + // Navigation wraps and both loaded and busy states ignore unrelated input. + model.focus = "ai:base" + model.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab, Mod: tea.ModShift})) + if model.focus != "forge:add" { + t.Fatalf("reverse focus wrap = %q", model.focus) + } + model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + if model.focus != "ai:base" { + t.Fatalf("forward focus wrap = %q", model.focus) + } + model.busy = "ai:save" + before := model.aiBase.Value() + model.Update(tea.KeyPressMsg{Code: 'q'}) + if model.aiBase.Value() != before || model.closed { + t.Fatal("busy settings accepted input or closed") + } + model.busy = "" + model.focus = "not:a:target" + model.applyFocus() + model.Update(tea.KeyPressMsg{Code: 'x'}) + model.Update(struct{}{}) + _ = model.View(1, 6) +} + +func TestSettingsSaveAndRemoveFailuresRemainRecoverable(t *testing.T) { + broken := &faultSettingsStore{ + ai: store.AISettings{BaseURL: "https://api.example", Model: "m"}, + setAIErr: errors.New("AI write failed"), + setForgeErr: errors.New("forge write failed"), + sources: []store.ForgeSource{ + {Name: "one", Kind: "gitlab", BaseURL: "https://one.example"}, + {Name: "two", Kind: "github", BaseURL: "https://two.example"}, + }, + } + model := newSettingsModelWithBackends(broken, &recordingAIProber{}, &recordingForgeProber{}, "alice", context.Background()) + loadSettingsForTest(t, model) + + model.aiBase.SetValue("not a URL") + if command := model.startAISave(); command != nil || !model.statusIsError { + t.Fatalf("invalid AI URL = command:%v status:%q", command, model.status) + } + model.aiBase.SetValue("https://api.example") + model.aiKey.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startAISave()) + if !model.statusIsError || !strings.Contains(model.status, "AI write failed") || model.aiKey.Value() == "" { + t.Fatalf("AI save failure = error:%v status:%q key-cleared:%v", model.statusIsError, model.status, model.aiKey.Value() == "") + } + + model.addForgeDraft() + draft := &model.rows[len(model.rows)-1] + if command := model.startForgeSave(draft); command != nil || !strings.Contains(model.status, "name") { + t.Fatalf("missing forge name = command:%v status:%q", command, model.status) + } + draft.name.SetValue("one") + draft.baseURL.SetValue("https://draft.example") + if command := model.startForgeSave(draft); command != nil || !strings.Contains(model.status, "exists") { + t.Fatalf("duplicate forge name = command:%v status:%q", command, model.status) + } + draft.name.SetValue("draft") + draft.baseURL.SetValue("") + if command := model.startForgeSave(draft); command != nil || !strings.Contains(model.status, "base URL") { + t.Fatalf("missing forge base = command:%v status:%q", command, model.status) + } + draft.baseURL.SetValue("https://draft.example") + draft.token.SetValue(settingsUnsavedSecret) + runSettingsCommand(t, model, model.startForgeSave(draft)) + if !model.statusIsError || !strings.Contains(model.status, "forge write failed") || draft.token.Value() == "" { + t.Fatalf("forge save failure = error:%v status:%q token-cleared:%v", model.statusIsError, model.status, draft.token.Value() == "") + } + + row := model.rowByID("source:one") + rowID := row.id + broken.deleteForgeErr = errors.New("delete failed") + model.armedRemove = row.id + runSettingsCommand(t, model, model.startForgeRemove(row)) + if model.rowByID(rowID) == nil || !model.statusIsError || !strings.Contains(model.status, "delete failed") { + t.Fatalf("remove failure lost row or status: row=%v status=%q", model.rowByID(rowID), model.status) + } + broken.deleteForgeErr = nil + row = model.rowByID(rowID) + model.armedRemove = rowID + runSettingsCommand(t, model, model.startForgeRemove(row)) + if model.rowByID(rowID) != nil { + t.Fatalf("successful retry retained row: busy=%q status=%q armed=%q", model.busy, model.status, model.armedRemove) + } + + // Matching late messages for rows already removed are harmless. + model.busy = "forge:save:missing" + model.Update(forgeSettingsSavedMsg{id: "missing"}) + model.busy = "forge:remove:missing" + model.Update(forgeSettingsRemovedMsg{id: "missing", err: errors.New("late failure")}) + if valueOrEmpty(nil) != "" || safeSettingsError(errors.New(" ")) != "operation failed" { + t.Fatal("empty helper fallbacks changed") + } +} + +func stringPointer(value string) *string { return &value } + +func TestSafeSettingsErrorBoundsAndNormalizes(t *testing.T) { + message := settingsUnsavedSecret + "\n" + strings.Repeat("x", 300) + got := safeSettingsError(errors.New(message), settingsUnsavedSecret) + if strings.Contains(got, settingsUnsavedSecret) || strings.ContainsAny(got, "\r\n") || len([]rune(got)) > 160 { + t.Fatalf("unsafe bounded error = %q", got) + } + if got := safeSettingsError(nil); got != "" { + t.Fatalf("nil error = %q", got) + } + if settingsTestTimeout <= 0 { + t.Fatal("test timeout must be positive") + } +} diff --git a/internal/tui/settings_view.go b/internal/tui/settings_view.go new file mode 100644 index 0000000..9e55f43 --- /dev/null +++ b/internal/tui/settings_view.go @@ -0,0 +1,218 @@ +package tui + +import ( + "strings" + "unicode/utf8" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" +) + +func (m *settingsModel) View(width, height int) string { + width = max(width, 1) + height = max(height, 3) + inputWidth := max(width-18, 8) + m.aiBase.SetWidth(inputWidth) + m.aiModel.SetWidth(inputWidth) + m.aiKey.SetWidth(inputWidth) + for i := range m.rows { + m.rows[i].name.SetWidth(inputWidth) + m.rows[i].baseURL.SetWidth(inputWidth) + m.rows[i].project.SetWidth(inputWidth) + m.rows[i].token.SetWidth(inputWidth) + } + + header := settingsFit("kb / settings / "+m.user, width) + body := []string{"", "AI SETTINGS"} + if !m.loaded { + body = append(body, "loading settings...") + } else { + body = append(body, + m.inputModelLine("ai:base", "Base URL", m.aiBase, false, width), + m.inputModelLine("ai:model", "Model", m.aiModel, false, width), + m.inputModelLine("ai:key", keyLabel("API key", m.hasKey), m.aiKey, true, width), + m.actionLine("ai:test", "Test connection", width), + m.actionLine("ai:save", "Save AI settings", width), + "", "FORGE INTEGRATIONS", + ) + if len(m.rows) == 0 { + body = append(body, "(none configured)") + } + for i := range m.rows { + body = append(body, m.renderForgeRow(&m.rows[i], width)...) + } + body = append(body, m.actionLine("forge:add", "+ Add integration", width)) + } + status := m.status + if status == "" { + status = "ready" + } + if m.statusIsError { + status = "error: " + status + } + footer := settingsFit(status+" | tab navigate | enter act | esc back", width) + + bodyHeight := height - 2 + focusLine := -1 + for i, line := range body { + if strings.HasPrefix(line, ">") { + focusLine = i + break + } + } + maxScroll := max(len(body)-bodyHeight, 0) + if focusLine >= 0 && focusLine < m.scroll { + m.scroll = focusLine + } + if focusLine >= m.scroll+bodyHeight { + m.scroll = focusLine - bodyHeight + 1 + } + m.scroll = min(max(m.scroll, 0), maxScroll) + end := min(m.scroll+bodyHeight, len(body)) + visible := body[m.scroll:end] + lines := make([]string, 0, height) + lines = append(lines, header) + for _, line := range visible { + lines = append(lines, settingsFit(line, width)) + } + lines = append(lines, footer) + return strings.Join(lines, "\n") +} + +func (m *settingsModel) renderForgeRow(row *integrationSettingsRow, width int) []string { + prefix := "forge:" + row.id + ":" + marker := "new" + if row.persisted { + marker = "saved" + } + lines := []string{"", settingsFit("-- "+row.name.Value()+" ("+marker+") --", width)} + if row.persisted { + lines = append(lines, + settingsFit(" Name: "+row.name.Value()+" (locked)", width), + settingsFit(" Kind: "+row.kind+" (locked)", width), + ) + } else { + lines = append(lines, + m.inputLine(prefix+"kind", "Kind", row.kind, width), + m.inputModelLine(prefix+"name", "Name", row.name, false, width), + ) + } + lines = append(lines, + m.inputModelLine(prefix+"base", "Base URL", row.baseURL, false, width), + m.inputModelLine(prefix+"project", "Project", row.project, false, width), + m.inputModelLine(prefix+"token", keyLabel("Token", row.hasToken), row.token, true, width), + m.actionLine(prefix+"test", "Test", width), + ) + remove := "Remove" + if m.armedRemove == row.id { + remove = "Confirm remove" + } + return append(lines, + m.actionLine(prefix+"save", "Save", width), + m.actionLine(prefix+"remove", remove, width), + ) +} + +func settingsInputDisplay(input textinput.Model, secret, focused bool, width int) string { + value := input.Value() + if value == "" { + value = input.Placeholder + } + raw := []rune(value) + position := min(max(input.Position(), 0), len(raw)) + safe := sanitizeTerminal(value) + safePosition := utf8.RuneCountInString(sanitizeTerminal(string(raw[:position]))) + safePosition = min(safePosition, utf8.RuneCountInString(safe)) + if secret && input.Value() != "" { + safe = strings.Repeat("*", max(utf8.RuneCountInString(safe), 1)) + safePosition = min(safePosition, utf8.RuneCountInString(safe)) + } + if !focused { + return ansi.Truncate(safe, max(width, 0), "") + } + return settingsCursorViewport(safe, safePosition, width) +} + +func settingsCursorViewport(value string, position, width int) string { + if width <= 0 { + return "" + } + const cursor = "|" + if width == 1 { + return cursor + } + runes := []rune(value) + position = min(max(position, 0), len(runes)) + before, after := string(runes[:position]), string(runes[position:]) + contentWidth := width - 1 + cursorColumn := ansi.StringWidth(before) + left := max(cursorColumn-contentWidth, 0) + visibleBefore := ansi.Cut(before, left, cursorColumn) + remaining := max(contentWidth-ansi.StringWidth(visibleBefore), 0) + visibleAfter := ansi.Truncate(after, remaining, "") + return visibleBefore + cursor + visibleAfter +} + +func keyLabel(label string, saved bool) string { + if saved { + return label + " (saved)" + } + return label +} + +func (m *settingsModel) inputLine(target, label, value string, width int) string { + marker := " " + if m.focus == target { + marker = "> " + } + return settingsFit(marker+label+": "+value, width) +} + +func (m *settingsModel) inputModelLine( + target, label string, + input textinput.Model, + secret bool, + width int, +) string { + marker := " " + if m.focus == target { + marker = "> " + } + prefix := marker + label + ": " + available := max(width-ansi.StringWidth(prefix), 1) + value := settingsInputDisplay(input, secret, m.focus == target, available) + return settingsFit(prefix+value, width) +} + +func (m *settingsModel) actionLine(target, label string, width int) string { + marker := " " + if m.focus == target { + marker = "> " + } + return settingsFit(marker+"["+label+"]", width) +} + +func settingsFit(line string, width int) string { + return ansi.Truncate(sanitizeTerminal(line), max(width, 0), "") +} + +func sanitizeTerminal(value string) string { + value = ansi.Strip(value) + return strings.Map(func(r rune) rune { + if r <= 0x1f || (r >= 0x7f && r <= 0x9f) { + return -1 + } + return r + }, value) +} + +func isSettingsMessage(message tea.Msg) bool { + switch message.(type) { + case settingsLoadedMsg, aiSettingsTestedMsg, aiSettingsSavedMsg, + forgeSettingsTestedMsg, forgeSettingsSavedMsg, forgeSettingsRemovedMsg: + return true + default: + return false + } +} diff --git a/internal/tui/testdata/TestSettingsPaneGolden.golden b/internal/tui/testdata/TestSettingsPaneGolden.golden new file mode 100644 index 0000000..951ed7e --- /dev/null +++ b/internal/tui/testdata/TestSettingsPaneGolden.golden @@ -0,0 +1,22 @@ +kb / settings / alice + +AI SETTINGS + Base URL: https://api.example/v1 + Model: gpt-example + API key (saved): blank keeps saved key +> [Test connection] + [Save AI settings] + +FORGE INTEGRATIONS + +-- work (saved) -- + Name: work (locked) + Kind: gitlab (locked) + Base URL: https://gitlab.example + Project: owner/project (optional) + Token (saved): blank keeps saved token + [Test] + [Save] + [Remove] + [+ Add integration] +ready | tab navigate | enter act | esc back \ No newline at end of file