ajhahn.de
← eeco
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,
	})
}