diff --git a/CHANGELOG.md b/CHANGELOG.md index e2aea0a5..3d44feae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,30 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed +- `phi config` is now a full-screen terminal editor (`internal/tui/configui`) + instead of a loopback web page. The form edits the same keys with inline text + fields, choice pickers (model list, thinking level, api, permission mode), + tri-state toggles, and expandable bash allow/deny rule lists. Nothing is + written until a save, and the previous file is kept as `config.yaml.bak`. +- The editable config schema moved to `internal/project` (`project.ConfigDoc`, + `ReadConfigDoc`, `Save`, `Validate`). The runtime parser and the editor now + share one definition instead of two hand-kept copies, and empty `api_key` / + `base_url` values are omitted when a file is written. +- The `phi config` form gives its two columns one job each: labels and values the + file does not set share a single quiet tone, real values are body text, and + state (`on`, `default`, `not set`) is the only thing that spends a color. The + label column no longer relies on `Dim`, which terminals render anywhere from + soft to invisible, and a filled `api_key` now reads as data instead of as an + empty field. + ### Deprecated ### Removed +- The embedded HTML config page and its `/api/config` and `/api/models` + endpoints, along with the local HTTP server, origin checks, and browser + launch that served them. + ### Fixed - `diff` pane notes no longer persist. `i` / `x` / `a` keep working, but drafts @@ -28,6 +48,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm file-operation lists are refreshed once instead of accumulating duplicate blocks. - Picker hints (`/` commands, `@` files, `?` shortcuts) now start in one shared column instead of trailing each label's own length. +- `phi config` model headers now sit on the same grid as every other row: the + model name owns the label column (truncated when it does not fit) and the + `default` badge plus the api/context summary share the value column. A long + name used to reach into the summary and eat the badge's last letter. + +- The `phi config` confirm modal read its arrows backwards: `↑`/`←` selected No + and `↓`/`→` selected Yes, so pressing a key in the visible direction looked + dead. The keys now pick the row above / below like every other decision + panel, and the footer says `↑↓ move` instead of pointing at a horizontal axis. ### Security diff --git a/README.md b/README.md index ac5dffa6..9c60c711 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ A lean, high-performance terminal coding agent harness in Go — a sibling to Pi ![phi diff review](assets/diff.png) +![phi config viewer](assets/phi-config.png) + - [Docs](https://pulseaiclub.github.io/docs/getting-started/) - [Quick start](#quick-start) - [Footprint](#footprint) @@ -136,10 +138,20 @@ figures. ## Configuration phi reads `~/.phi/config.yaml` (standard YAML). Environment variables -override it for one-off runs. `phi config` opens an HTML editor for the same -file in your browser. +override it for one-off runs. `phi config` opens a full-screen terminal editor +for the same file — nothing is written until you save, and the previous file is +kept as `config.yaml.bak`. + +``` +phi config keys + ↑↓ ←→ move / cycle a value a add a model + ⏎ edit or open a picker d delete the focused row + esc close, or quit f fetch model ids from the provider + ^s save s save q quit +``` -![phi config](assets/config.png) +An empty field means "not set", i.e. the value the loader would fill in; the +placeholder shows that value. ```yaml # ~/.phi/config.yaml diff --git a/README.zh-CN.md b/README.zh-CN.md index d0e1eccd..b5b30018 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -136,9 +136,18 @@ TUI 给模型提供四个核心工具——`read`、`write`、`edit` 和 `bash` ## 配置 phi 读取 `~/.phi/config.yaml`(标准 YAML)。环境变量可覆盖配置,用于一次性运行。 -`phi config` 会在浏览器中打开一个 HTML 编辑器来编辑同一个文件。 +`phi config` 会在终端里打开全屏编辑器来编辑同一个文件;不保存就不落盘,保存前 +的旧文件会留作 `config.yaml.bak`。 -![phi config](assets/config.png) +``` +phi config 按键 + ↑↓ ←→ 移动 / 切换取值 a 新增模型 + ⏎ 编辑,或打开选择列表 d 删除当前行 + esc 收起列表 / 退出 f 从服务商拉取模型列表 + ^s 保存 s 保存 q 退出 +``` + +留空表示「未设置」,也就是交给加载器填默认值;占位符显示的就是那个值。 ```yaml # ~/.phi/config.yaml diff --git a/assets/config.png b/assets/config.png deleted file mode 100644 index df4d59c7..00000000 Binary files a/assets/config.png and /dev/null differ diff --git a/assets/phi-config.png b/assets/phi-config.png new file mode 100644 index 00000000..2beeab43 Binary files /dev/null and b/assets/phi-config.png differ diff --git a/cmd/config.go b/cmd/config.go index 0198df27..cbd6e0a9 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,89 +2,29 @@ package main import ( "context" - _ "embed" "encoding/json" "errors" "fmt" "io" - "mime" - "net" "net/http" "net/url" - "os" - "os/exec" - "runtime" "sort" "strings" "time" - "gopkg.in/yaml.v3" - "github.com/pulseaiclub/phi/internal/util" ) -//go:embed config.html -var configHTML []byte - -// configDoc is the document served to / accepted from the config editor. -// The yaml tags mirror internal/project's fileConfig so the page round-trips -// config.yaml losslessly for every key the parser understands; the json tags -// drive the editor API. Pointer fields preserve "key absent" across saves so -// untouched sections are never rewritten. -type configDoc struct { - Path string `yaml:"-" json:"path,omitempty"` - Models []modelDoc `yaml:"models" json:"models"` - SkillPath *string `yaml:"skill_path,omitempty" json:"skillPath,omitempty"` - Permissions *permDoc `yaml:"permissions,omitempty" json:"permissions,omitempty"` - Agents *agentsDoc `yaml:"agents,omitempty" json:"agents,omitempty"` -} - -type modelDoc struct { - Name string `yaml:"name" json:"name"` - APIKey string `yaml:"api_key" json:"apiKey"` - BaseURL string `yaml:"base_url" json:"baseUrl"` - ContextWindow *int `yaml:"context_window,omitempty" json:"contextWindow,omitempty"` - // ImageEnabled is a pointer so the editor can omit the key until the user - // toggles it (absent vs false). Runtime parse treats absence as false. - ImageEnabled *bool `yaml:"image_enabled,omitempty" json:"imageEnabled,omitempty"` - API string `yaml:"api,omitempty" json:"api,omitempty"` // OpenAI | OpenAIResponses | Anthropic | Gemini - ThinkEnabled *bool `yaml:"think_enabled,omitempty" json:"thinkEnabled,omitempty"` - ThinkLevel string `yaml:"think_level,omitempty" json:"thinkLevel,omitempty"` - Default bool `yaml:"default,omitempty" json:"default"` -} - -type permDoc struct { - Mode *string `yaml:"mode,omitempty" json:"mode,omitempty"` - WorkspaceOnlyWrites *bool `yaml:"workspace_only_writes,omitempty" json:"workspaceOnlyWrites,omitempty"` - AskTimeoutSec *int `yaml:"ask_timeout_sec,omitempty" json:"askTimeoutSec,omitempty"` - DangerouslyAllowAll *bool `yaml:"dangerously_allow_all,omitempty" json:"dangerouslyAllowAll,omitempty"` - Bash *bashDoc `yaml:"bash,omitempty" json:"bash,omitempty"` -} - -type bashDoc struct { - Default *string `yaml:"default,omitempty" json:"default,omitempty"` - Allow []string `yaml:"allow" json:"allow,omitempty"` - Deny []string `yaml:"deny" json:"deny,omitempty"` -} - -type agentsDoc struct { - // Enabled is a pointer so omitting the key in YAML/JSON keeps default-on - // when only agents.models is set. - Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` - Models *agentsModelsDoc `yaml:"models,omitempty" json:"models,omitempty"` -} - -type agentsModelsDoc struct { - Explore string `yaml:"explore,omitempty" json:"explore,omitempty"` - Review string `yaml:"review,omitempty" json:"review,omitempty"` - Worker string `yaml:"worker,omitempty" json:"worker,omitempty"` -} +const ( + defaultOpenAIBaseURL = "https://api.openai.com/v1" + anthropicAPIVersion = "2023-06-01" + modelListTimeout = 15 * time.Second + modelListBodyLimit = int64(4 << 20) +) -type modelListRequest struct { - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - Model string `json:"model"` - API string `json:"api"` // OpenAI | OpenAIResponses | Anthropic | Gemini; empty falls back to legacy heuristics +type modelListResponse struct { + Data []modelListItem `json:"data"` + Models []modelListItem `json:"models"` } type modelListItem struct { @@ -93,97 +33,12 @@ type modelListItem struct { DisplayName string `json:"display_name"` } -type modelListResponse struct { - Data []modelListItem `json:"data"` - Models []modelListItem `json:"models"` -} - -const ( - defaultOpenAIBaseURL = "https://api.openai.com/v1" - anthropicAPIVersion = "2023-06-01" - modelListRequestLimit = 15 * time.Second - modelListBodyLimit = int64(4 << 20) -) - -// configHandler serves the embedded editor page and its /api/config endpoints. -type configHandler struct { - configPath string -} - -func (h *configHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if (r.URL.Path == "/api/config" || r.URL.Path == "/api/models") && !isLoopbackHost(r.Host) { - writeConfigErr(w, http.StatusForbidden, errors.New("request origin is not allowed")) - return - } - - switch r.URL.Path { - case "/": - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write(configHTML) - case "/api/config": - h.handleConfig(w, r) - case "/api/models": - h.handleModels(w, r) - default: - http.NotFound(w, r) - } -} - -func (h *configHandler) handleConfig(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - doc, err := readConfigDoc(h.configPath) - if err != nil { - writeConfigErr(w, http.StatusInternalServerError, err) - return - } - doc.Path = h.configPath - writeConfigJSON(w, doc) - case http.MethodPost: - if status, err := validateLocalJSONRequest(r); err != nil { - writeConfigErr(w, status, err) - return - } - var doc configDoc - if err := json.NewDecoder(r.Body).Decode(&doc); err != nil { - writeConfigErr(w, http.StatusBadRequest, fmt.Errorf("bad request: %w", err)) - return - } - if err := validateConfigDoc(&doc); err != nil { - writeConfigErr(w, http.StatusBadRequest, err) - return - } - if err := writeConfigDoc(h.configPath, &doc); err != nil { - writeConfigErr(w, http.StatusInternalServerError, err) - return - } - writeConfigJSON(w, map[string]string{"status": "saved"}) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -// handleModels fetches model IDs through the local config server so the page -// does not need cross-origin access to a provider API. -func (*configHandler) handleModels(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if status, err := validateLocalJSONRequest(r); err != nil { - writeConfigErr(w, status, err) - return - } - - var input modelListRequest - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - writeConfigErr(w, http.StatusBadRequest, fmt.Errorf("bad request: %w", err)) - return - } - - baseURL := strings.TrimSpace(input.BaseURL) - apiKey := strings.TrimSpace(input.APIKey) - anthropic := isAnthropicAPI(input.API, baseURL, input.Model) +// fetchModelIDs asks a provider which models it advertises, so `phi config` can +// fill a model name from a list instead of a typed-in guess. It runs on a +// background goroutine owned by the config overlay. +func fetchModelIDs(ctx context.Context, baseURL, apiKey, api, model string) ([]string, error) { + baseURL = strings.TrimSpace(baseURL) + anthropic := isAnthropicAPI(api, baseURL, model) if baseURL == "" { if anthropic { baseURL = "https://api.anthropic.com" @@ -193,40 +48,35 @@ func (*configHandler) handleModels(w http.ResponseWriter, r *http.Request) { } endpoint, err := modelListEndpoint(baseURL, anthropic) if err != nil { - writeConfigErr(w, http.StatusBadRequest, err) - return + return nil, err } - ctx, cancel := context.WithTimeout(r.Context(), modelListRequestLimit) + ctx, cancel := context.WithTimeout(ctx, modelListTimeout) defer cancel() request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody) if err != nil { - writeConfigErr(w, http.StatusBadRequest, fmt.Errorf("build model list request: %w", err)) - return + return nil, fmt.Errorf("build model list request: %w", err) } request.Header.Set("Accept", "application/json") if anthropic { - request.Header.Set("X-Api-Key", apiKey) + request.Header.Set("X-Api-Key", strings.TrimSpace(apiKey)) request.Header.Set("Anthropic-Version", anthropicAPIVersion) } else { - request.Header.Set("Authorization", "Bearer "+apiKey) + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(apiKey)) } response, err := modelListHTTPClient().Do(request) if err != nil { - writeConfigErr(w, http.StatusBadGateway, fmt.Errorf("fetch model list: %w", err)) - return + return nil, fmt.Errorf("fetch model list: %w", err) } defer response.Body.Close() body, err := io.ReadAll(io.LimitReader(response.Body, modelListBodyLimit+1)) if err != nil { - writeConfigErr(w, http.StatusBadGateway, fmt.Errorf("read model list: %w", err)) - return + return nil, fmt.Errorf("read model list: %w", err) } if int64(len(body)) > modelListBodyLimit { - writeConfigErr(w, http.StatusBadGateway, errors.New("model list response is too large")) - return + return nil, errors.New("model list response is too large") } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { message := strings.TrimSpace(string(body)) @@ -236,22 +86,21 @@ func (*configHandler) handleModels(w http.ResponseWriter, r *http.Request) { if message == "" { message = response.Status } - writeConfigErr(w, http.StatusBadGateway, fmt.Errorf("model list request failed: %s", message)) - return + return nil, fmt.Errorf("model list request failed: %s", message) } var payload modelListResponse if err := json.Unmarshal(body, &payload); err != nil { - writeConfigErr(w, http.StatusBadGateway, fmt.Errorf("decode model list: %w", err)) - return + return nil, fmt.Errorf("decode model list: %w", err) } models := collectModelIDs(append(payload.Data, payload.Models...)) sort.Strings(models) - writeConfigJSON(w, struct { - Models []string `json:"models"` - }{Models: models}) + return models, nil } +// modelListHTTPClient is the shared client with one restriction on top: a +// redirect must stay on the same origin, so a provider cannot bounce the API +// key to another host. func modelListHTTPClient() *http.Client { client := *util.DefaultHTTPClient() client.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -275,11 +124,14 @@ func isAnthropicAPI(api, baseURL, model string) bool { case "OpenAI", "OpenAIResponses", "Gemini": return false } - // Empty api: keep legacy heuristics so older editor sessions still fetch. + // Empty api: keep legacy heuristics so an entry with only claude-ish values + // still probes the Anthropic endpoint. return strings.Contains(strings.ToLower(baseURL), "anthropic") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "claude") } +// modelListEndpoint resolves the /models URL for a base URL, tolerating a base +// that already ends in /models or /v1. func modelListEndpoint(baseURL string, anthropic bool) (string, error) { u, err := url.Parse(strings.TrimSpace(baseURL)) if err != nil || u.Scheme == "" || u.Host == "" { @@ -325,130 +177,3 @@ func collectModelIDs(items []modelListItem) []string { } return models } - -// validateLocalJSONRequest requires browser POSTs to use a non-simple content -// type and come from the config page's own origin. ServeHTTP separately checks -// that every API request uses a loopback Host. -func validateLocalJSONRequest(r *http.Request) (int, error) { - mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) - if err != nil || mediaType != "application/json" { - return http.StatusUnsupportedMediaType, errors.New("content type must be application/json") - } - origin := r.Header.Get("Origin") - originURL, err := url.Parse(origin) - if err != nil || origin == "" || originURL.Scheme == "" || originURL.Host == "" || - originURL.User != nil || originURL.Path != "" || originURL.RawQuery != "" || originURL.Fragment != "" { - return http.StatusForbidden, errors.New("request origin is not allowed") - } - - expectedScheme := "http" - if r.TLS != nil { - expectedScheme = "https" - } - if !strings.EqualFold(originURL.Scheme, expectedScheme) || !strings.EqualFold(originURL.Host, r.Host) { - return http.StatusForbidden, errors.New("request origin is not allowed") - } - return 0, nil -} - -func isLoopbackHost(rawHost string) bool { - u, err := url.Parse("http://" + rawHost) - if err != nil || u.Host != rawHost || u.User != nil || u.Path != "" { - return false - } - hostname := u.Hostname() - if strings.EqualFold(hostname, "localhost") { - return true - } - ip := net.ParseIP(hostname) - return ip != nil && ip.IsLoopback() -} - -// readConfigDoc loads the current config file into the editor document. A -// missing file yields an empty document so the page can bootstrap a config. -func readConfigDoc(path string) (*configDoc, error) { - doc := &configDoc{} - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return doc, nil - } - return nil, err - } - if err := yaml.Unmarshal(data, doc); err != nil { - return nil, err - } - return doc, nil -} - -func validateConfigDoc(doc *configDoc) error { - if len(doc.Models) == 0 { - return errors.New("at least one model is required") - } - hasDefault := false - for i := range doc.Models { - m := &doc.Models[i] - if m.Name == "" { - return fmt.Errorf("model %d has no name", i+1) - } - if !m.Default { - continue - } - if hasDefault { - return errors.New("only one model may be marked default") - } - hasDefault = true - if m.APIKey == "" { - return fmt.Errorf("default model %q is missing api_key", m.Name) - } - } - if !hasDefault { - doc.Models[0].Default = true - if doc.Models[0].APIKey == "" { - return fmt.Errorf("default model %q is missing api_key", doc.Models[0].Name) - } - } - return nil -} - -// writeConfigDoc backs up the current file and writes the document as YAML. -func writeConfigDoc(path string, doc *configDoc) error { - data, err := yaml.Marshal(doc) - if err != nil { - return err - } - if cur, err := os.ReadFile(path); err == nil { - //nolint:gosec // G306: config backup stays user-readable - if err := os.WriteFile(path+".bak", cur, 0o644); err != nil { - return fmt.Errorf("backup config: %w", err) - } - } - return os.WriteFile(path, data, 0o644) //nolint:gosec // G306: config.yaml is meant to be user-readable -} - -func writeConfigJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func writeConfigErr(w http.ResponseWriter, status int, err error) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) -} - -// openBrowser best-effort opens the editor URL in the default browser. -func openBrowser(ctx context.Context, pageURL string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.CommandContext(ctx, "open", pageURL) - case "linux": - cmd = exec.CommandContext(ctx, "xdg-open", pageURL) - default: - return - } - _ = cmd.Start() -} diff --git a/cmd/config.html b/cmd/config.html deleted file mode 100644 index 6242ad0e..00000000 --- a/cmd/config.html +++ /dev/null @@ -1,1866 +0,0 @@ - - - - - -phi config - - - -
- - -
-
-
-
-
-

