summaryrefslogtreecommitdiff
path: root/internal/hexaiaction
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-06 13:19:01 +0300
committerPaul Buetow <paul@buetow.org>2025-09-06 13:19:01 +0300
commit04f290dbeeee8a6fcbc70fed253a968336bcb2ab (patch)
tree3ee23a4ac4bcc5b43b43697cfb0e905735fc6331 /internal/hexaiaction
parent5e966f50111adf6e2cb2683fe588f6fe033fa931 (diff)
more tests
Diffstat (limited to 'internal/hexaiaction')
-rw-r--r--internal/hexaiaction/parse.go69
-rw-r--r--internal/hexaiaction/parse_test.go121
-rw-r--r--internal/hexaiaction/prompts.go91
-rw-r--r--internal/hexaiaction/run.go74
-rw-r--r--internal/hexaiaction/run_test.go51
-rw-r--r--internal/hexaiaction/tui.go118
-rw-r--r--internal/hexaiaction/tui_delegate.go35
-rw-r--r--internal/hexaiaction/tui_delegate_test.go32
-rw-r--r--internal/hexaiaction/tui_test.go36
-rw-r--r--internal/hexaiaction/types.go19
10 files changed, 646 insertions, 0 deletions
diff --git a/internal/hexaiaction/parse.go b/internal/hexaiaction/parse.go
new file mode 100644
index 0000000..99e2b24
--- /dev/null
+++ b/internal/hexaiaction/parse.go
@@ -0,0 +1,69 @@
+package hexaiaction
+
+import (
+ "bufio"
+ "io"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/textutil"
+)
+
+// ParseInput splits raw stdin into optional diagnostics and selection/code.
+// Format:
+//
+// Diagnostics:\n
+// <one per line>\n
+// <blank line> (optional)\n
+// <rest is selection/code>
+//
+// If the header is absent, the entire input is treated as selection.
+func ParseInput(r io.Reader) (InputParts, error) {
+ b, err := io.ReadAll(bufio.NewReader(r))
+ if err != nil {
+ return InputParts{}, err
+ }
+ raw := strings.TrimSpace(string(b))
+ if raw == "" {
+ return InputParts{Selection: ""}, nil
+ }
+ lines := strings.Split(raw, "\n")
+ // find a case-insensitive line equal to "diagnostics:"
+ diagsIdx := -1
+ for i, ln := range lines {
+ t := strings.TrimSpace(strings.ToLower(ln))
+ if t == "diagnostics:" {
+ diagsIdx = i
+ break
+ }
+ }
+ if diagsIdx < 0 {
+ return InputParts{Selection: raw}, nil
+ }
+ // collect diagnostics until a blank line or EOF
+ diags := []string{}
+ i := diagsIdx + 1
+ for ; i < len(lines); i++ {
+ t := strings.TrimSpace(lines[i])
+ if t == "" {
+ i++
+ break
+ }
+ diags = append(diags, t)
+ }
+ sel := strings.Join(lines[i:], "\n")
+ sel = strings.TrimSpace(sel)
+ return InputParts{Selection: sel, Diagnostics: diags}, nil
+}
+
+// ExtractInstruction mirrors the LSP instructionFromSelection behavior (subset),
+// scanning the first line for an instruction marker and removing it from the selection.
+func ExtractInstruction(sel string) (string, string) { return textutil.InstructionFromSelection(sel) }
+
+// findFirstInstructionInLine follows the same precedence as LSP:
+// - ;text; (strict)
+// - /* text */ (single-line)
+// - <!-- text --> (single-line)
+// - // text
+// - # text
+// - -- text
+// helpers moved to textutil
diff --git a/internal/hexaiaction/parse_test.go b/internal/hexaiaction/parse_test.go
new file mode 100644
index 0000000..f81ab54
--- /dev/null
+++ b/internal/hexaiaction/parse_test.go
@@ -0,0 +1,121 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+func TestParseInput_NoDiagnostics(t *testing.T) {
+ in := "some code here"
+ parts, err := ParseInput(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if parts.Selection != in || len(parts.Diagnostics) != 0 {
+ t.Fatalf("unexpected parse: %#v", parts)
+ }
+}
+
+func TestParseInput_WithDiagnostics(t *testing.T) {
+ in := "Diagnostics:\nmissing return\nuse of undefined: foo\n\nfunc a() {}"
+ parts, err := ParseInput(strings.NewReader(in))
+ if err != nil {
+ t.Fatalf("unexpected err: %v", err)
+ }
+ if parts.Selection != "func a() {}" {
+ t.Fatalf("selection wrong: %q", parts.Selection)
+ }
+ if len(parts.Diagnostics) != 2 || parts.Diagnostics[0] != "missing return" {
+ t.Fatalf("diags wrong: %#v", parts.Diagnostics)
+ }
+}
+
+func TestExtractInstruction_Variants(t *testing.T) {
+ cases := []struct{ in, wantInstr string }{
+ {";rewrite to X;\ncode", "rewrite to X"},
+ {"/* fix it */\ncode", "fix it"},
+ {"<!-- doc me -->\ncode", "doc me"},
+ {"// change it\ncode", "change it"},
+ {"# tweak\ncode", "tweak"},
+ {"-- fix\ncode", "fix"},
+ }
+ for _, c := range cases {
+ got, cleaned := ExtractInstruction(c.in)
+ if got != c.wantInstr {
+ t.Fatalf("instr mismatch: %q != %q", got, c.wantInstr)
+ }
+ if strings.Contains(cleaned, c.wantInstr) && strings.Contains(c.in, c.wantInstr) {
+ t.Fatalf("expected instruction removed from selection: %q", cleaned)
+ }
+ }
+}
+
+func TestRenderAndStrip(t *testing.T) {
+ tpl := "Hello, {{name}}"
+ out := Render(tpl, map[string]string{"name": "Hex"})
+ if out != "Hello, Hex" {
+ t.Fatalf("unexpected render: %q", out)
+ }
+ fenced := "```go\npackage x\n```"
+ if StripFences(fenced) != "package x" {
+ t.Fatalf("unexpected strip")
+ }
+}
+
+type fakeClient struct {
+ last []llm.Message
+ out string
+ err error
+}
+
+func (f *fakeClient) Chat(_ context.Context, msgs []llm.Message, _ ...llm.RequestOption) (string, error) {
+ f.last = msgs
+ return f.out, f.err
+}
+
+func TestRuners_Prompts(t *testing.T) {
+ cfg := appconfig.App{
+ PromptCodeActionRewriteSystem: "SYS-R",
+ PromptCodeActionRewriteUser: "R {{instruction}} :: {{selection}}",
+ PromptCodeActionDiagnosticsSystem: "SYS-D",
+ PromptCodeActionDiagnosticsUser: "D {{diagnostics}} :: {{selection}}",
+ PromptCodeActionDocumentSystem: "SYS-C",
+ PromptCodeActionDocumentUser: "C {{selection}}",
+ PromptCodeActionGoTestSystem: "SYS-T",
+ PromptCodeActionGoTestUser: "T {{function}}",
+ }
+ f := &fakeClient{out: "```\nDONE\n```"}
+ ctx := context.Background()
+ // rewrite
+ if out, err := runRewrite(ctx, cfg, f, "instr", "sel"); err != nil || out != "DONE" {
+ t.Fatalf("rewrite failed: %q %v", out, err)
+ }
+ if len(f.last) != 2 || f.last[0].Content != "SYS-R" || !strings.Contains(f.last[1].Content, "instr") {
+ t.Fatalf("rewrite prompts wrong: %#v", f.last)
+ }
+ // diagnostics
+ if out, err := runDiagnostics(ctx, cfg, f, []string{"a", "b"}, "sel"); err != nil || out != "DONE" {
+ t.Fatalf("diagnostics failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-D" || !strings.Contains(f.last[1].Content, "a\nb") {
+ t.Fatalf("diagnostics prompts wrong: %#v", f.last)
+ }
+ // document
+ if out, err := runDocument(ctx, cfg, f, "sel"); err != nil || out != "DONE" {
+ t.Fatalf("document failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-C" || !strings.Contains(f.last[1].Content, "sel") {
+ t.Fatalf("document prompts wrong: %#v", f.last)
+ }
+ // gotest
+ if out, err := runGoTest(ctx, cfg, f, "func A(){}"); err != nil || out != "DONE" {
+ t.Fatalf("gotest failed: %q %v", out, err)
+ }
+ if f.last[0].Content != "SYS-T" || !strings.Contains(f.last[1].Content, "func A(){") {
+ t.Fatalf("gotest prompts wrong: %#v", f.last)
+ }
+}
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go
new file mode 100644
index 0000000..2e0e4e2
--- /dev/null
+++ b/internal/hexaiaction/prompts.go
@@ -0,0 +1,91 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/textutil"
+)
+
+// Render performs simple {{var}} replacement like LSP.
+func Render(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) }
+
+// StripFences removes surrounding markdown code fences.
+func StripFences(s string) string { return textutil.StripCodeFences(s) }
+
+type chatDoer interface {
+ Chat(ctx context.Context, msgs []llm.Message, opts ...llm.RequestOption) (string, error)
+}
+
+func runRewrite(ctx context.Context, cfg appconfig.App, client chatDoer, instruction, selection string) (string, error) {
+ sys := cfg.PromptCodeActionRewriteSystem
+ user := Render(cfg.PromptCodeActionRewriteUser, map[string]string{"instruction": instruction, "selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runDiagnostics(ctx context.Context, cfg appconfig.App, client chatDoer, diags []string, selection string) (string, error) {
+ var b strings.Builder
+ for i, d := range diags {
+ if strings.TrimSpace(d) == "" {
+ continue
+ }
+ b.WriteString(strings.TrimSpace(d))
+ if i < len(diags)-1 {
+ b.WriteString("\n")
+ }
+ }
+ sys := cfg.PromptCodeActionDiagnosticsSystem
+ user := Render(cfg.PromptCodeActionDiagnosticsUser, map[string]string{"diagnostics": b.String(), "selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runDocument(ctx context.Context, cfg appconfig.App, client chatDoer, selection string) (string, error) {
+ sys := cfg.PromptCodeActionDocumentSystem
+ user := Render(cfg.PromptCodeActionDocumentUser, map[string]string{"selection": selection})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runGoTest(ctx context.Context, cfg appconfig.App, client chatDoer, funcCode string) (string, error) {
+ sys := cfg.PromptCodeActionGoTestSystem
+ user := Render(cfg.PromptCodeActionGoTestUser, map[string]string{"function": funcCode})
+ return runOnceWithOpts(ctx, client, sys, user, reqOptsFrom(cfg))
+}
+
+func runOnce(ctx context.Context, client chatDoer, sys, user string) (string, error) {
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ txt, err := client.Chat(ctx, msgs)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(StripFences(txt)), nil
+}
+
+func runOnceWithOpts(ctx context.Context, client chatDoer, sys, user string, opts []llm.RequestOption) (string, error) {
+ msgs := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ txt, err := client.Chat(ctx, msgs, opts...)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(StripFences(txt)), nil
+}
+
+// reqOptsFrom builds LLM request options similar to LSP behavior.
+func reqOptsFrom(cfg appconfig.App) []llm.RequestOption {
+ opts := []llm.RequestOption{llm.WithMaxTokens(cfg.MaxTokens)}
+ if cfg.CodingTemperature != nil {
+ opts = append(opts, llm.WithTemperature(*cfg.CodingTemperature))
+ }
+ return opts
+}
+
+// Timeout helpers to mirror LSP behavior.
+func timeout10s(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, 10*time.Second)
+}
+
+func timeout8s(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, 8*time.Second)
+}
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
new file mode 100644
index 0000000..2a67a58
--- /dev/null
+++ b/internal/hexaiaction/run.go
@@ -0,0 +1,74 @@
+package hexaiaction
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/logging"
+ "codeberg.org/snonux/hexai/internal/llmutils"
+)
+
+// Run executes the hexai-action command flow.
+func Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error {
+ logger := log.New(stderr, "hexai-action ", log.LstdFlags|log.Lmsgprefix)
+ cfg := appconfig.Load(logger)
+ client, err := llmutils.NewClientFromApp(cfg)
+ if err != nil {
+ fmt.Fprintf(stderr, logging.AnsiBase+"hexai-action: LLM disabled: %v"+logging.AnsiReset+"\n", err)
+ return err
+ }
+ parts, err := ParseInput(stdin)
+ if err != nil {
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: failed to read input"+logging.AnsiReset)
+ return err
+ }
+ if strings.TrimSpace(parts.Selection) == "" {
+ return fmt.Errorf("hexai-action: no input provided on stdin")
+ }
+ kind, err := RunTUI()
+ if err != nil {
+ return err
+ }
+ out, err := executeAction(ctx, kind, parts, cfg, client, stderr)
+ if err != nil {
+ return err
+ }
+ io.WriteString(stdout, out)
+ return nil
+}
+
+func executeAction(ctx context.Context, kind ActionKind, parts InputParts, cfg appconfig.App, client chatDoer, stderr io.Writer) (string, error) {
+ switch kind {
+ case ActionSkip:
+ return parts.Selection, nil
+ case ActionRewrite:
+ instr, cleaned := ExtractInstruction(parts.Selection)
+ if strings.TrimSpace(instr) == "" {
+ fmt.Fprintln(stderr, logging.AnsiBase+"hexai-action: no inline instruction found; echoing input"+logging.AnsiReset)
+ return parts.Selection, nil
+ }
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runRewrite(cctx, cfg, client, instr, cleaned)
+ case ActionDiagnostics:
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runDiagnostics(cctx, cfg, client, parts.Diagnostics, parts.Selection)
+ case ActionDocument:
+ cctx, cancel := timeout10s(ctx)
+ defer cancel()
+ return runDocument(cctx, cfg, client, parts.Selection)
+ case ActionGoTest:
+ cctx, cancel := timeout8s(ctx)
+ defer cancel()
+ return runGoTest(cctx, cfg, client, parts.Selection)
+ default:
+ return parts.Selection, nil
+ }
+}
+
+// client construction is shared via internal/llmutils
diff --git a/internal/hexaiaction/run_test.go b/internal/hexaiaction/run_test.go
new file mode 100644
index 0000000..87fbfa8
--- /dev/null
+++ b/internal/hexaiaction/run_test.go
@@ -0,0 +1,51 @@
+package hexaiaction
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llm"
+)
+
+type fakeDoer struct{ out string }
+
+func (f fakeDoer) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) {
+ return f.out, nil
+}
+
+func TestExecuteAction_Skip(t *testing.T) {
+ cfg := appconfig.App{}
+ parts := InputParts{Selection: "data"}
+ out, err := executeAction(context.Background(), ActionSkip, parts, cfg, fakeDoer{"IGN"}, nil)
+ if err != nil || out != "data" {
+ t.Fatalf("skip failed: %q %v", out, err)
+ }
+}
+
+func TestExecuteAction_Rewrite_Document_GoTest(t *testing.T) {
+ cfg := appconfig.Load(nil) // defaults
+ // Use fenced output to exercise StripFences
+ client := fakeDoer{"```\nDONE\n```"}
+
+ // rewrite with inline instruction
+ sel := ";change;\ncode"
+ out, err := executeAction(context.Background(), ActionRewrite, InputParts{Selection: sel}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("rewrite failed: %q %v", out, err)
+ }
+
+ // document
+ out, err = executeAction(context.Background(), ActionDocument, InputParts{Selection: "code"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("document failed: %q %v", out, err)
+ }
+
+ // go test
+ out, err = executeAction(context.Background(), ActionGoTest, InputParts{Selection: "func A(){}"}, cfg, client, nil)
+ if err != nil || strings.TrimSpace(out) != "DONE" {
+ t.Fatalf("gotest failed: %q %v", out, err)
+ }
+}
+
diff --git a/internal/hexaiaction/tui.go b/internal/hexaiaction/tui.go
new file mode 100644
index 0000000..16988c0
--- /dev/null
+++ b/internal/hexaiaction/tui.go
@@ -0,0 +1,118 @@
+package hexaiaction
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// item implements list.Item
+type item struct {
+ title, desc string
+ kind ActionKind
+ hotkey rune
+}
+
+func (i item) Title() string { return i.title }
+func (i item) Description() string { return i.desc }
+func (i item) FilterValue() string { return i.title }
+
+type model struct {
+ list list.Model
+ chosen ActionKind
+ done bool
+}
+
+func newModel() model {
+ items := []list.Item{
+ item{title: "Rewrite selection", desc: "", kind: ActionRewrite, hotkey: 'r'},
+ item{title: "Document code", desc: "", kind: ActionDocument, hotkey: 'c'},
+ item{title: "Generate Go unit test(s)", desc: "", kind: ActionGoTest, hotkey: 't'},
+ item{title: "Skip", desc: "", kind: ActionSkip, hotkey: 's'},
+ }
+ l := list.New(items, oneLineDelegate{}, 0, 0)
+ l.Title = "Select Hexai Action"
+ l.SetShowHelp(false)
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(false)
+ return model{list: l}
+}
+
+func (m model) Init() tea.Cmd { return nil }
+
+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ return handleKey(m, msg)
+ case tea.WindowSizeMsg:
+ m.list.SetSize(msg.Width, msg.Height)
+ }
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func handleKey(m model, msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ raw := msg.String()
+ low := strings.ToLower(raw)
+ switch low {
+ case "esc", "q":
+ // Treat ESC and q as Skip/quit
+ m.chosen = ActionSkip
+ m.done = true
+ return m, tea.Quit
+ case "enter":
+ if it, ok := m.list.SelectedItem().(item); ok {
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ case "j", "down":
+ m.list.CursorDown()
+ case "k", "up":
+ m.list.CursorUp()
+ case "g", "home":
+ m.list.Select(0)
+ case "end":
+ if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
+ case "s", "r", "c", "t":
+ items := m.list.Items()
+ for i := 0; i < len(items); i++ {
+ if it, ok := items[i].(item); ok && strings.ToLower(string(it.hotkey)) == low {
+ m.list.Select(i)
+ m.chosen = it.kind
+ m.done = true
+ return m, tea.Quit
+ }
+ }
+ }
+ if raw == "G" { // Shift+G jumps to end
+ if n := len(m.list.Items()); n > 0 { m.list.Select(n - 1) }
+ }
+ return m, nil
+}
+
+func (m model) View() string {
+ if m.done {
+ return ""
+ }
+ return m.list.View()
+}
+
+// RunTUI returns the chosen ActionKind.
+func RunTUI() (ActionKind, error) {
+ p := tea.NewProgram(newModel())
+ md, err := p.Run()
+ if err != nil {
+ return ActionSkip, err
+ }
+ if m, ok := md.(model); ok {
+ if m.chosen == "" {
+ return ActionSkip, nil
+ }
+ return m.chosen, nil
+ }
+ return ActionSkip, fmt.Errorf("unexpected model type")
+}
diff --git a/internal/hexaiaction/tui_delegate.go b/internal/hexaiaction/tui_delegate.go
new file mode 100644
index 0000000..0e5a68c
--- /dev/null
+++ b/internal/hexaiaction/tui_delegate.go
@@ -0,0 +1,35 @@
+package hexaiaction
+
+import (
+ "fmt"
+ "io"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// oneLineDelegate renders a single compact line per item, no spacing.
+type oneLineDelegate struct{}
+
+var (
+ hotStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205"))
+ cursorStyle = lipgloss.NewStyle().Bold(true)
+)
+
+func (oneLineDelegate) Height() int { return 1 }
+func (oneLineDelegate) Spacing() int { return 0 }
+func (oneLineDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
+func (oneLineDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+ title := listItem.FilterValue()
+ hk := '?'
+ if it, ok := listItem.(item); ok {
+ hk = it.hotkey
+ }
+ hot := hotStyle.Render(fmt.Sprintf(" (%c)", hk))
+ cursor := " "
+ if index == m.Index() {
+ cursor = cursorStyle.Render("> ")
+ }
+ fmt.Fprintf(w, "%s%s%s", cursor, title, hot)
+}
diff --git a/internal/hexaiaction/tui_delegate_test.go b/internal/hexaiaction/tui_delegate_test.go
new file mode 100644
index 0000000..27881e4
--- /dev/null
+++ b/internal/hexaiaction/tui_delegate_test.go
@@ -0,0 +1,32 @@
+package hexaiaction
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+
+ "github.com/charmbracelet/bubbles/list"
+)
+
+func stripANSI(s string) string {
+ re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+ return re.ReplaceAllString(s, "")
+}
+
+func TestOneLineDelegate_Render(t *testing.T) {
+ items := []list.Item{item{title: "Rewrite selection", kind: ActionRewrite, hotkey: 'r'}}
+ m := list.New(items, oneLineDelegate{}, 0, 0)
+ m.Select(0)
+ var b bytes.Buffer
+ oneLineDelegate{}.Render(&b, m, 0, items[0])
+ out := stripANSI(b.String())
+ if !regexp.MustCompile(`> \w`).MatchString(out) {
+ t.Fatalf("expected cursor prefix in %q", out)
+ }
+ if !regexp.MustCompile(`Rewrite selection`).MatchString(out) {
+ t.Fatalf("expected title in %q", out)
+ }
+ if !regexp.MustCompile(`\(r\)`).MatchString(out) {
+ t.Fatalf("expected hotkey in %q", out)
+ }
+}
diff --git a/internal/hexaiaction/tui_test.go b/internal/hexaiaction/tui_test.go
new file mode 100644
index 0000000..0f7d091
--- /dev/null
+++ b/internal/hexaiaction/tui_test.go
@@ -0,0 +1,36 @@
+package hexaiaction
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestHandleKey_EscSkips(t *testing.T) {
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyEsc})
+ got, ok := nm.(model)
+ if !ok || !got.done || got.chosen != ActionSkip {
+ t.Fatalf("esc should skip: ok=%v done=%v chosen=%v", ok, got.done, got.chosen)
+ }
+}
+
+func TestHandleKey_QuickHotkey(t *testing.T) {
+ m := newModel()
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
+ got := nm.(model)
+ if !got.done || got.chosen != ActionRewrite {
+ t.Fatalf("r should choose rewrite: done=%v chosen=%v", got.done, got.chosen)
+ }
+}
+
+func TestHandleKey_JumpEndWithG(t *testing.T) {
+ m := newModel()
+ // raw 'G' rune should jump to end (special cased)
+ nm, _ := handleKey(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}})
+ got := nm.(model)
+ if idx := got.list.Index(); idx != len(got.list.Items())-1 {
+ t.Fatalf("G should jump to end, index=%d", idx)
+ }
+}
+
diff --git a/internal/hexaiaction/types.go b/internal/hexaiaction/types.go
new file mode 100644
index 0000000..5e01cfc
--- /dev/null
+++ b/internal/hexaiaction/types.go
@@ -0,0 +1,19 @@
+package hexaiaction
+
+// Summary: Core types and constants for hexai-action.
+
+type ActionKind string
+
+const (
+ ActionSkip ActionKind = "skip"
+ ActionRewrite ActionKind = "rewrite"
+ ActionDiagnostics ActionKind = "diagnostics"
+ ActionDocument ActionKind = "document"
+ ActionGoTest ActionKind = "gotest"
+)
+
+// InputParts represents parsed stdin input for actions.
+type InputParts struct {
+ Selection string
+ Diagnostics []string
+}