package tui import ( "fmt" "os" "path/filepath" "sort" "strconv" "strings" "github.com/ajhahnde/eeco/internal/ai" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/hooks" "github.com/ajhahnde/eeco/internal/memory" "github.com/ajhahnde/eeco/internal/workflow" ) // cmdEntry pairs a slash command with the one-line purpose surfaced by the // `/` palette and the ? overlay. Single source of truth so the dispatcher, // Tab completion, the palette, and opHelp never drift. type cmdEntry struct { name string // with leading slash purpose string } // commandIndex is the canonical, sorted command set. The `/` palette // renders this list directly; slashCommands and the ? overlay (via // opHelp) derive from it so a new command lands in one place. var commandIndex = []cmdEntry{ {"/gc", "run memory garbage collection"}, {"/help", "command and key reference"}, {"/hooks", "show or toggle reversible hooks"}, {"/memory", "list stored facts"}, {"/new", "scaffold a new workflow"}, {"/queue", "show items awaiting a decision"}, {"/quit", "leave the control center"}, {"/run", "run a workflow (--ai for one gated pass)"}, {"/settings", "view or set the AI config (config.local)"}, } // slashCommands is the name-only projection of commandIndex used by the // dispatcher and Tab completion. Build order matches commandIndex. var slashCommands = func() []string { out := make([]string, len(commandIndex)) for i, e := range commandIndex { out[i] = e.name } return out }() // parsedCmd is one line of input resolved to either a slash command or a // free-text request. Exactly one of name / free is set. type parsedCmd struct { name string // command without the leading slash; empty for free text args []string // positional arguments (flags removed) ai bool // --ai was present (meaningful for `run`) free string // the raw line when it is a free-text request } // parseInput classifies a submitted line. A leading slash makes it a // command; anything else is a free-text request routed through the // gated AI provider. Empty input yields a no-op (name and free empty). func parseInput(s string) parsedCmd { t := strings.TrimSpace(s) if t == "" { return parsedCmd{} } if !strings.HasPrefix(t, "/") { return parsedCmd{free: t} } fields := strings.Fields(t) p := parsedCmd{name: strings.TrimPrefix(fields[0], "/")} for _, a := range fields[1:] { if a == "--ai" { p.ai = true continue } p.args = append(p.args, a) } return p } // runNames lists the names a `/run` argument may complete to: the // builtins plus any workflow scaffolded into the workspace. func runNames(cfg *config.Config) []string { set := map[string]struct{}{} for _, n := range workflow.DefaultRegistry().Names() { set[n] = struct{}{} } if cfg != nil { if ents, err := os.ReadDir(filepath.Join(cfg.Workspace, "workflows")); err == nil { for _, e := range ents { if e.IsDir() { set[e.Name()] = struct{}{} } } } } out := make([]string, 0, len(set)) for n := range set { out = append(out, n) } sort.Strings(out) return out } // complete performs Tab completion on the current input. It returns the // possibly-extended input and, when the choice is ambiguous, the // candidate list to echo. Only slash input completes: free text does // not. The command token completes against slashCommands; a `/run` // argument completes against runNames. func complete(input string, names []string) (string, []string) { if !strings.HasPrefix(input, "/") { return input, nil } if !strings.Contains(input, " ") { return completeToken(input, slashCommands, "") } cmd := strings.Fields(input)[0] if cmd != "/run" { return input, nil } // Completing the workflow argument. Preserve everything up to the // last token and complete that token against the workflow names. cut := strings.LastIndex(input, " ") prefix := input[:cut+1] tok := input[cut+1:] return completeToken(tok, names, prefix) } // completeToken extends tok against candidates. A single match completes // fully (with a trailing space); several matches extend to the longest // common prefix and return the candidates for display. func completeToken(tok string, candidates []string, prefix string) (string, []string) { var matches []string for _, c := range candidates { if strings.HasPrefix(c, tok) { matches = append(matches, c) } } switch len(matches) { case 0: return prefix + tok, nil case 1: return prefix + matches[0] + " ", nil default: return prefix + longestCommonPrefix(matches), matches } } func longestCommonPrefix(xs []string) string { if len(xs) == 0 { return "" } p := xs[0] for _, s := range xs[1:] { for !strings.HasPrefix(s, p) { p = p[:len(p)-1] if p == "" { return "" } } } return p } // dispatch resolves a non-AI command to its output lines and control // flags. AI-bearing work (free text, an opted-in `/run`) is handled by // the model as an interruptible background command; dispatch covers the // synchronous, AI-free commands and reports an unknown one. type dispatchResult struct { lines []string quit bool // async, when set, names a long operation the caller must run off // the UI goroutine (so Esc can interrupt it). args carry its input. async string asyncAI bool asyncS string } func dispatch(cfg *config.Config, st styles, width int, p parsedCmd) dispatchResult { switch { case p.name == "" && p.free == "": return dispatchResult{} case p.free != "": // Free-text chat is retired (C5): eeco configures the harness that // runs AI, it no longer runs a chat turn itself. Echo a synchronous // hint — no gate, no goroutine, no spend. return dispatchResult{lines: []string{st.dim.Render( "free-text chat is retired — type / for commands (/run, /memory, …) or ? for help")}} } switch p.name { case "quit", "q": return dispatchResult{quit: true} case "help": return dispatchResult{lines: opHelp(st, width)} case "hooks": return dispatchResult{lines: opHooks(cfg, st, width, p.args)} case "settings": return dispatchResult{lines: opSettings(cfg, st, width, p.args)} case "queue": return dispatchResult{lines: opQueue(cfg, st, width)} case "memory": return dispatchResult{lines: opMemory(cfg, st, width)} case "gc": return dispatchResult{async: "gc"} case "new": if len(p.args) != 1 { return dispatchResult{lines: renderError(st, "new", "usage: /new ")} } return dispatchResult{lines: opNew(cfg, st, width, p.args[0])} case "run": if len(p.args) != 1 { return dispatchResult{lines: renderSection(width, st, section{ title: "run", subtitle: "usage", body: []string{ " /run [--ai] ", " builtins: " + strings.Join(workflow.DefaultRegistry().Names(), ", "), }, })} } return dispatchResult{async: "run", asyncS: p.args[0], asyncAI: p.ai} default: return dispatchResult{lines: renderError(st, "tui", fmt.Sprintf("unknown command %q — type /help or ? for the reference", "/"+p.name))} } } // opRun executes one workflow through the existing engine and formats // the report exactly as `eeco run` does. Returns a one-line summary // (the value the status bar's `run:` field surfaces), the full styled // section to print, and the workflow exit code. It introduces no write // path of its own and honours the same exit-code contract. func opRun(cfg *config.Config, st styles, width int, name string, aiFlag bool) (summary string, lines []string, code int) { det, derr := workflow.NewDetector(cfg.AttributionPatterns) if derr != nil { summary = "run " + name + ": " + derr.Error() return summary, renderError(st, "run "+name, derr.Error()), workflow.CodeFinding } gate := ai.NewGate(cfg, aiFlag, det.ScanResponse) env := workflow.Env{Config: cfg, AI: gate.Consent, Gate: gate} reg := workflow.DefaultRegistry() var ( res workflow.Result err error ) if w, ok := reg.Get(name); ok { res, err = workflow.Run(w, env) } else { res, err = workflow.ScriptRun(name, env) } if err != nil { summary = "run " + name + ": " + err.Error() return summary, renderError(st, "run "+name, err.Error()), workflow.CodeFinding } summary = fmt.Sprintf("run %s: %s (exit %d)", name, res.Summary, res.Code) body := make([]string, 0, len(res.Findings)) for _, f := range res.Findings { if f.Line > 0 { body = append(body, fmt.Sprintf(" %s:%d: %s", f.Path, f.Line, f.Msg)) } else { body = append(body, fmt.Sprintf(" %s: %s", f.Path, f.Msg)) } } if len(body) == 0 { body = []string{" " + st.dim.Render("no findings")} } return summary, renderSection(width, st, section{ title: "run " + name, subtitle: fmt.Sprintf("%s (exit %d)", res.Summary, res.Code), body: body, }), res.Code } // opQueue shows the unresolved queue. It only reads the queue file. func opQueue(cfg *config.Config, st styles, width int) []string { n := queueCount(cfg) b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md")) if err != nil || len(strings.TrimSpace(string(b))) == 0 { return renderSection(width, st, section{ title: "queue", subtitle: "empty", body: []string{" nothing needs a decision"}, }) } body := make([]string, 0) for _, ln := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { body = append(body, " "+ln) } return renderSection(width, st, section{ title: "queue", subtitle: fmt.Sprintf("%d open", n), body: body, }) } // opMemory lists the stored facts. It reads the store; it changes // nothing on disk. func opMemory(cfg *config.Config, st styles, width int) []string { store, err := memory.Open(cfg) if err != nil { return renderError(st, "memory", err.Error()) } facts, err := store.LoadAll() if err != nil { return renderError(st, "memory", err.Error()) } if len(facts) == 0 { return renderSection(width, st, section{ title: "memory", subtitle: "empty", body: []string{" no facts stored"}, }) } rows := make([]sectionRow, 0, len(facts)) for _, f := range facts { typeCol := string(f.Type) if f.Pin { typeCol += " [pinned]" } if f.Disabled { typeCol += " [off]" } rows = append(rows, sectionRow{key: f.Name, value: f.Description, note: typeCol}) } body := tableBody(st, [3]string{"fact", "description", "type"}, rows) return renderSection(width, st, section{ title: "memory", subtitle: fmt.Sprintf("%d fact(s)", len(facts)), body: body, }) } // opGC runs memory garbage collection — the same engine operation as // `eeco gc`, writing only inside the workspace. It requires an // initialised workspace, mirroring the CLI guard. func opGC(cfg *config.Config, st styles, width int) []string { if !config.IsInitialized(cfg) { return renderError(st, "gc", "workspace not initialised — run `eeco init` first") } store, err := memory.Open(cfg) if err != nil { return renderError(st, "gc", err.Error()) } res, err := store.GC() if err != nil { return renderError(st, "gc", err.Error()) } body := make([]string, 0, len(res.Actions)) for _, a := range res.Actions { if a.Action == "kept" { continue } body = append(body, fmt.Sprintf(" %-9s %s (%s) — %s", a.Action, a.Name, a.Type, a.Reason)) } if len(body) == 0 { body = []string{" " + st.dim.Render("no changes")} } return renderSection(width, st, section{ title: "gc", subtitle: fmt.Sprintf("archived %d · queued %d · kept %d", res.Archived, res.Queued, res.Kept), body: body, }) } // opNew scaffolds a workflow into the workspace — the same engine // operation as `eeco new`. It requires an initialised workspace. func opNew(cfg *config.Config, st styles, width int, name string) []string { if !config.IsInitialized(cfg) { return renderError(st, "new", "workspace not initialised — run `eeco init` first") } dir, err := workflow.Scaffold(cfg, name) if err != nil { return renderError(st, "new", err.Error()) } return renderSection(width, st, section{ title: "new", subtitle: fmt.Sprintf("scaffolded %q", name), body: []string{" " + dir}, footer: []string{" next: edit run to implement the check, then /run " + name}, }) } // opHooks shows or toggles the opt-in, reversible hooks — the same // engine operation as `eeco hooks`. With no argument it reports state; // with ` on|off` it toggles. It introduces no new write path: the // only touches are the sanctioned reversible ones the user asked for. func opHooks(cfg *config.Config, st styles, width int, args []string) []string { hooksUsage := []string{ " /hooks [status]", " /hooks ", " names: " + hooks.PreCommit + ", " + hooks.SessionStart + ", machinery", } if len(args) == 0 || (len(args) == 1 && args[0] == "status") { raw := hooks.Status(cfg) raw = append(raw, hooks.CockpitMachineryStatus(cfg)...) body := make([]string, len(raw)) for i, ln := range raw { body[i] = " " + ln } return renderSection(width, st, section{ title: "hooks", body: body, }) } if len(args) != 2 { return renderSection(width, st, section{ title: "hooks", subtitle: "usage", body: hooksUsage, }) } name, action := args[0], args[1] var ( msg string err error ) switch { case name == hooks.PreCommit && action == "on": msg, err = hooks.EnablePreCommit(cfg) case name == hooks.PreCommit && action == "off": msg, err = hooks.DisablePreCommit(cfg) case name == hooks.SessionStart && action == "on": msg, err = hooks.EnableSessionStart(cfg) case name == hooks.SessionStart && action == "off": msg, err = hooks.DisableSessionStart(cfg) case name == "machinery" && action == "on": msg, err = hooks.EnableCockpitMachinery(cfg) case name == "machinery" && action == "off": msg, err = hooks.DisableCockpitMachinery(cfg) default: return renderSection(width, st, section{ title: "hooks", subtitle: "usage", body: hooksUsage, }) } if err != nil { return renderError(st, "hooks", err.Error()) } return []string{st.ok.Render("hooks:") + " " + msg} } // opSettings views and edits the AI configuration knobs, persisting // changes to /config.local (inside the gitignored workspace // — write-scope safe; no brand baked in). It changes nothing in the // tracked tree and adds no new write path. A change applies the next // time eeco starts: the long-lived session deliberately keeps the gate // and budget cap it began with (a mid-session silent re-spend is // impossible). func opSettings(cfg *config.Config, st styles, width int, args []string) []string { if !config.IsInitialized(cfg) { return renderError(st, "settings", "workspace not initialised — run `eeco init` first") } if len(args) == 0 { provider := "not configured (every AI pass is parked)" if ai.Select(cfg).Name() != "none" { provider = "configured" } cmd := "(unset)" if len(cfg.AICommand) > 0 { cmd = strings.Join(cfg.AICommand, " ") } body := tableBody(st, [3]string{"key", "value", "note"}, []sectionRow{ {key: "automation", value: string(cfg.Automation), note: "only `auto` is standing AI consent"}, {key: "ai_budget", value: strconv.Itoa(cfg.AIBudget), note: "gated passes per invocation; 0 disables AI"}, {key: "ai_command", value: cmd, note: "argv of the provider CLI"}, {key: "provider", value: provider, note: "configure via `/settings ai_command`"}, }) footer := append( []string{" " + st.tableHeader.Render("set a value")}, tableBody(st, [3]string{}, []sectionRow{ {key: "/settings automation", value: "", note: "background-AI policy"}, {key: "/settings ai_budget", value: "", note: "0 disables AI for the session"}, {key: "/settings ai_command", value: "", note: "e.g. claude --print"}, })..., ) footer = append(footer, "", " "+st.dim.Render("applies on next `eeco` start; saved to config.local"), ) return renderSection(width, st, section{ title: "settings", subtitle: "AI", body: body, footer: footer, }) } key := args[0] val := strings.TrimSpace(strings.Join(args[1:], " ")) switch key { case "automation": switch config.Automation(val) { case config.AutomationManual, config.AutomationPropose, config.AutomationScaffold, config.AutomationAuto: default: return renderError(st, "settings", "automation must be manual|propose|scaffold|auto") } case "ai_budget": if n, err := strconv.Atoi(val); err != nil || n < 0 { return renderError(st, "settings", "ai_budget must be a non-negative integer") } case "ai_command": if val == "" { return renderError(st, "settings", "ai_command needs an argv, e.g. /settings ai_command yourcli --print") } default: return renderSection(width, st, section{ title: "settings", subtitle: "usage", body: []string{ " unknown key " + strconv.Quote(key), " /settings [automation|ai_budget|ai_command] ", }, }) } if err := config.WriteLocalKeys(cfg, map[string]string{key: val}); err != nil { return renderError(st, "settings", err.Error()) } return []string{ st.ok.Render("settings:") + " " + fmt.Sprintf("%s set to %q", key, val), " " + st.dim.Render("applies on next `eeco` start; this session keeps its current gate."), } } // opHelp wraps the in-session command and key reference in the unified // section frame. It names no external tool and uses no first person // (Constraint 4). func opHelp(st styles, width int) []string { body := []string{ " " + st.key.Render("commands:"), " /run [--ai] run a workflow (--ai opts into one gated pass)", " /queue show items awaiting a decision", " /memory list stored facts", " /gc run memory garbage collection", " /new scaffold a new workflow", " /hooks [ on|off] show or toggle reversible hooks", " /settings [ ] view or set the AI config (config.local)", " /help this reference", " /quit leave the control center", "", " " + st.key.Render("keys:"), " Up/Down command history Tab complete command/workflow", " ? toggle this overlay Esc interrupt a running task", " Ctrl-C quit q quit (empty input)", "", " " + st.dim.Render("type / for commands; free-text chat is retired —"), " " + st.dim.Render("eeco configures the harness that runs AI, it does not chat itself."), } return renderSection(width, st, section{ title: "help", body: body, }) }