Models

-
0 models
-
-

Keep at least one model; only one can be the startup model.

-
- -
-
-
- -
-
-
-
-
General
-

Choose where skill files are loaded from.

-
-
-
-
- -
Reads SKILL.md files inside; leave empty for the default path.
- -
-
-
- -
-
-
-
Permission mode
-

Controls how the agent runs commands, reads or writes files, and makes network requests.

-
-
-
-
- -
Different modes change which actions require confirmation.
- -

-
-
- -
How long permission prompts wait before timing out.
- -
-
-
- - -
-
-
- -
-
-
-

Tool rules

-

Further limit the agent by command and destination host.

-
-
-
-
- - -
-
-
-
- - -
- - - - diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go index b46d5975..c7f593de 100644 --- a/cmd/config_cmd.go +++ b/cmd/config_cmd.go @@ -1,61 +1,63 @@ package main import ( - "context" - "errors" "fmt" - "net" - "net/http" "os" - "os/signal" - "time" + + "github.com/pulseaiclub/xui" cli "github.com/pulseaiclub/pli" + "github.com/pulseaiclub/phi/internal/components" + "github.com/pulseaiclub/phi/internal/components/app" "github.com/pulseaiclub/phi/internal/project" + "github.com/pulseaiclub/phi/internal/tui/configui" ) var configCommand = cli.Command{ Name: "config", - Desc: "open the HTML config editor (local web server)", - Long: "Open the HTML config editor (starts a local web server on 127.0.0.1).", + Desc: "edit the meta config in a terminal UI", + Long: `Edit the meta config in a full-screen terminal UI (models, skills, +permissions, sub-agents). Changes are written only on save; the previous file +is kept next to it as config.yaml.bak.`, Run: func(_ []string, _ cli.Flags) error { return runConfigEditor() }, } -// runConfigEditor starts a local web server (loopback only) that edits -// config.yaml in the browser. +// runConfigEditor opens ~/.phi/config.yaml in the config form. func runConfigEditor() error { proj := project.GetDefaultProject() + path := proj.Global().ConfigFile() - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - - var lc net.ListenConfig - ln, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + doc, err := project.ReadConfigDoc(path) if err != nil { return err } - addr := ln.Addr().(*net.TCPAddr) - pageURL := fmt.Sprintf("http://127.0.0.1:%d/", addr.Port) - fmt.Fprintf(os.Stderr, "phi config: %s\n config: %s\n Ctrl-C to stop\n", pageURL, proj.Global().ConfigFile()) - openBrowser(ctx, pageURL) - - srv := &http.Server{ - Handler: &configHandler{configPath: proj.Global().ConfigFile()}, - ReadHeaderTimeout: 10 * time.Second, - } - errc := make(chan error, 1) - go func() { errc <- srv.Serve(ln) }() - select { - case err := <-errc: - if err != nil && !errors.Is(err, http.ErrServerClosed) { - return err + vx, err := xui.New(xui.Options{Mouse: true, BracketedPaste: true}) + if err != nil { + fmt.Fprintln(os.Stderr, "phi: terminal UI:", err) + return exitCode(ExitError) + } + defer func(vx *xui.XUI) { + if err := vx.Close(); err != nil { + fmt.Fprintln(os.Stderr, "phi: close terminal:", err) } - case <-ctx.Done(): - _ = srv.Close() + }(vx) + + application := app.NewApp(vx) + ui := configui.New( + doc, + path, + proj.Global().SkillsDir(), + components.DefaultTheme(), + fetchModelIDs, + application.RequestRedraw, + ) + if err := application.Run(ui); err != nil { + fmt.Fprintln(os.Stderr, "phi:", err) + return exitCode(ExitError) } return nil } diff --git a/cmd/config_test.go b/cmd/config_test.go index b5d13874..6308e1c8 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -1,184 +1,26 @@ package main import ( - "context" "encoding/json" - "fmt" - "io" "net/http" "net/http/httptest" - "os" - "path/filepath" - "strings" "sync/atomic" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/pulseaiclub/phi/internal/project" ) -const configUIFixture = `models: - - name: model-a - api_key: key-a - base_url: https://a.example/v1 - context_window: 1000 - image_enabled: true - default: true - - name: model-b - api_key: key-b - base_url: https://b.example/v1 -permissions: - mode: readonly - dangerously_allow_all: true - bash: - default: ask - allow: - - "^git " -agents: - enabled: false -` - -func TestConfigHandlerGETAndRoundTrip(t *testing.T) { - home := t.TempDir() - phiDir := filepath.Join(home, ".phi") - require.NoError(t, os.MkdirAll(phiDir, 0o755)) - path := filepath.Join(phiDir, "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(configUIFixture), 0o644)) - - h := &configHandler{configPath: path} - - // GET serves the current document. - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newLocalAPIRequest(http.MethodGet, "/api/config", nil)) - require.Equal(t, http.StatusOK, rr.Code) - var got configDoc - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) - require.Len(t, got.Models, 2) - assert.Equal(t, "model-a", got.Models[0].Name) - assert.True(t, got.Models[0].Default) - require.NotNil(t, got.Models[0].ImageEnabled) - assert.True(t, *got.Models[0].ImageEnabled) - require.NotNil(t, got.Permissions) - require.NotNil(t, got.Permissions.Bash) - assert.Equal(t, []string{"^git "}, got.Permissions.Bash.Allow) - require.NotNil(t, got.Agents) - require.NotNil(t, got.Agents.Enabled) - assert.False(t, *got.Agents.Enabled) - assert.Equal(t, path, got.Path) - - // Edit: drop model-b and change the api_key, keep permissions untouched. - got.Models = got.Models[:1] - got.Models[0].APIKey = "new-key" - body, err := json.Marshal(got) - require.NoError(t, err) - - rr = httptest.NewRecorder() - h.ServeHTTP(rr, newJSONAPIRequest("/api/config", strings.NewReader(string(body)))) - require.Equal(t, http.StatusOK, rr.Code) - - // The app must be able to load the written file with the same results. - // os.UserHomeDir uses HOME on Unix and USERPROFILE on Windows. - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - p, err := project.Discover("") - require.NoError(t, err) - require.NoError(t, p.LoadConfig()) - cfg := p.Config() - require.Len(t, cfg.Models, 1) - assert.Equal(t, "model-a", cfg.Model().Name) - assert.Equal(t, "new-key", cfg.Model().APIKey) - assert.True(t, cfg.Model().ImageEnabled) - assert.Equal(t, "readonly", string(cfg.Permissions.Mode)) - assert.True(t, cfg.Permissions.DangerouslyAllowAll) - assert.Equal(t, []string{"^git "}, cfg.Permissions.BashAllow) - assert.False(t, cfg.Agents.Enabled) - - // The previous file content is kept as a backup. - bak, err := os.ReadFile(path + ".bak") - require.NoError(t, err) - assert.Contains(t, string(bak), "model-b") -} - -func TestConfigHandlerMissingFile(t *testing.T) { - h := &configHandler{configPath: filepath.Join(t.TempDir(), "nope.yaml")} - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newLocalAPIRequest(http.MethodGet, "/api/config", nil)) - require.Equal(t, http.StatusOK, rr.Code) - var doc configDoc - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &doc)) - assert.Empty(t, doc.Models) -} - -func TestConfigHandlerValidation(t *testing.T) { - h := &configHandler{configPath: filepath.Join(t.TempDir(), "config.yaml")} - +func TestFetchModelIDs(t *testing.T) { cases := []struct { - name string - doc configDoc - }{ - {"no models", configDoc{}}, - {"default missing api_key", configDoc{Models: []modelDoc{{Name: "m"}}}}, - { - "two defaults", - configDoc{ - Models: []modelDoc{{Name: "a", APIKey: "k", Default: true}, {Name: "b", APIKey: "k", Default: true}}, - }, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - body, err := json.Marshal(tc.doc) - require.NoError(t, err) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newJSONAPIRequest("/api/config", strings.NewReader(string(body)))) - require.Equal(t, http.StatusBadRequest, rr.Code) - }) - } - - // A minimal valid document saves and marks the first model default. - doc := configDoc{Models: []modelDoc{{Name: "m", APIKey: "k"}}} - body, err := json.Marshal(doc) - require.NoError(t, err) - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newJSONAPIRequest("/api/config", strings.NewReader(string(body)))) - require.Equal(t, http.StatusOK, rr.Code) - - data, err := os.ReadFile(h.configPath) - require.NoError(t, err) - require.Contains(t, string(data), "default: true") - require.Contains(t, string(data), "name: m") -} - -func TestConfigHandlerServesPage(t *testing.T) { - h := &configHandler{configPath: filepath.Join(t.TempDir(), "config.yaml")} - rr := httptest.NewRecorder() - h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)) - require.Equal(t, http.StatusOK, rr.Code) - body := rr.Body.String() - assert.Contains(t, body, `id="langToggle"`) - assert.Contains(t, body, "phi-config-lang") - assert.Contains(t, body, "配置中心") - assert.Contains(t, body, "Config") - require.Contains(t, body, `type: "password"`) - assert.Contains(t, body, "tokens") - assert.Contains(t, body, "seconds") - assert.Contains(t, body, "apiRouter") - assert.Contains(t, body, "thinkLevel") - assert.Contains(t, body, "/api/config") - assert.Contains(t, body, "/api/models") -} - -func TestConfigHandlerListsModels(t *testing.T) { - cases := []struct { - name string - api string - model string - response string - wantPath string - wantModels []string - checkAuth func(*testing.T, *http.Request) + name string + api string + model string + response string + wantPath string + wantModels []string + checkAuth func(*testing.T, *http.Request) + withBaseURL bool }{ { name: "openai compatible", @@ -191,6 +33,7 @@ func TestConfigHandlerListsModels(t *testing.T) { assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) assert.Empty(t, r.Header.Get("Anthropic-Version")) }, + withBaseURL: true, }, { name: "anthropic explicit api", @@ -201,26 +44,30 @@ func TestConfigHandlerListsModels(t *testing.T) { wantModels: []string{"claude-sonnet-4-20250514"}, checkAuth: func(t *testing.T, r *http.Request) { assert.Equal(t, "test-key", r.Header.Get("X-Api-Key")) - assert.Equal(t, "2023-06-01", r.Header.Get("Anthropic-Version")) + assert.Equal(t, anthropicAPIVersion, r.Header.Get("Anthropic-Version")) }, + withBaseURL: true, }, { name: "claude name with openai api stays openai", api: "OpenAI", model: "claude-via-proxy", - response: `{"data":[{"id":"proxy-model"}]}`, + response: `{"models":[{"name":"proxy-model"}]}`, wantPath: "/v1/models", wantModels: []string{"proxy-model"}, checkAuth: func(t *testing.T, r *http.Request) { assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) assert.Empty(t, r.Header.Get("X-Api-Key")) }, + withBaseURL: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + var hits atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) assert.Equal(t, tc.wantPath, r.URL.Path) tc.checkAuth(t, r) w.Header().Set("Content-Type", "application/json") @@ -228,53 +75,79 @@ func TestConfigHandlerListsModels(t *testing.T) { })) defer server.Close() - body, err := json.Marshal(modelListRequest{ - BaseURL: server.URL + "/v1", - APIKey: "test-key", - Model: tc.model, - API: tc.api, - }) + baseURL := "" + if tc.withBaseURL { + baseURL = server.URL + "/v1" + } + got, err := fetchModelIDs(t.Context(), baseURL, "test-key", tc.api, tc.model) require.NoError(t, err) + assert.Equal(t, tc.wantModels, got) + assert.Equal(t, int32(1), hits.Load()) + }) + } +} - h := &configHandler{configPath: filepath.Join(t.TempDir(), "config.yaml")} - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newJSONAPIRequest("/api/models", strings.NewReader(string(body)))) - require.Equal(t, http.StatusOK, rr.Code) +func TestFetchModelIDsDefaultsToOpenAI(t *testing.T) { + // No base URL and no claude-ish hints: the request must go to the public + // OpenAI endpoint. The DNS lookup fails here, which is fine — we only care + // that the failure is a transport error and not an arg error. + _, err := fetchModelIDs(t.Context(), "", "test-key", "", "gpt-4o") + require.Error(t, err) + assert.NotErrorIs(t, err, http.ErrNotSupported) +} - var got struct { - Models []string `json:"models"` - } - require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &got)) - assert.Equal(t, tc.wantModels, got.Models) +func TestFetchModelIDsRejectsBadBaseURL(t *testing.T) { + cases := []struct{ name, baseURL string }{ + {"relative", "api.example.com/v1"}, + {"no host", "https:///v1"}, + {"bad scheme", "ftp://api.example.com/v1"}, + {"not a url", "://nope"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := fetchModelIDs(t.Context(), tc.baseURL, "key", "OpenAI", "m") + require.Error(t, err) }) } } -func TestConfigHandlerModelListRedirects(t *testing.T) { +func TestFetchModelIDsSurfacesProviderError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad key"}`)) + })) + defer server.Close() + + _, err := fetchModelIDs(t.Context(), server.URL, "test-key", "OpenAI", "m") + require.Error(t, err) + assert.Contains(t, err.Error(), "bad key") +} + +func TestFetchModelIDsRedirects(t *testing.T) { t.Run("same origin is followed", func(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/models": http.Redirect(w, r, "/models-final", http.StatusTemporaryRedirect) case "/models-final": - assert.Equal(t, "test-key", r.Header.Get("X-Api-Key")) - _, _ = w.Write([]byte(`{"data":[{"id":"claude-model"}]}`)) + assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization")) + _, _ = w.Write([]byte(`{"data":[{"id":"via-redirect"}]}`)) default: http.NotFound(w, r) } })) defer server.Close() - rr := requestModelList(t, server.URL+"/v1", "claude-model") - require.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), "claude-model") + got, err := fetchModelIDs(t.Context(), server.URL+"/v1", "test-key", "OpenAI", "m") + require.NoError(t, err) + assert.Equal(t, []string{"via-redirect"}, got) }) t.Run("cross origin is rejected without forwarding key", func(t *testing.T) { var targetRequests atomic.Int32 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetRequests.Add(1) - assert.Empty(t, r.Header.Get("X-Api-Key")) + assert.Empty(t, r.Header.Get("Authorization")) _, _ = w.Write([]byte(`{"data":[]}`)) })) defer target.Close() @@ -284,173 +157,67 @@ func TestConfigHandlerModelListRedirects(t *testing.T) { })) defer source.Close() - rr := requestModelList(t, source.URL+"/v1", "claude-model") - require.Equal(t, http.StatusBadGateway, rr.Code) + _, err := fetchModelIDs(t.Context(), source.URL+"/v1", "test-key", "OpenAI", "m") + require.Error(t, err) assert.Zero(t, targetRequests.Load()) }) } -func TestConfigHandlerRejectsUnsafePOSTs(t *testing.T) { - const localHost = "127.0.0.1:43210" - const localOrigin = "http://127.0.0.1:43210" - - var targetRequests atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - targetRequests.Add(1) - _, _ = w.Write([]byte(`{"data":[]}`)) - })) - defer target.Close() - - configBody := `{"models":[{"name":"model","apiKey":"key","default":true}]}` - modelBody := fmt.Sprintf(`{"baseUrl":%q,"apiKey":"key","model":"model"}`, target.URL) +func TestModelListEndpoint(t *testing.T) { cases := []struct { - name string - path string - body string - contentType string - host string - origin string - wantStatus int + name string + baseURL string + anthropic bool + want string }{ - { - "config rejects non-JSON", - "/api/config", - configBody, - "text/plain", - localHost, - localOrigin, - http.StatusUnsupportedMediaType, - }, - { - "config rejects missing origin", - "/api/config", - configBody, - "application/json", - localHost, - "", - http.StatusForbidden, - }, - { - "config rejects cross-origin", - "/api/config", - configBody, - "application/json", - localHost, - "https://attacker.example", - http.StatusForbidden, - }, - { - "config rejects non-loopback host", - "/api/config", - configBody, - "application/json", - "attacker.example", - "http://attacker.example", - http.StatusForbidden, - }, - { - "models rejects non-JSON", - "/api/models", - modelBody, - "text/plain", - localHost, - localOrigin, - http.StatusUnsupportedMediaType, - }, - { - "models rejects missing origin", - "/api/models", - modelBody, - "application/json", - localHost, - "", - http.StatusForbidden, - }, - { - "models rejects cross-origin", - "/api/models", - modelBody, - "application/json", - localHost, - "https://attacker.example", - http.StatusForbidden, - }, - { - "models rejects non-loopback host", - "/api/models", - modelBody, - "application/json", - "attacker.example", - "http://attacker.example", - http.StatusForbidden, - }, + {"openai base", "https://api.openai.com/v1", false, "https://api.openai.com/v1/models"}, + {"bare host", "https://example.com", false, "https://example.com/models"}, + {"already models", "https://example.com/v1/models", false, "https://example.com/v1/models"}, + {"anthropic adds v1", "https://api.anthropic.com", true, "https://api.anthropic.com/v1/models"}, + {"drops query", "https://example.com/v1?x=1", false, "https://example.com/v1/models"}, + {"trailing slash", "https://example.com/v1/", false, "https://example.com/v1/models"}, } - for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - before := targetRequests.Load() - h := &configHandler{configPath: filepath.Join(t.TempDir(), "config.yaml")} - req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, tc.path, strings.NewReader(tc.body)) - req.Host = tc.host - req.Header.Set("Content-Type", tc.contentType) - if tc.origin != "" { - req.Header.Set("Origin", tc.origin) - } - rr := httptest.NewRecorder() - - h.ServeHTTP(rr, req) - - assert.Equal(t, tc.wantStatus, rr.Code) - assert.Equal(t, before, targetRequests.Load()) - _, err := os.Stat(h.configPath) - assert.ErrorIs(t, err, os.ErrNotExist) + got, err := modelListEndpoint(tc.baseURL, tc.anthropic) + require.NoError(t, err) + assert.Equal(t, tc.want, got) }) } } -func TestConfigHandlerRejectsNonLoopbackGET(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yaml") - require.NoError(t, os.WriteFile(path, []byte(`models: - - name: secret-model - api_key: secret-key - default: true -`), 0o600)) - - h := &configHandler{configPath: path} - req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/config", http.NoBody) - req.Host = "attacker.example" - rr := httptest.NewRecorder() - - h.ServeHTTP(rr, req) - - require.Equal(t, http.StatusForbidden, rr.Code) - assert.NotContains(t, rr.Body.String(), "secret-key") +func TestIsAnthropicAPI(t *testing.T) { + cases := []struct { + api, baseURL, model string + want bool + }{ + {"Anthropic", "", "", true}, + {"OpenAI", "https://anthropic.example", "claude-3", false}, + {"Gemini", "https://x.example", "gemini-pro", false}, + {"", "https://api.anthropic.com", "m", true}, + {"", "https://x.example", "claude-sonnet", true}, + {"", "https://x.example", "m", false}, + } + for _, tc := range cases { + assert.Equal(t, tc.want, isAnthropicAPI(tc.api, tc.baseURL, tc.model), tc) + } } -func requestModelList(t *testing.T, baseURL, model string) *httptest.ResponseRecorder { - t.Helper() - body, err := json.Marshal(modelListRequest{ - BaseURL: baseURL, - APIKey: "test-key", - Model: model, +func TestCollectModelIDsPrefersID(t *testing.T) { + got := collectModelIDs([]modelListItem{ + {ID: "id", Name: "name", DisplayName: "display"}, + {Name: "name-only"}, + {DisplayName: "display-only"}, + {}, + {ID: "id"}, }) - require.NoError(t, err) - - h := &configHandler{configPath: filepath.Join(t.TempDir(), "config.yaml")} - rr := httptest.NewRecorder() - h.ServeHTTP(rr, newJSONAPIRequest("/api/models", strings.NewReader(string(body)))) - return rr -} - -func newJSONAPIRequest(target string, body io.Reader) *http.Request { - req := newLocalAPIRequest(http.MethodPost, target, body) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "http://"+req.Host) - return req + assert.Equal(t, []string{"id", "name-only", "display-only"}, got) } -func newLocalAPIRequest(method, target string, body io.Reader) *http.Request { - req := httptest.NewRequestWithContext(context.Background(), method, target, body) - req.Host = "127.0.0.1:43210" - return req +// modelListResponse must decode both the OpenAI and Anthropic envelopes. +func TestModelListResponseDecodesBothEnvelopes(t *testing.T) { + var payload modelListResponse + require.NoError(t, json.Unmarshal([]byte(`{"data":[{"id":"a"}],"models":[{"id":"b"}]}`), &payload)) + assert.Len(t, payload.Data, 1) + assert.Len(t, payload.Models, 1) } diff --git a/cmd/main.go b/cmd/main.go index b90f99c2..832bc73c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,7 +37,7 @@ func buildRoot() *cli.Command { Desc: "terminal coding agent", Long: `phi start the interactive TUI phi tui start the interactive TUI -phi config open the HTML config editor (local web server) +phi config edit the meta config in a terminal UI phi update install the latest release (see 'phi update --help') phi run -p "..." run one agent loop headlessly (see 'phi run --help') phi sessions list list persisted sessions for this directory diff --git a/internal/components/chrome/chrome.go b/internal/components/chrome/chrome.go index b070af82..3efefde3 100644 --- a/internal/components/chrome/chrome.go +++ b/internal/components/chrome/chrome.go @@ -74,9 +74,10 @@ func AskHint(nav, action, dismiss string) string { return strings.Join(parts, Sep) } -// ConfirmHint is the confirm-panel footer (includes Y/N chords). +// ConfirmHint is the confirm-panel footer (includes Y/N chords). Decision rows +// stack vertically, so the nav fragment names the vertical axis. func ConfirmHint() string { - return "←→ move" + Sep + "Enter confirm" + Sep + "Y yes" + Sep + "N/Esc cancel" + return "↑↓ move" + Sep + "Enter confirm" + Sep + "Y yes" + Sep + "N/Esc cancel" } // FeedbackHint is the deny-with-feedback footer. diff --git a/internal/components/chrome/chrome_test.go b/internal/components/chrome/chrome_test.go index fbdb1e9e..9bf0251c 100644 --- a/internal/components/chrome/chrome_test.go +++ b/internal/components/chrome/chrome_test.go @@ -17,7 +17,7 @@ func TestHintDialect(t *testing.T) { assert.Equal(t, " ⏎ select · esc close ", chrome.ListHintShort("select")) assert.Equal(t, "↑↓ move · Enter select · Esc cancel", chrome.AskHint("↑↓ move", "select", "cancel")) assert.Equal(t, "↑↓ move · Enter select · Esc stop", chrome.AskHint("↑↓ move", "select", "stop")) - assert.Equal(t, "←→ move · Enter confirm · Y yes · N/Esc cancel", chrome.ConfirmHint()) + assert.Equal(t, "↑↓ move · Enter confirm · Y yes · N/Esc cancel", chrome.ConfirmHint()) assert.Equal(t, "Enter send · Esc cancel", chrome.FeedbackHint()) } diff --git a/internal/project/config.go b/internal/project/config.go index c83925bc..6793ae08 100644 --- a/internal/project/config.go +++ b/internal/project/config.go @@ -1,7 +1,6 @@ package project import ( - "errors" "fmt" "os" "strings" @@ -36,9 +35,9 @@ type AgentsConfig struct { // AgentsRoleModels maps sub-agent roles to configured model names. type AgentsRoleModels struct { - Explore string `yaml:"explore"` - Review string `yaml:"review"` - Worker string `yaml:"worker"` + Explore string `yaml:"explore,omitempty"` + Review string `yaml:"review,omitempty"` + Worker string `yaml:"worker,omitempty"` } // Model returns the default model config with the skill path applied, ready @@ -136,13 +135,13 @@ func parseConfigFile(path string) (*Config, error) { // Pointer fields distinguish "key absent" from "zero value", so per-key // defaults (and permission.DefaultPolicy) survive decoding and are only // overridden by keys that are actually present. - var raw fileConfig + var raw ConfigDoc if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse %s: %w", path, err) } for _, m := range raw.Models { - mc := modelEntryToConfig(m) + mc := modelDocToConfig(m) if m.Default && cfg.DefaultModel == "" { cfg.DefaultModel = mc.Name } @@ -169,7 +168,7 @@ func parseConfigFile(path string) (*Config, error) { return cfg, nil } -func modelEntryToConfig(m modelEntry) llm.ModelConfig { +func modelDocToConfig(m ModelDoc) llm.ModelConfig { cfg := llm.ModelConfig{Name: m.Name, APIKey: m.APIKey, BaseURL: m.BaseURL} // A built-in preset supplies base_url / context_window / image_enabled / api // when the entry omits them; the explicit fields below still win so @@ -191,7 +190,7 @@ func modelEntryToConfig(m modelEntry) llm.ModelConfig { cfg.Think = pc.Think } if m.API != "" { - cfg.API = m.API + cfg.API = llm.RouterType(m.API) } if m.ContextWindow != nil && *m.ContextWindow > 0 { cfg.ContextWindow = *m.ContextWindow @@ -202,8 +201,8 @@ func modelEntryToConfig(m modelEntry) llm.ModelConfig { if m.ThinkEnabled != nil { cfg.Think.Enabled = *m.ThinkEnabled } - if m.ThinkLevel != nil && *m.ThinkLevel != "" { - mode := llm.ThinkMode(*m.ThinkLevel) + if m.ThinkLevel != "" { + mode := llm.ThinkMode(m.ThinkLevel) cfg.Think.Mode = mode cfg.Think.Enabled = mode != llm.Off } @@ -213,51 +212,11 @@ func modelEntryToConfig(m modelEntry) llm.ModelConfig { return cfg } -// fileConfig mirrors the YAML keys in ~/.phi/config.yaml. -type fileConfig struct { - Models []modelEntry `yaml:"models"` - SkillPath *string `yaml:"skill_path"` - Permissions *permConfig `yaml:"permissions"` - Agents *agentsConfig `yaml:"agents"` -} - -type agentsConfig struct { - // Enabled is a pointer so omitting the key keeps the default (on). - Enabled *bool `yaml:"enabled"` - Models *AgentsRoleModels `yaml:"models"` -} - -type modelEntry struct { - Name string `yaml:"name"` - APIKey string `yaml:"api_key"` - BaseURL string `yaml:"base_url"` - ContextWindow *int `yaml:"context_window"` - ImageEnabled *bool `yaml:"image_enabled"` - API llm.RouterType `yaml:"api"` - Default bool `yaml:"default"` - ThinkEnabled *bool `yaml:"think_enabled"` - ThinkLevel *string `yaml:"think_level"` -} - -type permConfig struct { - Mode permission.Mode `yaml:"mode"` - WorkspaceOnlyWrites *bool `yaml:"workspace_only_writes"` - AskTimeoutSec *int `yaml:"ask_timeout_sec"` - DangerouslyAllowAll *bool `yaml:"dangerously_allow_all"` - Bash *bashConfig `yaml:"bash"` -} - -type bashConfig struct { - Default *string `yaml:"default"` - Allow *stringList `yaml:"allow"` - Deny *stringList `yaml:"deny"` -} - // applyPermissions merges the file's permissions block over DefaultPolicy. // An explicitly set list (even an empty one) replaces the default list. -func applyPermissions(p *permission.Policy, raw *permConfig) { +func applyPermissions(p *permission.Policy, raw *PermDoc) { if raw.Mode != "" { - p.Mode = raw.Mode + p.Mode = permission.Mode(raw.Mode) } if raw.WorkspaceOnlyWrites != nil { p.WorkspaceOnlyWrites = *raw.WorkspaceOnlyWrites @@ -269,8 +228,8 @@ func applyPermissions(p *permission.Policy, raw *permConfig) { p.DangerouslyAllowAll = *raw.DangerouslyAllowAll } if b := raw.Bash; b != nil { - if b.Default != nil { - p.BashDefault = parseDecision(*b.Default, p.BashDefault) + if b.Default != "" { + p.BashDefault = parseDecision(b.Default, p.BashDefault) } if b.Allow != nil { p.BashAllow = *b.Allow @@ -281,26 +240,6 @@ func applyPermissions(p *permission.Policy, raw *permConfig) { } } -// stringList accepts either a single YAML scalar or a sequence, so both -// `allow: "go test ./..."` and the block list form in the README work. -type stringList []string - -func (s *stringList) UnmarshalYAML(node *yaml.Node) error { - switch node.Kind { - case yaml.ScalarNode: - *s = stringList{node.Value} - case yaml.SequenceNode: - items := make(stringList, 0, len(node.Content)) - for _, n := range node.Content { - items = append(items, n.Value) - } - *s = items - default: - return errors.New("expected a string or a list of strings") - } - return nil -} - func countIndent(line string) int { n := 0 for _, r := range line { diff --git a/internal/project/configdoc.go b/internal/project/configdoc.go new file mode 100644 index 00000000..9edcb3c8 --- /dev/null +++ b/internal/project/configdoc.go @@ -0,0 +1,156 @@ +package project + +import ( + "errors" + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// ConfigDoc is the editable view of ~/.phi/config.yaml: the keys +// parseConfigFile reads, with pointer fields so a key the editor never touched +// stays absent when the file is written back. `phi config` edits this document; +// the runtime keeps loading through parseConfigFile. +type ConfigDoc struct { + Models []ModelDoc `yaml:"models"` + SkillPath *string `yaml:"skill_path,omitempty"` + Permissions *PermDoc `yaml:"permissions,omitempty"` + Agents *AgentsDoc `yaml:"agents,omitempty"` +} + +// ModelDoc is one models[] entry. +type ModelDoc struct { + Name string `yaml:"name"` + // APIKey and BaseURL are omitted when empty: the loader treats an absent + // key exactly like an empty one, and a saved file reads cleaner without + // `base_url: ""` noise on every preset-backed model. + APIKey string `yaml:"api_key,omitempty"` + BaseURL string `yaml:"base_url,omitempty"` + ContextWindow *int `yaml:"context_window,omitempty"` + // ImageEnabled is a pointer so json/yaml round-trips keep "key absent" + // distinct from an explicit false. + ImageEnabled *bool `yaml:"image_enabled,omitempty"` + API string `yaml:"api,omitempty"` // OpenAI | OpenAIResponses | Anthropic | Gemini + ThinkEnabled *bool `yaml:"think_enabled,omitempty"` + ThinkLevel string `yaml:"think_level,omitempty"` + Default bool `yaml:"default,omitempty"` +} + +// PermDoc is the permissions block. +type PermDoc struct { + Mode string `yaml:"mode,omitempty"` + WorkspaceOnlyWrites *bool `yaml:"workspace_only_writes,omitempty"` + AskTimeoutSec *int `yaml:"ask_timeout_sec,omitempty"` + DangerouslyAllowAll *bool `yaml:"dangerously_allow_all,omitempty"` + Bash *BashDoc `yaml:"bash,omitempty"` +} + +// BashDoc is the permissions.bash block. +type BashDoc struct { + Default string `yaml:"default,omitempty"` + Allow *StringList `yaml:"allow,omitempty"` + Deny *StringList `yaml:"deny,omitempty"` +} + +// AgentsDoc is the agents block. +type AgentsDoc struct { + // Enabled is a pointer so omitting the key keeps the default (on) when only + // agents.models is set. + Enabled *bool `yaml:"enabled,omitempty"` + Models *AgentsRoleModels `yaml:"models,omitempty"` +} + +// StringList accepts either a single YAML scalar or a sequence, so both +// `allow: "go test ./..."` and the block list form in the README work. +type StringList []string + +func (s *StringList) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + *s = StringList{node.Value} + case yaml.SequenceNode: + items := make(StringList, 0, len(node.Content)) + for _, n := range node.Content { + items = append(items, n.Value) + } + *s = items + default: + return errors.New("expected a string or a list of strings") + } + return nil +} + +// ReadConfigDoc loads path into an editable document. A missing file yields an +// empty document so the editor can bootstrap a config. +func ReadConfigDoc(path string) (*ConfigDoc, error) { + doc := &ConfigDoc{} + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return doc, nil + } + return nil, fmt.Errorf("read config %s: %w", path, err) + } + if err := yaml.Unmarshal(data, doc); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return doc, nil +} + +// Validate reports whether doc is safe to write. It does not mutate doc: a +// document with no explicit default is valid, since the loader falls back to +// the first model. +func (d *ConfigDoc) Validate() error { + if len(d.Models) == 0 { + return errors.New("at least one model is required") + } + seen := make(map[string]struct{}, len(d.Models)) + defaults := 0 + for i, m := range d.Models { + name := strings.TrimSpace(m.Name) + if name == "" { + return fmt.Errorf("model %d has no name", i+1) + } + if _, dup := seen[name]; dup { + return fmt.Errorf("duplicate model name %q", name) + } + seen[name] = struct{}{} + if m.Default { + defaults++ + if strings.TrimSpace(m.APIKey) == "" { + return fmt.Errorf("default model %q is missing api_key", name) + } + } + } + if defaults > 1 { + return errors.New("only one model may be marked default") + } + // With no explicit default the loader starts with the first entry, so that + // entry is the one that has to be usable. + if defaults == 0 && strings.TrimSpace(d.Models[0].APIKey) == "" { + return fmt.Errorf("model %q is used by default and is missing api_key", strings.TrimSpace(d.Models[0].Name)) + } + return nil +} + +// Save writes doc to path as YAML, keeping the previous file as path+".bak" so +// a bad edit never costs the user their only copy. +func (d *ConfigDoc) Save(path string) error { + data, err := yaml.Marshal(d) + if err != nil { + return fmt.Errorf("encode config: %w", err) + } + if cur, err := os.ReadFile(path); err == nil { + //nolint:gosec // G306: config backup stays user-readable + if err := os.WriteFile(path+".bak", cur, 0o644); err != nil { + return fmt.Errorf("backup config: %w", err) + } + } + //nolint:gosec // G306: config.yaml is meant to be user-readable + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write config %s: %w", path, err) + } + return nil +} diff --git a/internal/project/configdoc_test.go b/internal/project/configdoc_test.go new file mode 100644 index 00000000..65bd5914 --- /dev/null +++ b/internal/project/configdoc_test.go @@ -0,0 +1,152 @@ +package project + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const configDocFixture = `models: + - name: model-a + api_key: key-a + base_url: https://a.example/v1 + context_window: 1000 + image_enabled: true + default: true + - name: model-b + api_key: key-b + base_url: https://b.example/v1 +permissions: + mode: readonly + dangerously_allow_all: true + bash: + default: ask + allow: + - "^git " +agents: + enabled: false +` + +func TestConfigDocRoundTripThroughLoader(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(configDocFixture), 0o600)) + + doc, err := ReadConfigDoc(path) + require.NoError(t, err) + require.Len(t, doc.Models, 2) + assert.Equal(t, "model-a", doc.Models[0].Name) + assert.True(t, doc.Models[0].Default) + require.NotNil(t, doc.Models[0].ImageEnabled) + assert.True(t, *doc.Models[0].ImageEnabled) + require.NotNil(t, doc.Permissions) + require.NotNil(t, doc.Permissions.Bash) + require.NotNil(t, doc.Permissions.Bash.Allow) + assert.Equal(t, StringList{"^git "}, *doc.Permissions.Bash.Allow) + require.NotNil(t, doc.Agents) + require.NotNil(t, doc.Agents.Enabled) + assert.False(t, *doc.Agents.Enabled) + + // An edit then a save must load back with the same values the runtime uses. + doc.Models[0].APIKey = "new-key" + doc.Models = doc.Models[:1] + require.NoError(t, doc.Save(path)) + + loaded, err := parseConfigFile(path) + require.NoError(t, err) + require.Len(t, loaded.Models, 1) + assert.Equal(t, "model-a", loaded.Model().Name) + assert.Equal(t, "new-key", loaded.Model().APIKey) + assert.Equal(t, 1000, loaded.Model().ContextWindow) + assert.True(t, loaded.Model().ImageEnabled) + assert.Equal(t, "readonly", string(loaded.Permissions.Mode)) + assert.True(t, loaded.Permissions.DangerouslyAllowAll) + assert.Equal(t, []string{"^git "}, loaded.Permissions.BashAllow) + assert.False(t, loaded.Agents.Enabled) + + // The previous file is kept as a backup. + backup, err := os.ReadFile(path + ".bak") + require.NoError(t, err) + assert.Contains(t, string(backup), "model-b") +} + +func TestConfigDocKeepsAbsentKeysAbsent(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("models:\n - name: m\n api_key: k\n"), 0o600)) + + doc, err := ReadConfigDoc(path) + require.NoError(t, err) + require.NoError(t, doc.Save(path)) + + data, err := os.ReadFile(path) + require.NoError(t, err) + // Keys the editor never touched must not appear: their absence is what keeps + // the loader defaults (permissions policy, agents on) in force. + for _, key := range []string{"skill_path", "permissions", "agents", "image_enabled", "context_window"} { + assert.NotContains(t, string(data), key) + } + assert.Contains(t, string(data), "name: m") +} + +func TestConfigDocMissingFileIsEmpty(t *testing.T) { + doc, err := ReadConfigDoc(filepath.Join(t.TempDir(), "nope.yaml")) + require.NoError(t, err) + assert.Empty(t, doc.Models) + assert.Nil(t, doc.Permissions) +} + +func TestConfigDocMalformedFileFails(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("models: [\n"), 0o600)) + _, err := ReadConfigDoc(path) + require.Error(t, err) +} + +func TestConfigDocValidate(t *testing.T) { + cases := []struct { + name string + doc ConfigDoc + wantErr string + }{ + {"no models", ConfigDoc{}, "at least one model"}, + {"unnamed model", ConfigDoc{Models: []ModelDoc{{}}}, "has no name"}, + {"duplicate names", ConfigDoc{Models: []ModelDoc{{Name: "m", APIKey: "k"}, {Name: "m"}}}, "duplicate"}, + { + "two defaults", + ConfigDoc{Models: []ModelDoc{ + {Name: "a", APIKey: "k", Default: true}, + {Name: "b", APIKey: "k", Default: true}, + }}, + "only one model", + }, + { + "default without key", + ConfigDoc{Models: []ModelDoc{{Name: "a", Default: true}}}, + "missing api_key", + }, + {"no explicit default is fine", ConfigDoc{Models: []ModelDoc{{Name: "a", APIKey: "k"}}}, ""}, + { + "first model is the implicit default", + ConfigDoc{Models: []ModelDoc{{Name: "a"}, {Name: "b", APIKey: "k"}}}, + "used by default", + }, + { + "keyless non-default model is fine", + ConfigDoc{Models: []ModelDoc{{Name: "a", APIKey: "k"}, {Name: "b"}}}, + "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.doc.Validate() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} diff --git a/internal/tui/configui/doc.go b/internal/tui/configui/doc.go new file mode 100644 index 00000000..5eb5db6a --- /dev/null +++ b/internal/tui/configui/doc.go @@ -0,0 +1,14 @@ +// Package configui is the full-screen editor behind `phi config`: one keyboard +// form over ~/.phi/config.yaml. +// +// The document (project.ConfigDoc) is the single source of truth. Rows are +// rebuilt from it after every edit, the cursor is anchored to a row key instead +// of an index, and nothing is written to disk until the user saves. +package configui + +import "context" + +// ModelLister fetches the model IDs a provider advertises for one connection. +// It runs off the UI goroutine, so implementations must be safe for concurrent +// use and must honor ctx. +type ModelLister func(ctx context.Context, baseURL, apiKey, api, model string) ([]string, error) diff --git a/internal/tui/configui/editor.go b/internal/tui/configui/editor.go new file mode 100644 index 00000000..e5c6eda7 --- /dev/null +++ b/internal/tui/configui/editor.go @@ -0,0 +1,825 @@ +package configui + +import ( + "context" + "strings" + "sync" + "time" + "unicode" + + "github.com/pulseaiclub/xui" + + "github.com/pulseaiclub/phi/internal/components" + "github.com/pulseaiclub/phi/internal/components/listpicker" + "github.com/pulseaiclub/phi/internal/project" +) + +const ( + // fetchTimeout caps one provider model-list request. + fetchTimeout = 15 * time.Second + // pickerPrimaryWidth keeps choice labels in one column. + pickerPrimaryWidth = 22 +) + +// ConfigEditor is the full-screen config form. It implements components.Widget and is +// driven by the App loop, so all state lives on the UI goroutine — except the +// background model-list fetch, which hands its result back under fetchMu. +type ConfigEditor struct { + doc *project.ConfigDoc + path string + skills string + theme components.Theme + list ModelLister + redraw func() + + rows []row + cursor int + scroll int + expanded string + dirty bool + + status string + statusKind statusKind + + edit *editState + confirm *confirmState + picker listpicker.Picker + + // Painted geometry, recorded by Draw for hit testing, scrolling and the + // text caret. + width int + rowsTop int + page int + caretX int + caretY int + + fetchMu sync.Mutex + fetch fetchState +} + +// New builds the editor for doc. path is the file to write, skills is the +// directory an empty skill_path falls back to, list fetches provider model IDs +// (nil hides the feature), and redraw wakes the app after a background fetch. +func New( + doc *project.ConfigDoc, + path, skills string, + theme components.Theme, + list ModelLister, + redraw func(), +) *ConfigEditor { + e := &ConfigEditor{ + doc: doc, + path: path, + skills: skills, + theme: theme, + list: list, + redraw: redraw, + } + // Build once so the editor answers keys before the first frame. + e.rebuild() + e.move(1) + return e +} + +// Dirty reports whether the document has unsaved edits. +func (e *ConfigEditor) Dirty() bool { return e != nil && e.dirty } + +type editState struct { + key string + buf []rune + cur int +} + +// confirmState is a yes/no modal over the form. +type confirmState struct { + title string + body string + danger bool + yes bool + run func(ctx *components.EventContext) +} + +type fetchState struct { + active bool + done bool + index int + // name is the model's name when the fetch started; if the entry at index + // no longer matches it, the result is for a model that moved or was + // replaced while the request was in flight. + name string + ids []string + err error +} + +// Handle routes one event. Modal states own the keyboard outright; otherwise +// navigation, editing, and the shortcut keys share it. +func (e *ConfigEditor) Handle(ctx *components.EventContext, ev xui.Event) { + switch ev := ev.(type) { + case xui.KeyEvent: + if !ev.Press { + return + } + e.handleKey(ctx, ev) + case xui.PasteEvent: + if e.edit != nil { + e.insert(strings.NewReplacer("\n", " ", "\r", " ").Replace(ev.Text)) + ctx.ConsumeAndRedraw() + return + } + ctx.Consume = true + case xui.MouseEvent: + e.handleMouse(ctx, ev) + } +} + +func (e *ConfigEditor) handleKey(ctx *components.EventContext, k xui.KeyEvent) { + switch { + case e.picker.Open: + e.picker.Handle(ctx, k) + case e.confirm != nil: + e.handleConfirmKey(ctx, k) + case e.edit != nil: + e.handleEditKey(ctx, k) + default: + e.handleNavKey(ctx, k) + } +} + +func (e *ConfigEditor) handleNavKey(ctx *components.EventContext, k xui.KeyEvent) { + switch k.Code { + case xui.KeyUp: + e.move(-1) + case xui.KeyDown: + e.move(1) + case xui.KeyPageUp: + e.move(-max(e.page, 1)) + case xui.KeyPageDown: + e.move(max(e.page, 1)) + case xui.KeyHome: + e.jumpEdge(false) + case xui.KeyEnd: + e.jumpEdge(true) + case xui.KeyLeft: + e.cycleFocused(-1) + case xui.KeyRight: + e.cycleFocused(1) + case xui.KeyEnter, xui.KeyTab: + e.activate() + case xui.KeyEscape: + if e.expanded != "" { + e.collapse() + } else { + e.requestQuit(ctx) + } + case xui.KeyRune: + if k.Mods.Has(xui.ModAlt) { + ctx.Consume = true + return + } + if k.Mods.Has(xui.ModCtrl) { + if k.Rune != 's' && k.Rune != 'S' { + ctx.Consume = true + return + } + e.save() + break + } + switch k.Rune { + case 'k': + e.move(-1) + case 'j': + e.move(1) + case 'g': + e.jumpEdge(false) + case 'G': + e.jumpEdge(true) + case 'a': + e.addModel() + case 'd': + e.deleteFocused() + case 'f': + e.fetchModels() + case 's': + e.save() + case 'q': + e.requestQuit(ctx) + case ' ': + e.toggleFocused() + default: + ctx.Consume = true + return + } + default: + ctx.Consume = true + return + } + ctx.ConsumeAndRedraw() +} + +func (e *ConfigEditor) focused() (row, bool) { + if e.cursor < 0 || e.cursor >= len(e.rows) { + return row{}, false + } + return e.rows[e.cursor], true +} + +func (e *ConfigEditor) move(delta int) { + i := e.cursor + for { + i += delta + if i < 0 || i >= len(e.rows) { + return + } + if e.rows[i].kind != rowSection { + break + } + } + e.setCursor(i) +} + +// jumpEdge moves to the first or last selectable row. +func (e *ConfigEditor) jumpEdge(last bool) { + for i := range e.rows { + index := i + if last { + index = len(e.rows) - 1 - i + } + if e.rows[index].kind != rowSection { + e.setCursor(index) + return + } + } +} + +// setCursor moves the cursor, collapsing an expanded list when the cursor +// leaves it. Row indices shift on collapse, so the target key is captured +// first. +func (e *ConfigEditor) setCursor(index int) { + if index < 0 || index >= len(e.rows) { + return + } + key := e.rows[index].key + if e.expanded != "" { + if base, _, ok := splitListKey(key); !ok || base != e.expanded { + e.expanded = "" + e.rebuild() + } + } + e.anchor(key, index) +} + +// refocus rebuilds rows and puts the cursor back on key. +func (e *ConfigEditor) refocus(key string) { + e.rebuild() + e.anchor(key, e.cursor) +} + +func (e *ConfigEditor) anchor(key string, fallback int) { + if len(e.rows) == 0 { + e.cursor = 0 + return + } + for i := range e.rows { + if e.rows[i].key == key { + e.cursor = i + return + } + } + fallback = min(max(fallback, 0), len(e.rows)-1) + for fallback > 0 && e.rows[fallback].kind == rowSection { + fallback-- + } + e.cursor = fallback +} + +func (e *ConfigEditor) rebuild() { + e.build() + e.cursor = min(max(e.cursor, 0), max(len(e.rows)-1, 0)) +} + +func (e *ConfigEditor) activate() { + r, ok := e.focused() + if !ok { + return + } + switch r.kind { + case rowAdd: + e.startEdit(r.key, "") + case rowItem: + e.startEdit(r.key, r.label) + case rowField: + switch r.field { + case kindList: + e.expand(r.key) + case kindBool, kindPlainBool: + e.toggleFocused() + case kindChoice: + e.openKeyPicker(r.label, r.key, r.options) + default: + e.startEdit(r.key, r.raw) + } + } +} + +func (e *ConfigEditor) expand(key string) { + e.expanded = key + e.rebuild() + if len(e.listItems(key)) > 0 { + e.anchor(listItemKey(key, 0), e.cursor) + return + } + e.anchor(listAddKey(key), e.cursor) +} + +func (e *ConfigEditor) collapse() { + key := e.expanded + e.expanded = "" + e.refocus(key) +} + +func (e *ConfigEditor) toggleFocused() { + r, ok := e.focused() + if !ok { + return + } + switch r.field { + case kindBool: + e.commit(r.key, cycleTri(r.raw)) + case kindPlainBool: + e.commit(r.key, onOff(r.raw != triOn)) + } +} + +// cycleFocused steps a choice or toggle with ←/→ so the common edits never need +// a modal. +func (e *ConfigEditor) cycleFocused(delta int) { + r, ok := e.focused() + if !ok || r.kind != rowField { + return + } + switch r.field { + case kindBool: + e.commit(r.key, cycleTri(r.raw)) + case kindPlainBool: + e.commit(r.key, onOff(r.raw != triOn)) + case kindChoice: + if len(r.options) == 0 { + return + } + at := 0 + for i, o := range r.options { + if o.value == r.raw { + at = i + break + } + } + next := (at + delta + len(r.options)) % len(r.options) + e.commit(r.key, r.options[next].value) + } +} + +func (e *ConfigEditor) commit(key, value string) { + if err := e.apply(key, value); err != nil { + e.setError(err.Error()) + return + } + e.dirty = true + e.status, e.statusKind = "", statusNone + e.refocus(key) +} + +func (e *ConfigEditor) addModel() { + e.expanded = "" + e.doc.Models = append(e.doc.Models, project.ModelDoc{}) + if len(e.doc.Models) == 1 { + e.doc.Models[0].Default = true + } + e.dirty = true + index := len(e.doc.Models) - 1 + e.refocus(modelKey(index, fName)) + e.startEdit(modelKey(index, fName), "") + e.setStatus("new model — type a model id") +} + +func (e *ConfigEditor) deleteFocused() { + r, ok := e.focused() + if !ok { + return + } + if r.kind == rowItem { + _, index, _ := splitListKey(r.key) + e.removeListItem(r.listKey, index) + e.dirty = true + e.refocus(listAddKey(r.listKey)) + return + } + index, ok := modelOf(r) + if !ok { + return + } + name := e.doc.Models[index].Name + if strings.TrimSpace(name) == "" { + name = "this model" + } else { + name = "model " + name + } + e.confirm = &confirmState{ + title: "Delete " + name + "?", + body: "Nothing is written until you save.", + danger: true, + yes: false, // destructive: Enter keeps the model + run: func(*components.EventContext) { + e.deleteModel(index) + }, + } +} + +func (e *ConfigEditor) deleteModel(index int) { + if index < 0 || index >= len(e.doc.Models) { + return + } + e.doc.Models = append(e.doc.Models[:index:index], e.doc.Models[index+1:]...) + e.dirty = true + // Keep one default: with none marked, the loader silently starts from the + // first entry — make that the explicit choice here instead. + defaults := 0 + for _, m := range e.doc.Models { + if m.Default { + defaults++ + } + } + if defaults == 0 && len(e.doc.Models) > 0 { + e.doc.Models[0].Default = true + } + if next := min(index, len(e.doc.Models)-1); next >= 0 { + e.refocus(modelKey(next, fName)) + return + } + e.refocus(keySkillPath) +} + +// modelOf reports the model index a row belongs to. +func modelOf(r row) (int, bool) { + switch r.kind { + case rowModel: + return r.model, true + case rowField: + if index, _, ok := splitModelKey(r.key); ok { + return index, true + } + } + return 0, false +} + +func (e *ConfigEditor) save() { + if err := e.doc.Validate(); err != nil { + e.setError(err.Error()) + return + } + if err := e.doc.Save(e.path); err != nil { + e.setError(err.Error()) + return + } + e.dirty = false + e.status, e.statusKind = "saved "+e.path, statusSuccess +} + +func (e *ConfigEditor) requestQuit(ctx *components.EventContext) { + if !e.dirty { + ctx.Quit = true + return + } + e.confirm = &confirmState{ + title: "Discard unsaved changes?", + body: "The file on disk still holds the last saved version.", + danger: true, + yes: false, + run: func(ctx *components.EventContext) { + ctx.Quit = true + }, + } +} + +// handleConfirmKey moves between the two decision rows the way every other +// panel does: up/left picks the row above, down/right/tab the row below. The +// rows are painted Yes then No, so yes is the first one. +func (e *ConfigEditor) handleConfirmKey(ctx *components.EventContext, k xui.KeyEvent) { + st := e.confirm + switch k.Code { + case xui.KeyUp, xui.KeyLeft: + st.yes = true + case xui.KeyDown, xui.KeyRight, xui.KeyTab: + st.yes = false + case xui.KeyEnter: + e.confirm = nil + if st.yes && st.run != nil { + st.run(ctx) + } + case xui.KeyEscape: + e.confirm = nil + case xui.KeyRune: + switch k.Rune { + case 'y', 'Y': + e.confirm = nil + if st.run != nil { + st.run(ctx) + } + case 'n', 'N': + e.confirm = nil + case 'h', 'H', 'k', 'K': + st.yes = true + case 'l', 'L', 'j', 'J': + st.yes = false + } + default: + ctx.Consume = true + return + } + ctx.ConsumeAndRedraw() +} + +func (e *ConfigEditor) startEdit(key, raw string) { + buf := []rune(raw) + e.edit = &editState{key: key, buf: buf, cur: len(buf)} + e.refocus(key) +} + +func (e *ConfigEditor) handleEditKey(ctx *components.EventContext, k xui.KeyEvent) { + switch k.Code { + case xui.KeyEnter: + e.commitEdit() + case xui.KeyEscape: + e.edit = nil + case xui.KeyBackspace: + e.deleteBefore() + case xui.KeyDelete: + e.deleteAt() + case xui.KeyLeft: + e.moveCursor(-1) + case xui.KeyRight: + e.moveCursor(1) + case xui.KeyHome: + e.edit.cur = 0 + case xui.KeyEnd: + e.edit.cur = len(e.edit.buf) + case xui.KeyRune: + if k.Mods.Has(xui.ModCtrl) { + e.handleEditCtrl(k.Rune) + break + } + if k.Mods.Has(xui.ModAlt) { + ctx.Consume = true + return + } + text := k.Text + if text == "" && k.Rune >= 0x20 { + text = string(k.Rune) + } + e.insert(text) + default: + ctx.Consume = true + return + } + ctx.ConsumeAndRedraw() +} + +func (e *ConfigEditor) handleEditCtrl(r rune) { + switch unicode.ToLower(r) { + case 'a': + e.edit.cur = 0 + case 'e': + e.edit.cur = len(e.edit.buf) + case 'u': + e.edit.buf = append([]rune(nil), e.edit.buf[e.edit.cur:]...) + e.edit.cur = 0 + case 'k': + e.edit.buf = e.edit.buf[:e.edit.cur] + case 'w': + for e.edit.cur > 0 && unicode.IsSpace(e.edit.buf[e.edit.cur-1]) { + e.deleteBefore() + } + for e.edit.cur > 0 && !unicode.IsSpace(e.edit.buf[e.edit.cur-1]) { + e.deleteBefore() + } + } +} + +func (e *ConfigEditor) insert(text string) { + if text == "" || e.edit == nil { + return + } + runes := []rune(text) + cur := e.edit.cur + out := make([]rune, 0, len(e.edit.buf)+len(runes)) + out = append(out, e.edit.buf[:cur]...) + out = append(out, runes...) + out = append(out, e.edit.buf[cur:]...) + e.edit.buf = out + e.edit.cur = cur + len(runes) +} + +func (e *ConfigEditor) deleteBefore() { + if e.edit == nil || e.edit.cur == 0 { + return + } + e.edit.buf = append(e.edit.buf[:e.edit.cur-1], e.edit.buf[e.edit.cur:]...) + e.edit.cur-- +} + +func (e *ConfigEditor) deleteAt() { + if e.edit == nil || e.edit.cur >= len(e.edit.buf) { + return + } + e.edit.buf = append(e.edit.buf[:e.edit.cur], e.edit.buf[e.edit.cur+1:]...) +} + +func (e *ConfigEditor) moveCursor(delta int) { + if e.edit == nil { + return + } + e.edit.cur = min(max(e.edit.cur+delta, 0), len(e.edit.buf)) +} + +func (e *ConfigEditor) commitEdit() { + st := e.edit + if st == nil { + return + } + key := st.key + value := string(st.buf) + if err := e.apply(key, value); err != nil { + e.setError(err.Error()) + return + } + e.edit = nil + e.dirty = true + e.status, e.statusKind = "", statusNone + if base, index, ok := splitListKey(key); ok && index < 0 { + e.refocus(listItemKey(base, len(e.listItems(base))-1)) + return + } + e.refocus(key) +} + +func (e *ConfigEditor) fetchModels() { + if e.list == nil { + e.setError("model list is unavailable") + return + } + r, ok := e.focused() + if !ok { + return + } + index, ok := modelOf(r) + if !ok { + e.setStatus("move to a model first") + return + } + m := e.modelAt(index) + if m == nil { + return + } + e.fetchMu.Lock() + if e.fetch.active { + e.fetchMu.Unlock() + e.setStatus("already fetching models…") + return + } + e.fetch = fetchState{active: true, index: index, name: m.Name} + e.fetchMu.Unlock() + + baseURL, apiKey, api, name := m.BaseURL, m.APIKey, m.API, m.Name + e.setStatus("fetching models…") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) + defer cancel() + ids, err := e.list(ctx, baseURL, apiKey, api, name) + e.fetchMu.Lock() + e.fetch = fetchState{done: true, index: index, name: name, ids: ids, err: err} + e.fetchMu.Unlock() + if e.redraw != nil { + e.redraw() + } + }() +} + +// pollFetch adopts a finished background fetch. It runs on the UI goroutine +// inside Draw, next to the frame that shows the "fetching" status. +func (e *ConfigEditor) pollFetch() { + e.fetchMu.Lock() + if !e.fetch.done { + e.fetchMu.Unlock() + return + } + result := e.fetch + e.fetch = fetchState{} + e.fetchMu.Unlock() + + if result.err != nil { + e.setError("model list: " + result.err.Error()) + return + } + if len(result.ids) == 0 { + e.setError("model list: the provider returned no models") + return + } + m := e.modelAt(result.index) + if m == nil || m.Name != result.name { + e.setError("model list: the model changed while fetching — press f again") + return + } + options := make([]option, 0, len(result.ids)) + for _, id := range result.ids { + options = append(options, opt(id, id, "advertised by the provider")) + } + e.openKeyPicker("Model list", modelKey(result.index, fName), options) +} + +func (e *ConfigEditor) openKeyPicker(title, key string, options []option) { + if len(options) == 0 { + return + } + current := e.rawFor(key) + items := make([]listpicker.Item, 0, len(options)) + for _, o := range options { + badge := "" + if o.value == current { + badge = "current" + } + items = append(items, listpicker.Item{ID: o.value, Primary: o.label, Badge: badge, Detail: o.desc}) + } + e.picker = listpicker.Picker{ + Theme: e.theme, + OnAccept: func(item listpicker.Item) { e.commit(key, item.ID) }, + } + e.picker.Show(items, listpicker.ShowConfig{ + Title: title, + PrimaryWidth: pickerPrimaryWidth, + LeadingWidth: 1, + }) +} + +func (e *ConfigEditor) handleMouse(ctx *components.EventContext, m xui.MouseEvent) { + // Modals own the pointer: while one is up the form behind it must not move. + // A press dismisses the picker (Esc-equivalent); the confirm modal is + // keyboard-only. + if e.picker.Open { + if m.Action == xui.MousePress { + e.picker.Hide() + ctx.ConsumeAndRedraw() + return + } + ctx.Consume = true + return + } + if e.confirm != nil { + ctx.Consume = true + return + } + switch { + case m.Button == xui.MouseWheelUp: + e.move(-max(m.Wheel, 1)) + case m.Button == xui.MouseWheelDown: + e.move(max(m.Wheel, 1)) + case m.Action == xui.MousePress && m.Button == xui.MouseLeft: + // Clicking away from an inline edit drops it, matching Esc: a click must + // never keep typing into the field the caret just left. + if e.edit != nil { + e.edit = nil + } + if index := e.rowAt(m.Y); index >= 0 { + e.setCursor(index) + } + default: + ctx.Consume = true + return + } + ctx.ConsumeAndRedraw() +} + +// rowAt maps a screen row to a selectable row index, or -1. +func (e *ConfigEditor) rowAt(y int) int { + if e.page <= 0 || y < e.rowsTop || y >= e.rowsTop+e.page { + return -1 + } + index := e.scroll + (y - e.rowsTop) + if index < 0 || index >= len(e.rows) || e.rows[index].kind == rowSection { + return -1 + } + return index +} + +func (e *ConfigEditor) setError(msg string) { + e.status, e.statusKind = msg, statusError +} + +func (e *ConfigEditor) setStatus(msg string) { + e.status, e.statusKind = msg, statusInfo +} + +func (e *ConfigEditor) themeOrDefault() components.Theme { + if e.theme.Border.Fg.Kind == 0 && e.theme.Foreground.Fg.Kind == 0 { + return components.DefaultTheme() + } + return e.theme +} diff --git a/internal/tui/configui/editor_test.go b/internal/tui/configui/editor_test.go new file mode 100644 index 00000000..8f0c55bf --- /dev/null +++ b/internal/tui/configui/editor_test.go @@ -0,0 +1,729 @@ +package configui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pulseaiclub/xui" + + "github.com/pulseaiclub/phi/internal/components" + "github.com/pulseaiclub/phi/internal/project" +) + +// newTestEditor builds an editor over a fake home, so paths in the chrome stay +// short and "~" shortening is exercised. +func newTestEditor(t *testing.T, doc *project.ConfigDoc) *ConfigEditor { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + e := New(doc, filepath.Join(home, ".phi", "config.yaml"), filepath.Join(home, ".phi", "skills"), + components.DarkTheme(), nil, nil) + require.NotEmpty(t, e.rows) + return e +} + +// focus moves the cursor onto key, failing the test when the row is missing. +func focus(t *testing.T, e *ConfigEditor, key string) { + t.Helper() + for i := range e.rows { + if e.rows[i].key == key { + e.setCursor(i) + require.Equal(t, key, e.rows[e.cursor].key) + return + } + } + t.Fatalf("no row with key %q", key) +} + +func press(e *ConfigEditor, code xui.KeyCode, r rune) *components.EventContext { + ctx := &components.EventContext{} + e.Handle(ctx, xui.KeyEvent{Code: code, Rune: r, Press: true}) + return ctx +} + +func typeRune(e *ConfigEditor, r rune) { + press(e, xui.KeyRune, r) +} + +func sampleDoc() *project.ConfigDoc { + return &project.ConfigDoc{ + Models: []project.ModelDoc{ + {Name: "model-a", APIKey: "key-a", BaseURL: "https://a.example/v1", Default: true}, + {Name: "model-b", APIKey: "key-b", BaseURL: "https://b.example/v1"}, + }, + } +} + +func TestBuildRowsCoverEverySection(t *testing.T) { + e := newTestEditor(t, sampleDoc()) + + var sections []string + for _, r := range e.rows { + if r.kind == rowSection { + sections = append(sections, r.title) + } + } + assert.Equal(t, []string{"Models", "Skills", "Permissions", "Bash", "Sub-agents"}, sections) + + // Every row key must be addressable, and the first model's default badge + // must follow the document. + require.Equal(t, "default", e.rows[1].badge) + assert.Equal(t, "model-a", e.rows[1].name) + assert.Empty(t, e.rows[2].badge) +} + +func TestCursorSkipsSectionHeaders(t *testing.T) { + e := newTestEditor(t, sampleDoc()) + for range len(e.rows) * 2 { + e.move(1) + require.NotEqual(t, rowSection, e.rows[e.cursor].kind, "cursor landed on a section header") + } + for range len(e.rows) * 2 { + e.move(-1) + require.NotEqual(t, rowSection, e.rows[e.cursor].kind, "cursor landed on a section header") + } + + e.jumpEdge(true) + require.NotEqual(t, rowSection, e.rows[e.cursor].kind) + e.jumpEdge(false) + require.NotEqual(t, rowSection, e.rows[e.cursor].kind) +} + +func TestEditModelNameRejectsEmpty(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, modelKey(0, fName)) + press(e, xui.KeyEnter, 0) + require.NotNil(t, e.edit, "Enter must open the inline editor") + + press(e, xui.KeyBackspace, 0) + for range len("model-a") - 1 { + press(e, xui.KeyBackspace, 0) + } + press(e, xui.KeyEnter, 0) + require.NotNil(t, e.edit, "an empty name must keep the editor open") + assert.Equal(t, "model-a", doc.Models[0].Name) + assert.Equal(t, statusError, e.statusKind) + + typeRune(e, 'x') + press(e, xui.KeyEnter, 0) + assert.Nil(t, e.edit) + assert.Equal(t, "x", doc.Models[0].Name) + assert.True(t, e.Dirty()) +} + +func TestEditKeyPromptUsesNoCLIValues(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, modelKey(0, fKey)) + press(e, xui.KeyEnter, 0) + require.NotNil(t, e.edit) + assert.Equal(t, "key-a", string(e.edit.buf), "editing starts from the stored value") + + press(e, xui.KeyEscape, 0) + assert.Nil(t, e.edit) + assert.False(t, e.Dirty(), "canceling an edit must not dirty the document") +} + +func TestCycleChoiceAndTriState(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, keyPermMode) + assert.Nil(t, doc.Permissions) + press(e, xui.KeyRight, 0) + require.NotNil(t, doc.Permissions) + assert.Equal(t, "interactive", doc.Permissions.Mode) + press(e, xui.KeyLeft, 0) + assert.Empty(t, doc.Permissions.Mode) + + focus(t, e, modelKey(0, fImage)) + press(e, xui.KeyRight, 0) + require.NotNil(t, doc.Models[0].ImageEnabled) + assert.True(t, *doc.Models[0].ImageEnabled) + press(e, xui.KeyRight, 0) + require.NotNil(t, doc.Models[0].ImageEnabled) + assert.False(t, *doc.Models[0].ImageEnabled) + press(e, xui.KeyRight, 0) + assert.Nil(t, doc.Models[0].ImageEnabled, "the third step must return to auto (absent)") +} + +func TestDefaultModelIsExclusive(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, modelKey(1, fDeflt)) + press(e, xui.KeyEnter, 0) + assert.False(t, doc.Models[0].Default) + assert.True(t, doc.Models[1].Default) +} + +func TestAddAndDeleteModel(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + typeRune(e, 'a') + require.Len(t, doc.Models, 3) + assert.Equal(t, "new model — type a model id", e.status) + require.NotNil(t, e.edit, "adding a model opens the name editor") + typeRune(e, 'c') + press(e, xui.KeyEnter, 0) + assert.Equal(t, "c", doc.Models[2].Name) + + focus(t, e, modelKey(2, fName)) + typeRune(e, 'd') + require.NotNil(t, e.confirm, "deleting a model must confirm first") + assert.False(t, e.confirm.yes, "a destructive confirm must default to No") + assert.Len(t, doc.Models, 3) + + press(e, xui.KeyEnter, 0) // Enter takes the default: keep the model. + assert.Len(t, doc.Models, 3) + + typeRune(e, 'd') + typeRune(e, 'y') + assert.Nil(t, e.confirm) + assert.Len(t, doc.Models, 2) + + // Deleting the default promotes the next model, so the file never relies on + // the loader's implicit first-entry rule. + focus(t, e, modelKey(0, fName)) + typeRune(e, 'd') + typeRune(e, 'y') + require.Len(t, doc.Models, 1) + assert.True(t, doc.Models[0].Default) +} + +// The confirm rows are painted Yes then No, so the keys must follow that order: +// up/left to the row above, down/right/tab to the row below. Reversed keys made +// the highlight jump at the same key the user pressed and stand still on a +// second press, which read as an unresponsive dialog. +func TestConfirmArrowsFollowRowOrder(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, modelKey(0, fName)) + typeRune(e, 'd') + require.NotNil(t, e.confirm) + require.False(t, e.confirm.yes, "a destructive confirm starts on No, the second row") + + press(e, xui.KeyUp, 0) + assert.True(t, e.confirm.yes, "↑ selects the first row (Yes)") + press(e, xui.KeyDown, 0) + assert.False(t, e.confirm.yes, "↓ selects the second row (No)") + + press(e, xui.KeyLeft, 0) + assert.True(t, e.confirm.yes, "← is the same axis as ↑") + press(e, xui.KeyRight, 0) + assert.False(t, e.confirm.yes, "→ is the same axis as ↓") + + press(e, xui.KeyRune, 'k') + assert.True(t, e.confirm.yes) + press(e, xui.KeyRune, 'j') + assert.False(t, e.confirm.yes) + + press(e, xui.KeyEscape, 0) + assert.Nil(t, e.confirm) + assert.Len(t, doc.Models, 2, "a cancel must not delete the model") +} + +func TestDeleteModelCanBeCancelled(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, modelKey(0, fName)) + typeRune(e, 'd') + press(e, xui.KeyEscape, 0) + assert.Nil(t, e.confirm) + assert.Len(t, doc.Models, 2) +} + +func TestBashRulesInheritUntilEdited(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + // Collapsed, the field says the rules are the built-in ones. + focus(t, e, keyPermBashAllow) + r, _ := e.focused() + assert.True(t, r.inherited) + assert.Contains(t, r.display, "built-in") + + // Expanding lists the effective rules and offers a new-pattern row. + press(e, xui.KeyEnter, 0) + assert.Equal(t, keyPermBashAllow, e.expanded) + require.Greater(t, len(e.rows), len(e.listItems(keyPermBashAllow))) + assert.Nil(t, doc.Permissions, "looking at the rules must not write them") + r, _ = e.focused() + assert.Equal(t, rowItem, r.kind) + + // Editing one rule materializes the whole list, built-ins included. + press(e, xui.KeyEnter, 0) + require.NotNil(t, e.edit) + assert.Equal(t, e.listItems(keyPermBashAllow)[0], string(e.edit.buf)) + press(e, xui.KeyEnd, 0) + typeRune(e, 'x') + press(e, xui.KeyEnter, 0) + require.NotNil(t, doc.Permissions) + require.NotNil(t, doc.Permissions.Bash) + require.NotNil(t, doc.Permissions.Bash.Allow) + assert.Len(t, *doc.Permissions.Bash.Allow, len(e.listItems(keyPermBashAllow))) +} + +func TestAddBashRuleRow(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, keyPermBashAllow) + press(e, xui.KeyEnter, 0) + focus(t, e, listAddKey(keyPermBashAllow)) + press(e, xui.KeyEnter, 0) + require.NotNil(t, e.edit) + + for _, r := range "^make " { + typeRune(e, r) + } + press(e, xui.KeyEnter, 0) + require.NotNil(t, doc.Permissions.Bash.Allow) + assert.Equal(t, "^make ", (*doc.Permissions.Bash.Allow)[len(*doc.Permissions.Bash.Allow)-1]) + // The cursor lands on the row it just created. + assert.Equal(t, listItemKey(keyPermBashAllow, len(*doc.Permissions.Bash.Allow)-1), e.rows[e.cursor].key) +} + +func TestDeleteBashRule(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, keyPermBashDeny) + press(e, xui.KeyEnter, 0) + before := len(e.listItems(keyPermBashDeny)) + press(e, xui.KeyEnter, 0) // edit + press(e, xui.KeyEscape, 0) + typeRune(e, 'd') + require.NotNil(t, doc.Permissions.Bash.Deny) + assert.Len(t, *doc.Permissions.Bash.Deny, before-1) +} + +func TestSaveWritesFileAndBackup(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".phi") + require.NoError(t, os.MkdirAll(dir, 0o755)) + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("models:\n - name: old\n api_key: k\n"), 0o600)) + + doc, err := project.ReadConfigDoc(path) + require.NoError(t, err) + e := New(doc, path, "/skills", components.DarkTheme(), nil, nil) + + focus(t, e, modelKey(0, fName)) + press(e, xui.KeyEnter, 0) + press(e, xui.KeyEnd, 0) + for _, r := range "-2" { + typeRune(e, r) + } + press(e, xui.KeyEnter, 0) + + press(e, xui.KeyRune, 's') + assert.Equal(t, statusSuccess, e.statusKind, e.status) + assert.False(t, e.Dirty()) + + saved, err := project.ReadConfigDoc(path) + require.NoError(t, err) + require.Len(t, saved.Models, 1) + assert.Equal(t, "old-2", saved.Models[0].Name) + + backup, err := os.ReadFile(path + ".bak") + require.NoError(t, err) + assert.Contains(t, string(backup), "name: old") + + // The written file must load through the runtime parser. + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + p, err := project.Discover("") + require.NoError(t, err) + require.NoError(t, p.LoadConfig()) + assert.Equal(t, "old-2", p.Config().Model().Name) +} + +func TestSaveRefusesInvalidDocument(t *testing.T) { + doc := &project.ConfigDoc{Models: []project.ModelDoc{{Name: "", APIKey: "k"}}} + e := New(doc, filepath.Join(t.TempDir(), "config.yaml"), "/skills", components.DarkTheme(), nil, nil) + + press(e, xui.KeyRune, 's') + assert.Equal(t, statusError, e.statusKind) + assert.Contains(t, e.status, "no name") +} + +func TestQuitGuardsUnsavedChanges(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + ctx := press(e, xui.KeyEscape, 0) + assert.True(t, ctx.Quit, "a clean editor quits on Esc") + + focus(t, e, keyPermWorkspace) + typeRune(e, ' ') // toggle a bool through the space shortcut + require.True(t, e.Dirty()) + + ctx = press(e, xui.KeyEscape, 0) + require.NotNil(t, e.confirm) + assert.False(t, ctx.Quit) + + ctx = press(e, xui.KeyEscape, 0) // cancel the confirm + assert.Nil(t, e.confirm) + assert.False(t, ctx.Quit) + + press(e, xui.KeyEscape, 0) + ctx = press(e, xui.KeyRune, 'y') // discard + assert.True(t, ctx.Quit) +} + +func TestEscapeClosesExpandedListFirst(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, keyPermBashAllow) + press(e, xui.KeyEnter, 0) + require.NotEmpty(t, e.expanded) + + ctx := press(e, xui.KeyEscape, 0) + assert.Empty(t, e.expanded) + assert.False(t, ctx.Quit) + assert.Equal(t, keyPermBashAllow, e.rows[e.cursor].key) +} + +func TestChoicePickerCommitsSelection(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + focus(t, e, keyAgentsExplore) + press(e, xui.KeyEnter, 0) + require.True(t, e.picker.Open) + + press(e, xui.KeyEnter, 0) // first row is "(inherit)". + assert.False(t, e.picker.Open) + require.NotNil(t, doc.Agents) + require.NotNil(t, doc.Agents.Models) + assert.Empty(t, doc.Agents.Models.Explore) + + focus(t, e, keyAgentsExplore) + press(e, xui.KeyEnter, 0) + require.True(t, e.picker.Open) + typeRune(e, 'b') // filter to model-b + press(e, xui.KeyEnter, 0) + assert.Equal(t, "model-b", doc.Agents.Models.Explore) +} + +func TestDrawPaintsFormAndStatus(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + + ctx := components.DrawContext{Max: components.Size{Width: 100, Height: 48}, Method: xui.WidthUnicode} + surf := e.Draw(ctx) + text := components.SurfaceText(surf) + + assert.Contains(t, text, "phi config") + assert.Contains(t, text, "model-a") + assert.Contains(t, text, "~/.phi/config.yaml") + assert.Contains(t, text, "auto") + assert.Contains(t, text, "MODELS") + assert.Contains(t, text, "PERMISSIONS") + assert.Contains(t, text, "allow patterns") + assert.Contains(t, text, "built-in · ") + assert.Contains(t, text, "↑↓ move") + + // The caret follows the inline editor. + focus(t, e, modelKey(0, fName)) + press(e, xui.KeyEnter, 0) + surf = e.Draw(ctx) + require.NotNil(t, surf.Cursor) + assert.Positive(t, surf.Cursor.Y) + + // An unsaved document is called out in the title bar. + typeRune(e, 'x') + press(e, xui.KeyEnter, 0) + surf = e.Draw(ctx) + assert.Contains(t, components.SurfaceText(surf), "●") +} + +// modelHeader returns the painted header line of one model, without the right +// border or trailing padding. Header rows start at xModel, field rows at +// xModelField, so the leading cell fixes which one this is. +func modelHeader(t *testing.T, text, name string) string { + t.Helper() + for l := range strings.SplitSeq(text, "\n") { + if strings.HasPrefix(l, "│ "+name) { + return strings.TrimRight(l, " │") + } + } + require.FailNow(t, "no model header painted", "name=%q", name) + return "" +} + +func TestDrawAlignsModelHeadersToValueColumn(t *testing.T) { + window := 1_000_000 + doc := &project.ConfigDoc{Models: []project.ModelDoc{ + {Name: "deepseek-v4-flash", API: "OpenAIResponses", ContextWindow: &window, Default: true}, + {Name: "glm-5.2", API: "OpenAIResponses", ContextWindow: &window}, + }} + e := newTestEditor(t, doc) + + surf := e.Draw(components.DrawContext{Max: components.Size{Width: 100, Height: 48}, Method: xui.WidthUnicode}) + col := e.valueCol() + text := components.SurfaceText(surf) + + // The name owns the label column, and the default marker plus the summary + // share the value column: a long name is truncated rather than allowed to + // paint over the badge. + name := "deepseek-v4-flash" + pad := strings.Repeat(" ", col-xModel-len(name)) + assert.Equal(t, "│ "+name+pad+"default · OpenAIResponses · 1M context", modelHeader(t, text, name)) + assert.Equal(t, + "│ glm-5.2"+strings.Repeat(" ", col-xModel-len("glm-5.2"))+"OpenAIResponses · 1M context", + modelHeader(t, text, "glm-5.2")) +} + +func TestDrawIsStableOnTinyScreens(t *testing.T) { + e := newTestEditor(t, sampleDoc()) + for _, size := range []components.Size{{Width: 1, Height: 1}, {Width: 20, Height: 4}, {Width: 40, Height: 6}} { + surf := e.Draw(components.DrawContext{Max: size, Method: xui.WidthUnicode}) + assert.Equal(t, size, surf.Size) + } +} + +func TestMouseWheelMovesAndClickSelects(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + ctx := components.DrawContext{Max: components.Size{Width: 100, Height: 32}, Method: xui.WidthUnicode} + e.Draw(ctx) + + start := e.cursor + ev := &components.EventContext{} + e.Handle(ev, xui.MouseEvent{Action: xui.MousePress, Button: xui.MouseWheelDown, Wheel: 2}) + assert.Equal(t, min(start+2, len(e.rows)-1), e.cursor) + + // A click on a selectable row selects it. + y := e.rowsTop + (1 - e.scroll) + require.GreaterOrEqual(t, y, e.rowsTop) + ev = &components.EventContext{} + e.Handle(ev, xui.MouseEvent{X: 6, Y: y, Action: xui.MousePress, Button: xui.MouseLeft}) + assert.Equal(t, 1, e.cursor) +} + +func TestMouseDropsInlineEditOnClickAway(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + ctx := components.DrawContext{Max: components.Size{Width: 100, Height: 32}, Method: xui.WidthUnicode} + e.Draw(ctx) + + // Open the inline editor on the first model's name. + focus(t, e, modelKey(0, fName)) + e.activate() + require.NotNil(t, e.edit) + typeRune(e, 'X') + + // Click a different row: the edit is dropped (no silent commit), cursor + // moves to the clicked row, and the buffer is gone. + ev := &components.EventContext{} + e.Handle(ev, xui.MouseEvent{X: 6, Y: e.rowsTop + (3 - e.scroll), Action: xui.MousePress, Button: xui.MouseLeft}) + assert.Nil(t, e.edit) + assert.NotEqual(t, modelKey(0, fName), e.rows[e.cursor].key) +} + +func TestMouseDismissesPickerAndIgnoresConfirm(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + ctx := components.DrawContext{Max: components.Size{Width: 100, Height: 32}, Method: xui.WidthUnicode} + e.Draw(ctx) + + // A press while the picker is open closes it without touching the form. + focus(t, e, modelKey(0, fAPI)) + e.activate() // opens the api picker + require.True(t, e.picker.Open) + before := e.cursor + ev := &components.EventContext{} + e.Handle(ev, xui.MouseEvent{X: 5, Y: 5, Action: xui.MousePress, Button: xui.MouseLeft}) + assert.False(t, e.picker.Open) + assert.Equal(t, before, e.cursor) + + // A press while the confirm modal is up is ignored entirely. + e.dirty = true // requestQuit only shows the modal when dirty + e.requestQuit(&components.EventContext{}) + require.NotNil(t, e.confirm) + cur := e.cursor + ev = &components.EventContext{} + e.Handle(ev, xui.MouseEvent{X: 5, Y: 5, Action: xui.MousePress, Button: xui.MouseLeft}) + require.NotNil(t, e.confirm) + assert.Equal(t, cur, e.cursor) +} + +func TestSkillsFallbackIsShownWhenUnset(t *testing.T) { + doc := sampleDoc() + e := newTestEditor(t, doc) + focus(t, e, keySkillPath) + r, _ := e.focused() + assert.Equal(t, "~/.phi/skills", r.display) + assert.Equal(t, styleMuted, r.style) + // Empty skill_path stays absent from the document. + press(e, xui.KeyEnter, 0) + press(e, xui.KeyEnter, 0) + assert.Nil(t, doc.SkillPath) +} + +func TestFetchDiscardedWhenModelMoves(t *testing.T) { + doc := sampleDoc() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + list := ModelLister(func(_ context.Context, _, _, _, _ string) ([]string, error) { + return []string{"alpha", "beta"}, nil + }) + e := New(doc, filepath.Join(home, ".phi", "config.yaml"), filepath.Join(home, ".phi", "skills"), + components.DarkTheme(), list, func() {}) + + // Start a fetch on model 0, then delete model 0 before the result lands so + // model 1 shifts into its slot. The fetch's captured index/name pair must no + // longer match, and the picker must not open for the wrong model. + focus(t, e, modelKey(0, fName)) + e.fetchModels() + require.True(t, e.fetch.active) + + // Drain the background fetch synchronously: it only touches fetchMu state. + require.Eventually(t, func() bool { + e.fetchMu.Lock() + done := e.fetch.done + e.fetchMu.Unlock() + return done + }, time.Second, time.Millisecond) + + // Delete model 0 (default promotion keeps model 1 as the new default). + e.deleteModel(0) + require.Len(t, doc.Models, 1) + assert.Equal(t, "model-b", doc.Models[0].Name) + + e.pollFetch() + assert.False(t, e.picker.Open, "picker must not open for a model that moved") + assert.Contains(t, e.status, "changed while fetching") +} + +// drawForm paints the editor at a fixed frame size and returns the surface. +func drawForm(t *testing.T, e *ConfigEditor) components.Surface { + t.Helper() + const width, height = 100, 40 + return e.Draw(components.DrawContext{ + Max: components.Size{Width: width, Height: height}, + Method: xui.WidthUnicode, + }) +} + +// formRowY is the screen row a form key landed on in the last Draw. +func formRowY(t *testing.T, e *ConfigEditor, key string) int { + t.Helper() + for i := range e.rows { + if e.rows[i].key == key { + return 1 + i - e.scroll + } + } + t.Fatalf("no row with key %q", key) + return 0 +} + +func paintedStyle(t *testing.T, out components.Surface, x, y int) xui.Style { + t.Helper() + require.Less(t, x, out.Size.Width) + require.Less(t, y, out.Size.Height) + return out.Buffer[y*out.Size.Width+x].Style +} + +// TestFormColumnsKeepOneQuietTone locks the two-column color grammar: the label +// column is one quiet tone, the value column carries the data, and a value the +// file does not set drops back to the label tone instead of inventing a third +// shade of gray. +func TestFormColumnsKeepOneQuietTone(t *testing.T) { + // Only the name and the masked api key are set; every other field shows + // what the loader would supply instead. + doc := &project.ConfigDoc{Models: []project.ModelDoc{{Name: "model-a", APIKey: "key-a"}}} + e := newTestEditor(t, doc) + th := components.DarkTheme() + require.NotEqual(t, th.Muted.Fg, th.Foreground.Fg, "fixture needs a theme with two distinct tones") + // Park the cursor on the last row so the asserted rows are not selected. + focus(t, e, modelKey(0, fDeflt)) + out := drawForm(t, e) + col := e.valueCol() + quiet := secondaryStyle(th) + + nameY := formRowY(t, e, modelKey(0, fName)) + label := paintedStyle(t, out, xModelField, nameY) + assert.Equal(t, quiet.Fg, label.Fg, "the label column keeps the quiet tone") + assert.False(t, label.Dim, "Dim ranges from soft to invisible; labels must not rely on it") + assert.Equal(t, th.Foreground.Fg, paintedStyle(t, out, col, nameY).Fg, "a set value is body text") + + keyY := formRowY(t, e, modelKey(0, fKey)) + assert.Equal(t, th.Foreground.Fg, paintedStyle(t, out, col, keyY).Fg, + "a masked key is set, so it reads as data rather than as an empty field") + + // base_url and context_window are absent from the document. + for _, key := range []string{modelKey(0, fURL), modelKey(0, fCtx)} { + assert.Equal(t, quiet.Fg, paintedStyle(t, out, col, formRowY(t, e, key)).Fg, + "%s must not look set", key) + } +} + +// TestSelectedRowIsOneStripe pins what a cursor used to do to the two columns: a +// hole in the stripe behind a trailing hint, "not set" promoted into looking +// exactly like a value, and emphasis moved onto the label column. +func TestSelectedRowIsOneStripe(t *testing.T) { + doc := &project.ConfigDoc{Models: []project.ModelDoc{{Name: "model-a"}}} + e := newTestEditor(t, doc) + th := components.DarkTheme() + + // base_url is unset, so the row paints a placeholder plus a trailing hint. + key := modelKey(0, fURL) + focus(t, e, key) + out := drawForm(t, e) + y := formRowY(t, e, key) + + for x := 1; x < out.Size.Width-1; x++ { + assert.Equal(t, th.SelectionBg.Bg, paintedStyle(t, out, x, y).Bg, + "cell %d of the selected row must keep the stripe background", x) + } + + label := paintedStyle(t, out, xModelField, y) + empty := paintedStyle(t, out, e.valueCol(), y) + assert.Equal(t, th.SelectionFg.Fg, label.Fg, "the selected label stays readable") + assert.False(t, label.Bold, "the label never outweighs the value") + assert.Equal(t, secondaryStyle(th).Fg, empty.Fg, "a selected empty field still reads as empty") + assert.False(t, empty.Dim, "the quiet tone stays flat, on the stripe too") + + // A filled row puts the emphasis on the value, not on the label. + focus(t, e, modelKey(0, fName)) + out = drawForm(t, e) + y = formRowY(t, e, modelKey(0, fName)) + filledLabel := paintedStyle(t, out, xModelField, y) + filled := paintedStyle(t, out, e.valueCol(), y) + assert.Equal(t, th.SelectionFg.Fg, filled.Fg) + assert.True(t, filled.Bold, "the value column carries the emphasis") + assert.False(t, filledLabel.Bold) +} + +func TestHumanTokens(t *testing.T) { + cases := map[int]string{ + 0: "", + 999: "999", + 128000: "128k", + 1000000: "1M", + 1280000: "1.3M", + 2000000: "2M", + } + for n, want := range cases { + assert.Equal(t, want, humanTokens(n), "n=%d", n) + } +} diff --git a/internal/tui/configui/model.go b/internal/tui/configui/model.go new file mode 100644 index 00000000..45b93e91 --- /dev/null +++ b/internal/tui/configui/model.go @@ -0,0 +1,924 @@ +package configui + +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/pulseaiclub/phi/internal/llm" + "github.com/pulseaiclub/phi/internal/permission" + "github.com/pulseaiclub/phi/internal/project" + "github.com/pulseaiclub/phi/internal/project/model" +) + +// Field keys are stable ids: the cursor, the list editor, and tests all address +// fields by key, never by row index, so a rebuild may reorder rows freely. +const ( + keySkillPath = "skill_path" + + keyPermMode = "perm.mode" + keyPermWorkspace = "perm.workspace_only_writes" + keyPermTimeout = "perm.ask_timeout_sec" + keyPermDangerous = "perm.dangerously_allow_all" + keyPermBashDefault = "perm.bash.default" + keyPermBashAllow = "perm.bash.allow" + keyPermBashDeny = "perm.bash.deny" + + keyAgentsEnabled = "agents.enabled" + keyAgentsExplore = "agents.models.explore" + keyAgentsReview = "agents.models.review" + keyAgentsWorker = "agents.models.worker" +) + +// Model fields live under model... +const ( + fName = "name" + fKey = "api_key" + fURL = "base_url" + fAPI = "api" + fCtx = "context_window" + fImage = "image_enabled" + fThink = "think_level" + fDeflt = "default" +) + +// defaultBaseURL mirrors the endpoint project.parseConfigFile falls back to. +const defaultBaseURL = "https://api.openai.com/v1" + +// Tri-state toggle encodings. A bool field is written as absent (auto, the +// loader default), on, or off. +const ( + triAuto = "auto" + triOn = "on" + triOff = "off" +) + +func modelKey(index int, field string) string { + return "model." + strconv.Itoa(index) + "." + field +} + +// splitModelKey parses model... +func splitModelKey(key string) (index int, field string, ok bool) { + rest, found := strings.CutPrefix(key, "model.") + if !found { + return 0, "", false + } + rawIndex, field, found := strings.Cut(rest, ".") + if !found { + return 0, "", false + } + index, err := strconv.Atoi(rawIndex) + if err != nil || index < 0 { + return 0, "", false + } + return index, field, true +} + +func listItemKey(key string, index int) string { + return key + "#" + strconv.Itoa(index) +} + +func listAddKey(key string) string { return key + "#new" } + +// splitListKey parses #, where index -1 marks the add row. +func splitListKey(key string) (field string, index int, ok bool) { + base, suffix, found := strings.Cut(key, "#") + if !found { + return "", 0, false + } + if suffix == "new" { + return base, -1, true + } + index, err := strconv.Atoi(suffix) + if err != nil || index < 0 { + return "", 0, false + } + return base, index, true +} + +type rowKind int + +const ( + rowSection rowKind = iota + rowModel + rowField + rowItem + rowAdd +) + +type fieldKind int + +const ( + kindText fieldKind = iota + kindSecret + kindInt + kindBool // tri-state: auto / on / off + kindPlainBool // plain bool with a default of off + kindChoice + kindList +) + +// option is one choice value. An empty value drops the key from the file, +// which is how the loader's "use the default" state is expressed. +type option struct { + value string + label string + desc string +} + +func opt(value, label, desc string) option { return option{value: value, label: label, desc: desc} } + +// valueStyle picks a semantic color for a rendered value. +type valueStyle int + +const ( + styleValue valueStyle = iota + styleMuted + styleOn + styleWarn +) + +// row is one painted line of the form. Rows are a view model: build rebuilds +// them from the document, and apply writes a single field back. +type row struct { + kind rowKind + key string + + // rowSection + title string + note string + + // rowModel + model int + name string + badge string + + // rowField / rowItem / rowAdd + field fieldKind + label string + raw string + display string + style valueStyle + hint string + options []option + items []string + listKey string // owning field key for rowItem / rowAdd + inherited bool // list rows showing built-in rules rather than file rules +} + +// fieldSpec declares one editable field; fieldRow renders it against the +// document. +type fieldSpec struct { + key string + label string + kind fieldKind + options []option + placeholder string + // defaultOn is the loader's value when the bool key is absent. + defaultOn bool +} + +var ( + apiOptions = []option{ + opt("", "auto", "infer from the model name"), + opt(string(llm.OpenAI), "OpenAI", "chat completions"), + opt(string(llm.OpenAIResponses), "OpenAI Responses", "responses API"), + opt(string(llm.Anthropic), "Anthropic", "messages API"), + opt(string(llm.Gemini), "Gemini", "generateContent"), + } + thinkOptions = []option{ + opt("", "auto", "model or preset default"), + opt(string(llm.Off), "off", "no reasoning"), + opt(string(llm.Minimal), "minimal", "shortest reasoning"), + opt(string(llm.Low), "low", "light reasoning"), + opt(string(llm.Medium), "medium", "balanced"), + opt(string(llm.High), "high", "deep reasoning"), + opt(string(llm.XHigh), "xhigh", "deeper still"), + opt(string(llm.Max), "max", "provider maximum"), + } + permModeOptions = []option{ + opt("", "auto", "interactive: ask before gated tools"), + opt(string(permission.ModeInteractive), "interactive", "ask before gated tools"), + opt(string(permission.ModeReadonly), "readonly", "deny writes, keep reads"), + opt(string(permission.ModeAutopilot), "autopilot", "fold ask to allow"), + opt(string(permission.ModeHeadlessStrict), "headless-strict", "fold ask to deny"), + } + bashDefaultOptions = []option{ + opt("", "auto", "policy default (ask)"), + opt("ask", "ask", "prompt before running"), + opt("allow", "allow", "run without asking"), + opt("deny", "deny", "never run"), + } +) + +// build rebuilds rows from the document. The caller re-anchors the cursor +// afterwards; see ConfigEditor.refocus. +func (e *ConfigEditor) build() { + e.rows = e.rows[:0] + e.buildModels() + e.addSection("Skills", "") + skills := displayPath(e.skills) + e.addField(e.fieldRow(fieldSpec{key: keySkillPath, label: "skill path", kind: kindText, placeholder: skills})) + e.addSection("Permissions", "") + e.addField(e.fieldRow(fieldSpec{key: keyPermMode, label: "mode", kind: kindChoice, options: permModeOptions})) + e.addField(e.fieldRow(fieldSpec{ + key: keyPermWorkspace, label: "workspace only writes", kind: kindBool, defaultOn: true, + })) + e.addField(e.fieldRow(fieldSpec{ + key: keyPermTimeout, label: "ask timeout", kind: kindInt, placeholder: strconv.Itoa(defaultTimeout()), + })) + e.addField(e.fieldRow(fieldSpec{key: keyPermDangerous, label: "allow everything", kind: kindBool})) + e.addSection("Bash", "") + e.addField(e.fieldRow(fieldSpec{ + key: keyPermBashDefault, label: "default", kind: kindChoice, options: bashDefaultOptions, + })) + e.addListField(keyPermBashAllow, "allow patterns") + e.addListField(keyPermBashDeny, "deny patterns") + e.addSection("Sub-agents", "") + e.addField(e.fieldRow(fieldSpec{key: keyAgentsEnabled, label: "enabled", kind: kindBool, defaultOn: true})) + for _, role := range []struct{ key, label string }{ + {keyAgentsExplore, "explore model"}, + {keyAgentsReview, "review model"}, + {keyAgentsWorker, "worker model"}, + } { + e.addField(e.fieldRow(fieldSpec{ + key: role.key, label: role.label, kind: kindChoice, options: e.modelRefOptions(), + })) + } +} + +func (e *ConfigEditor) buildModels() { + note := "none yet — press a to add one" + if n := len(e.doc.Models); n == 1 { + note = "1 model" + } else if n > 1 { + note = strconv.Itoa(n) + " models" + } + e.addSection("Models", note) + for i, m := range e.doc.Models { + badge := "" + if m.Default { + badge = "default" + } + name := m.Name + if strings.TrimSpace(name) == "" { + name = "unnamed model" + } + e.rows = append(e.rows, row{ + kind: rowModel, + key: modelKey(i, "header"), + model: i, + name: name, + badge: badge, + note: modelNote(m), + }) + for _, sp := range modelSpecs(m) { + sp.key = modelKey(i, sp.key) + e.addField(e.fieldRow(sp)) + } + } +} + +// modelSpecs lists the editable fields of one model entry. Placeholders show +// what the built-in preset would supply, so an empty field is never a mystery. +func modelSpecs(m project.ModelDoc) []fieldSpec { + preset, known := model.Lookup(m.Name) + baseURL := defaultBaseURL + contextWindow := 0 + image := false + if known { + if preset.Config.BaseURL != "" { + baseURL = preset.Config.BaseURL + } + contextWindow = preset.Config.ContextWindow + image = preset.Config.ImageEnabled + } + return []fieldSpec{ + {key: fName, label: "name", kind: kindText, placeholder: "model id"}, + {key: fKey, label: "api key", kind: kindSecret}, + {key: fURL, label: "base url", kind: kindText, placeholder: baseURL}, + {key: fAPI, label: "api", kind: kindChoice, options: apiOptions}, + {key: fCtx, label: "context window", kind: kindInt, placeholder: humanTokens(contextWindow)}, + {key: fImage, label: "image input", kind: kindBool, defaultOn: image}, + {key: fThink, label: "thinking", kind: kindChoice, options: thinkOptions}, + {key: fDeflt, label: "use by default", kind: kindPlainBool}, + } +} + +func (e *ConfigEditor) modelRefOptions() []option { + out := []option{opt("", "inherit", "parent session model")} + for _, m := range e.doc.Models { + if strings.TrimSpace(m.Name) == "" { + continue + } + out = append(out, opt(m.Name, m.Name, "configured model")) + } + return out +} + +func (e *ConfigEditor) addSection(title, note string) { + e.rows = append(e.rows, row{kind: rowSection, key: "§" + title, title: title, note: note}) +} + +func (e *ConfigEditor) addField(r row) { e.rows = append(e.rows, r) } + +// addListField appends a list field and, while it is expanded, its item rows. +func (e *ConfigEditor) addListField(key, label string) { + items := e.listItems(key) + inherited := !e.listSet(key) + display := plural(len(items), "pattern") + style := styleValue + if inherited { + display, style = "built-in · "+plural(len(items), "pattern"), styleMuted + } + e.addField(row{ + kind: rowField, key: key, field: kindList, label: label, + raw: strconv.Itoa(len(items)), display: display, style: style, + items: items, listKey: key, inherited: inherited, + }) + if e.expanded != key { + return + } + for i, item := range items { + style := styleValue + if inherited { + style = styleMuted + } + e.addField(row{ + kind: rowItem, key: listItemKey(key, i), label: item, style: style, + items: items, listKey: key, inherited: inherited, + }) + } + e.addField(row{ + kind: rowAdd, key: listAddKey(key), label: addLabel(inherited), + style: styleMuted, listKey: key, inherited: inherited, + }) +} + +// addLabel names the "add a rule" row; a list that is still inheriting gets an +// explicit warning that the first edit writes the built-in rules out too. +func addLabel(inherited bool) string { + if inherited { + return "+ new pattern (keeps built-in rules)" + } + return "+ new pattern" +} + +// fieldRow renders one field. It is the only place the value column is built, +// so styling stays consistent across sections. +func (e *ConfigEditor) fieldRow(sp fieldSpec) row { + raw := e.rawFor(sp.key) + r := row{kind: rowField, key: sp.key, field: sp.kind, label: sp.label, raw: raw, options: sp.options} + switch sp.kind { + case kindText: + r.display, r.style = raw, styleValue + if raw == "" { + r.display, r.style, r.hint = unset(sp.placeholder), styleMuted, sp.placeholder + } + case kindSecret: + if raw == "" { + r.display, r.style = "not set", styleWarn + } else { + // A key that is set is data like any other value: bright dots say + // "set", while the quiet tone is reserved for absent or inherited + // values. + r.display, r.style = maskSecret(raw), styleValue + } + case kindInt: + r.display, r.style = raw, styleValue + if raw == "" { + r.display, r.style = unset(sp.placeholder), styleMuted + r.hint = "loader default" + } + case kindBool: + switch raw { + case triOn: + r.display, r.style = triOn, styleOn + case triOff: + r.display, r.style = triOff, styleValue + default: + r.display, r.style = triAuto, styleMuted + r.hint = "loader default: " + onOff(sp.defaultOn) + } + case kindPlainBool: + r.display, r.style = triOff, styleValue + if raw == triOn { + r.display, r.style = triOn, styleOn + } + case kindChoice: + label, desc := optionFor(sp.options, raw) + r.display, r.style, r.hint = label, styleValue, desc + if raw == "" { + r.style = styleMuted + } + } + return r +} + +// unset renders an empty field: the loader default when one is known, an +// em dash when the value only exists in the file. +func unset(fallback string) string { + if fallback == "" { + return "—" + } + return fallback +} + +func optionFor(options []option, value string) (label, desc string) { + for _, o := range options { + if o.value == value { + return o.label, o.desc + } + } + // A hand-edited file may hold a value the picker does not offer; show it + // rather than silently rewriting it. + return value, "not a known value" +} + +// rawFor returns the canonical string form of a field's value — the same +// encoding apply accepts back. "" means the key is absent. +func (e *ConfigEditor) rawFor(key string) string { + if index, field, ok := splitModelKey(key); ok { + m := e.modelAt(index) + if m == nil { + return "" + } + switch field { + case fName: + return m.Name + case fKey: + return m.APIKey + case fURL: + return m.BaseURL + case fAPI: + return m.API + case fCtx: + return intString(m.ContextWindow) + case fImage: + return triString(m.ImageEnabled) + case fThink: + return m.ThinkLevel + case fDeflt: + return onOff(m.Default) + } + return "" + } + if base, index, ok := splitListKey(key); ok { + items := e.listItems(base) + if index < 0 || index >= len(items) { + return "" + } + return items[index] + } + switch key { + case keySkillPath: + return stringValue(e.doc.SkillPath) + case keyPermMode: + return permString(e.doc, func(p *project.PermDoc) string { return p.Mode }) + case keyPermWorkspace: + return triString(permBool(e.doc, func(p *project.PermDoc) *bool { return p.WorkspaceOnlyWrites })) + case keyPermTimeout: + return intString(permInt(e.doc, func(p *project.PermDoc) *int { return p.AskTimeoutSec })) + case keyPermDangerous: + return triString(permBool(e.doc, func(p *project.PermDoc) *bool { return p.DangerouslyAllowAll })) + case keyPermBashDefault: + return bashString(e.doc, func(b *project.BashDoc) string { return b.Default }) + case keyAgentsEnabled: + return triString(agentsBool(e.doc, func(a *project.AgentsDoc) *bool { return a.Enabled })) + case keyAgentsExplore, keyAgentsReview, keyAgentsWorker: + return agentsString(e.doc, func(m *project.AgentsRoleModels) string { + switch key { + case keyAgentsExplore: + return m.Explore + case keyAgentsReview: + return m.Review + default: + return m.Worker + } + }) + } + return "" +} + +// apply writes one field. An error leaves the document untouched so the editor +// can report it and keep the user in the field. +func (e *ConfigEditor) apply(key, value string) error { + if index, field, ok := splitModelKey(key); ok { + return e.applyModel(index, field, value) + } + if base, index, ok := splitListKey(key); ok { + return e.applyListItem(base, index, value) + } + switch key { + case keySkillPath: + e.doc.SkillPath = optionalString(strings.TrimSpace(value)) + case keyPermMode: + perms(e.doc).Mode = strings.TrimSpace(value) + case keyPermWorkspace: + on, err := parseTri(value) + if err != nil { + return err + } + perms(e.doc).WorkspaceOnlyWrites = on + case keyPermTimeout: + secs, err := parsePositiveInt(value, "seconds") + if err != nil { + return err + } + perms(e.doc).AskTimeoutSec = secs + case keyPermDangerous: + on, err := parseTri(value) + if err != nil { + return err + } + perms(e.doc).DangerouslyAllowAll = on + case keyPermBashDefault: + bash(e.doc).Default = value + case keyAgentsEnabled: + on, err := parseTri(value) + if err != nil { + return err + } + agents(e.doc).Enabled = on + case keyAgentsExplore, keyAgentsReview, keyAgentsWorker: + role := strings.TrimPrefix(key, "agents.models.") + switch role { + case "explore": + agentsModels(e.doc).Explore = value + case "review": + agentsModels(e.doc).Review = value + case "worker": + agentsModels(e.doc).Worker = value + default: + return fmt.Errorf("unknown role %q", role) + } + default: + return fmt.Errorf("unknown field %q", key) + } + return nil +} + +func (e *ConfigEditor) applyModel(index int, field, value string) error { + m := e.modelAt(index) + if m == nil { + return fmt.Errorf("model %d no longer exists", index+1) + } + switch field { + case fName: + name := strings.TrimSpace(value) + if name == "" { + return errors.New("model name cannot be empty") + } + m.Name = name + case fKey: + m.APIKey = strings.TrimSpace(value) + case fURL: + m.BaseURL = strings.TrimSpace(value) + case fAPI: + m.API = value + case fCtx: + window, err := parsePositiveInt(value, "tokens") + if err != nil { + return err + } + m.ContextWindow = window + case fImage: + on, err := parseTri(value) + if err != nil { + return err + } + m.ImageEnabled = on + case fThink: + m.ThinkLevel = value + case fDeflt: + on := value == triOn + if on { + for i := range e.doc.Models { + if i != index { + e.doc.Models[i].Default = false + } + } + } + m.Default = on + default: + return fmt.Errorf("unknown model field %q", field) + } + return nil +} + +func (e *ConfigEditor) applyListItem(base string, index int, value string) error { + items := e.listItems(base) + // The pattern is stored verbatim: bash rules are regexes, and trimming one + // would quietly change what it matches. + rule := value + switch { + case index < 0: + if strings.TrimSpace(rule) == "" { + return errors.New("pattern cannot be empty") + } + items = append(items, rule) + case index < len(items): + if strings.TrimSpace(rule) == "" { + return errors.New("pattern cannot be empty") + } + items[index] = rule + default: + return fmt.Errorf("no pattern at %d", index+1) + } + return e.setList(base, items) +} + +// listItems returns the effective rules of a bash list field: the explicit list +// when the key is set, otherwise the loader's built-in defaults. Always a copy, +// so callers may edit it. +func (e *ConfigEditor) listItems(key string) []string { + var list []string + switch key { + case keyPermBashAllow: + list = bashStringList(e.doc, func(b *project.BashDoc) *project.StringList { return b.Allow }) + case keyPermBashDeny: + list = bashStringList(e.doc, func(b *project.BashDoc) *project.StringList { return b.Deny }) + default: + return nil + } + if list == nil { + policy := permission.DefaultPolicy() + if key == keyPermBashAllow { + list = policy.BashAllow + } else { + list = policy.BashDeny + } + } + return append([]string(nil), list...) +} + +// listSet reports whether the rules are written in the file, which is when +// there is no such key. +func (e *ConfigEditor) listSet(key string) bool { + switch key { + case keyPermBashAllow: + return bashStringList(e.doc, func(b *project.BashDoc) *project.StringList { return b.Allow }) != nil + case keyPermBashDeny: + return bashStringList(e.doc, func(b *project.BashDoc) *project.StringList { return b.Deny }) != nil + default: + return false + } +} + +// setList writes rules as an explicit list. An empty list stays explicit on +// purpose: it is how a user says "no patterns at all". +func (e *ConfigEditor) setList(key string, items []string) error { + list := project.StringList(items) + b := bash(e.doc) + switch key { + case keyPermBashAllow: + b.Allow = &list + case keyPermBashDeny: + b.Deny = &list + default: + return fmt.Errorf("unknown rule list %q", key) + } + return nil +} + +// removeListItem drops one rule, materializing the built-in rules first when +// the file had none. +func (e *ConfigEditor) removeListItem(key string, index int) { + items := e.listItems(key) + if index < 0 || index >= len(items) { + return + } + items = append(items[:index:index], items[index+1:]...) + if err := e.setList(key, items); err != nil { + e.setError(err.Error()) + } +} + +func (e *ConfigEditor) modelAt(index int) *project.ModelDoc { + if index < 0 || index >= len(e.doc.Models) { + return nil + } + return &e.doc.Models[index] +} + +func modelNote(m project.ModelDoc) string { + preset, known := model.Lookup(m.Name) + api := m.API + window := 0 + if m.ContextWindow != nil { + window = *m.ContextWindow + } + if known { + if api == "" { + api = string(preset.Config.API) + } + if window == 0 { + window = preset.Config.ContextWindow + } + } + if api == "" { + api = "auto" + } + parts := []string{api} + if window > 0 { + parts = append(parts, humanTokens(window)+" context") + } + return strings.Join(parts, " · ") +} + +// maskSecret keeps the last four characters visible: enough to tell two keys +// apart while editing a list of models, not enough to leak one. +func maskSecret(value string) string { + runes := []rune(value) + if len(runes) <= 8 { + return strings.Repeat("•", len(runes)) + } + return "••••" + string(runes[len(runes)-4:]) +} + +func humanTokens(n int) string { + switch { + case n <= 0: + return "" + case n >= 1_000_000: + // A whole million stays "1M"; a fractional one keeps one digit so a + // 1.28M context is not rounded down to "1M". + if n%1_000_000 == 0 { + return strconv.Itoa(n/1_000_000) + "M" + } + return strconv.FormatFloat(float64(n)/1_000_000, 'f', 1, 64) + "M" + case n >= 1000: + return strconv.Itoa(n/1000) + "k" + default: + return strconv.Itoa(n) + } +} + +func plural(n int, noun string) string { + if n == 1 { + return "1 " + noun + } + return strconv.Itoa(n) + " " + noun + "s" +} + +func onOff(on bool) string { + if on { + return triOn + } + return triOff +} + +func triString(v *bool) string { + if v == nil { + return triAuto + } + return onOff(*v) +} + +func parseTri(value string) (*bool, error) { + switch value { + case triAuto: + return nil, nil + case triOn: + on := true + return &on, nil + case triOff: + off := false + return &off, nil + default: + return nil, fmt.Errorf("expected auto, on, or off — got %q", value) + } +} + +// cycleTri advances the tri-state toggle: auto (absent) → on → off → auto. +func cycleTri(raw string) string { + switch raw { + case triAuto: + return triOn + case triOn: + return triOff + default: + return triAuto + } +} + +func parsePositiveInt(value, unit string) (*int, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + n, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("%q is not a number", value) + } + if n <= 0 { + return nil, fmt.Errorf("must be a positive number of %s", unit) + } + return &n, nil +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func stringValue(v *string) string { + if v == nil { + return "" + } + return *v +} + +func intString(v *int) string { + if v == nil { + return "" + } + return strconv.Itoa(*v) +} + +func defaultTimeout() int { return permission.DefaultPolicy().AskTimeoutSec } + +func perms(doc *project.ConfigDoc) *project.PermDoc { + if doc.Permissions == nil { + doc.Permissions = &project.PermDoc{} + } + return doc.Permissions +} + +func bash(doc *project.ConfigDoc) *project.BashDoc { + p := perms(doc) + if p.Bash == nil { + p.Bash = &project.BashDoc{} + } + return p.Bash +} + +func agents(doc *project.ConfigDoc) *project.AgentsDoc { + if doc.Agents == nil { + doc.Agents = &project.AgentsDoc{} + } + return doc.Agents +} + +func agentsModels(doc *project.ConfigDoc) *project.AgentsRoleModels { + a := agents(doc) + if a.Models == nil { + a.Models = &project.AgentsRoleModels{} + } + return a.Models +} + +func permString(doc *project.ConfigDoc, pick func(*project.PermDoc) string) string { + if doc.Permissions != nil { + return pick(doc.Permissions) + } + return "" +} + +func permBool(doc *project.ConfigDoc, pick func(*project.PermDoc) *bool) *bool { + if doc.Permissions != nil { + return pick(doc.Permissions) + } + return nil +} + +func permInt(doc *project.ConfigDoc, pick func(*project.PermDoc) *int) *int { + if doc.Permissions != nil { + return pick(doc.Permissions) + } + return nil +} + +func bashString(doc *project.ConfigDoc, pick func(*project.BashDoc) string) string { + if doc.Permissions != nil && doc.Permissions.Bash != nil { + return pick(doc.Permissions.Bash) + } + return "" +} + +func bashStringList(doc *project.ConfigDoc, pick func(*project.BashDoc) *project.StringList) []string { + if doc.Permissions == nil || doc.Permissions.Bash == nil { + return nil + } + if list := pick(doc.Permissions.Bash); list != nil { + return *list + } + return nil +} + +func agentsBool(doc *project.ConfigDoc, pick func(*project.AgentsDoc) *bool) *bool { + if doc.Agents != nil { + return pick(doc.Agents) + } + return nil +} + +func agentsString(doc *project.ConfigDoc, pick func(*project.AgentsRoleModels) string) string { + if doc.Agents != nil && doc.Agents.Models != nil { + return pick(doc.Agents.Models) + } + return "" +} diff --git a/internal/tui/configui/render.go b/internal/tui/configui/render.go new file mode 100644 index 00000000..e62e3263 --- /dev/null +++ b/internal/tui/configui/render.go @@ -0,0 +1,412 @@ +package configui + +import ( + "os" + "path/filepath" + "strings" + + "github.com/pulseaiclub/xui" + + "github.com/pulseaiclub/phi/internal/components" + "github.com/pulseaiclub/phi/internal/components/chrome" + "github.com/pulseaiclub/phi/internal/components/layout" +) + +// Row geometry. Every column is fixed so the eye can run down one line of +// labels and one line of values. +const ( + xSection = 2 + xModel = 4 + xField = 4 + xModelField = 6 + xNested = 8 +) + +// valueCol is the shared value column; it shrinks on narrow terminals. +func (e *ConfigEditor) valueCol() int { + col := 28 + if e.width > 0 { + col = min(col, max(e.width/3, 16)) + } + return col +} + +// Draw paints the frame the App loop shows. +func (e *ConfigEditor) Draw(ctx components.DrawContext) components.Surface { + w, h := ctx.Max.Width, ctx.Max.Height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + e.width = w + e.pollFetch() + e.rebuild() + + th := e.themeOrDefault() + out := components.NewSurface(w, h, e) + layout.DrawRoundedBorder(&out, layout.BorderRounded, th.Border, + e.topLabel(), nil, nil, nil, ctx.Method) + + inner := max(h-2, 1) + footer := 2 + if inner < 5 { + footer = 0 + } + page := max(inner-footer, 1) + e.page = page + e.rowsTop = 1 + e.ensureVisible(page) + + for i := e.scroll; i < len(e.rows) && i < e.scroll+page; i++ { + e.paintRow(&out, 1+i-e.scroll, e.rows[i], i == e.cursor, ctx.Method) + } + if footer > 0 { + e.paintFooter(&out, ctx.Method) + } + if e.edit != nil { + out.Cursor = &components.Point{X: e.caretX, Y: e.caretY} + } + + if e.confirm != nil { + out.Children = append(out.Children, e.drawConfirm(w, h, ctx.Method)) + } + if e.picker.Open { + out.Children = append(out.Children, components.SubSurface{ + Z: 20, + Surface: e.picker.Draw(ctx), + }) + } + return out +} + +// topLabel is the title bar: what this screen is, then the file it writes, +// then an unsaved marker. The title comes first so a long path is what gets +// truncated on a narrow terminal. +func (e *ConfigEditor) topLabel() *layout.BorderLabel { + th := e.themeOrDefault() + spans := []layout.BorderSpan{ + {Text: " phi config ", Style: chrome.PanelTitle(th)}, + {Text: "· " + displayPath(e.path) + " ", Style: th.Muted}, + } + if e.dirty { + spans = append(spans, layout.BorderSpan{Text: "● ", Style: th.Warning}) + } + return &layout.BorderLabel{Spans: spans} +} + +func (e *ConfigEditor) ensureVisible(page int) { + if len(e.rows) == 0 { + e.cursor, e.scroll = 0, 0 + return + } + e.cursor = min(max(e.cursor, 0), len(e.rows)-1) + if e.cursor < e.scroll { + e.scroll = e.cursor + } + if e.cursor >= e.scroll+page { + e.scroll = e.cursor - page + 1 + } + e.scroll = min(max(e.scroll, 0), max(len(e.rows)-page, 0)) +} + +func (e *ConfigEditor) paintRow(s *components.Surface, y int, r row, selected bool, method xui.WidthMethod) { + th := e.themeOrDefault() + base, secondary := th.Foreground, secondaryStyle(th) + bg := th.SelectionBg.Bg + if selected { + for x := 1; x < s.Size.Width-1; x++ { + s.SetCell(x, y, xui.Cell{Char: " ", Width: 1, Style: xui.Style{Bg: bg}}) + } + base = th.SelectionFg + base.Bg = bg + // Text on the stripe takes the stripe's tone, but the label column stays + // one weight below the value column: the eye belongs on the data, not on + // the key names. + secondary = base + secondary.Bold = false + } + switch r.kind { + case rowSection: + e.paintSection(s, y, r, method) + case rowModel: + // A model header is a label/value row like any other: the name owns the + // label column and the default marker plus the preset summary share the + // value column, so a long name can never overwrite the badge. + col := e.valueCol() + nameStyle := base + nameStyle.Bold = true + s.Print(xModel, y, layout.TruncateToWidth(r.name, max(col-1-xModel, 1), method), nameStyle, method) + x := col + if r.badge != "" { + x += s.Print(x, y, r.badge, withBG(th.Success, bg, selected), method) + if r.note != "" { + x += s.Print(x, y, chrome.Sep, secondary, method) + } + } + if r.note != "" { + s.Print(x, y, layout.TruncateToWidth(r.note, max(s.Size.Width-2-x, 1), method), secondary, method) + } + case rowItem: + x := xNested + x += s.Print(x, y, "· ", withBG(th.Border, bg, selected), method) + style := e.valStyle(r.style, bg, selected) + text := r.label + if e.edit != nil && e.edit.key == r.key { + text = string(e.edit.buf) + style = base + if selected { + style.Bg = bg + } + } + s.Print(x, y, layout.TruncateToWidth(text, s.Size.Width-2-x, method), style, method) + if e.edit != nil && e.edit.key == r.key { + e.caretX = x + xui.StringWidth(string(e.edit.buf[:e.edit.cur]), method) + e.caretY = y + } + case rowAdd: + style := th.ToolName + if selected { + style = base + } + s.Print(xNested, y, layout.TruncateToWidth(r.label, s.Size.Width-2-xNested, method), style, method) + case rowField: + e.paintField(s, y, r, selected, base, secondary, bg, method) + } +} + +func (e *ConfigEditor) paintField( + s *components.Surface, + y int, + r row, + selected bool, + base, secondary xui.Style, + bg xui.Color, + method xui.WidthMethod, +) { + th := e.themeOrDefault() + x := xField + if _, _, ok := splitModelKey(r.key); ok { + x = xModelField + } + col := e.valueCol() + // Labels are a reading aid: they never take emphasis away from the value + // column, on or off the stripe. + labelStyle := secondary + s.Print(x, y, layout.TruncateToWidth(r.label, max(col-x-2, 1), method), labelStyle, method) + + style := e.valStyle(r.style, bg, selected) + text := r.display + editing := e.edit != nil && e.edit.key == r.key + if editing { + text = string(e.edit.buf) + style = base + if selected { + style.Bg = bg + } + } + avail := max(s.Size.Width-2-col, 1) + printed := s.Print(col, y, layout.TruncateToWidth(text, avail, method), style, method) + if editing { + e.caretX = col + xui.StringWidth(string(e.edit.buf[:e.edit.cur]), method) + e.caretY = y + return + } + if selected && r.hint != "" { + note := th.Muted + note.Dim = true + // The selection bar is one continuous stripe, hint included. + note.Bg = bg + s.Print(col+printed+2, y, layout.TruncateToWidth(r.hint, max(avail-printed-2, 0), method), note, method) + } +} + +func (e *ConfigEditor) paintSection(s *components.Surface, y int, r row, method xui.WidthMethod) { + th := e.themeOrDefault() + title := th.TitleOrForeground() + x := xSection + x += s.Print(x, y, strings.ToUpper(r.title), title, method) + + end := s.Size.Width - 2 + note := r.note + noteW := 0 + if note != "" { + noteW = xui.StringWidth(note, method) + 1 + // Drop the note rather than let it crowd out the rule on a narrow frame. + if end-x-1-noteW < 2 { + note, noteW = "", 0 + } + } + if rule := end - x - 1 - noteW; rule > 0 { + s.Print(x+1, y, strings.Repeat("─", rule), th.Border, method) + } + if note != "" { + s.Print(end-noteW+1, y, note, th.Muted, method) + } +} + +func (e *ConfigEditor) paintFooter(s *components.Surface, method xui.WidthMethod) { + th := e.themeOrDefault() + w, h := s.Size.Width, s.Size.Height + ruleY := h - 3 + if ruleY > 1 { + s.Print(1, ruleY, strings.Repeat("─", max(w-2, 0)), th.Border, method) + } + y := h - 2 + hints := e.hintLine() + printed := s.Print(2, y, layout.TruncateToWidth(hints, max(w-6, 1), method), th.Muted, method) + if e.status == "" { + return + } + statusW := xui.StringWidth(e.status, method) + start := w - 2 - statusW + if start <= 4+printed { + return + } + s.Print(start, y, layout.TruncateToWidth(e.status, max(w-4, 1), method), e.statusStyle(), method) +} + +func (e *ConfigEditor) hintLine() string { + switch { + case e.edit != nil: + return "⏎ apply" + chrome.Sep + "esc cancel" + chrome.Sep + "^u clear" + case e.confirm != nil: + return chrome.ConfirmHint() + case e.expanded != "": + return "↑↓ move" + chrome.Sep + "⏎ edit" + chrome.Sep + "a add" + chrome.Sep + "d delete" + chrome.Sep + "esc close" + default: + return "↑↓ move" + chrome.Sep + "⏎ edit" + chrome.Sep + "←→ cycle" + chrome.Sep + + "a add model" + chrome.Sep + "f fetch" + chrome.Sep + "s save" + chrome.Sep + "q quit" + } +} + +func (e *ConfigEditor) statusStyle() xui.Style { + th := e.themeOrDefault() + switch e.statusKind { + case statusError: + return th.Destructive + case statusSuccess: + return th.Success + default: + return th.Muted + } +} + +// secondaryStyle is the form's one quiet tone: field labels, values the file +// does not set, and rules inherited from the built-in preset all share it. +// +// Dim is cleared on purpose. SGR 2 lands anywhere between "slightly softer" and +// "all but invisible" depending on the terminal, and a label column that sinks +// into the background next to a crisp value column is what makes a two-column +// form read as mud. Trailing hints keep Dim — they are meant to disappear first. +func secondaryStyle(th components.Theme) xui.Style { + st := th.Muted + st.Dim = false + return st +} + +func (e *ConfigEditor) valStyle(v valueStyle, bg xui.Color, selected bool) xui.Style { + th := e.themeOrDefault() + var st xui.Style + switch v { + case styleMuted: + st = secondaryStyle(th) + case styleOn: + st = th.Success + case styleWarn: + st = th.Warning + default: + st = th.Foreground + } + if selected { + // A filled value takes the stripe highlight; an unset one keeps the flat + // quiet tone it has everywhere else, so the cursor cannot make an empty + // field look written. + if v == styleValue { + st = th.SelectionFg + } + st.Bg = bg + st.Dim = false + } + return st +} + +type statusKind int + +const ( + statusNone statusKind = iota + statusInfo + statusSuccess + statusError +) + +func withBG(st xui.Style, bg xui.Color, selected bool) xui.Style { + if selected { + st.Bg = bg + } + return st +} + +// drawConfirm paints the yes/no modal centered over the form. +func (e *ConfigEditor) drawConfirm(w, h int, method xui.WidthMethod) components.SubSurface { + th := e.themeOrDefault() + st := e.confirm + + width := min(58, max(w-4, 20)) + bodyW := width - 4 + var lines []components.RichLine + add := func(spans ...components.Span) { + lines = append(lines, components.WrapSpans(spans, bodyW, method)...) + } + add(components.Span{Text: st.title, Style: th.TitleOrForeground()}) + if st.body != "" { + add(components.Span{Text: st.body, Style: th.Muted}) + } + lines = append(lines, components.RichLine{}) + + primary := chrome.DecisionPrimary(th) + border := chrome.ModalBorder(th) + if st.danger { + border = th.Destructive + } + for i, label := range []string{"Yes", "No"} { + lines = append(lines, chrome.OptionLine(th, primary, label, (i == 0) == st.yes, bodyW, method)...) + } + add(components.Span{Text: chrome.ConfirmHint(), Style: th.Muted}) + + height := len(lines) + 2 + panel := components.NewSurface(width, height, e) + fill := xui.Style{Fg: th.Foreground.Fg} + for y := range height { + for x := range width { + panel.SetCell(x, y, xui.Cell{Char: " ", Width: 1, Style: fill}) + } + } + layout.DrawRoundedBorder(&panel, layout.BorderRounded, border, nil, nil, nil, nil, method) + y := 1 + for _, line := range lines { + if y >= height-1 { + break + } + components.PaintSpans(&panel, 2, y, line, method) + y++ + } + return components.SubSurface{ + Origin: components.Point{X: max((w-width)/2, 0), Y: max((h-height)/2, 0)}, + Z: 30, + Surface: panel, + } +} + +// displayPath shortens the home directory to "~" for the border label. Slashes +// are normalized so Windows shows "~/.phi/config.yaml" instead of "~\.phi\config.yaml". +func displayPath(path string) string { + display := filepath.ToSlash(path) + if home, err := os.UserHomeDir(); err == nil && home != "" { + if rest, ok := strings.CutPrefix(display, filepath.ToSlash(home)); ok { + return "~" + rest + } + } + return display +}