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

+
+
- [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
+```
-
+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 按键
+ ↑↓ ←→ 移动 / 切换取值 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
-
-
-
-
-
-
-
-
-
-
-
-
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
+}