Go 560 lines
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 <workflow>")}
}
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] <workflow>",
" 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 `<name> 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 <name> <on|off>",
" 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 <workspace>/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: "<manual|propose|scaffold|auto>", note: "background-AI policy"},
{key: "/settings ai_budget", value: "<n>", note: "0 disables AI for the session"},
{key: "/settings ai_command", value: "<argv…>", 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] <value>",
},
})
}
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] <wf> 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 <wf> scaffold a new workflow",
" /hooks [<name> on|off] show or toggle reversible hooks",
" /settings [<k> <v>] 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,
})
}