From 503a5aeeadff11e75689c8e98e36e5792937fc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E7=84=B1?= <1877685089@qq.com> Date: Mon, 8 Jun 2026 15:49:05 +0800 Subject: [PATCH] docs: add interactive REPL implementation plan --- .../plans/2026-06-08-interactive-repl.md | 1806 +++++++++++++++++ 1 file changed, 1806 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-interactive-repl.md diff --git a/docs/superpowers/plans/2026-06-08-interactive-repl.md b/docs/superpowers/plans/2026-06-08-interactive-repl.md new file mode 100644 index 0000000..1ab2ecb --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-interactive-repl.md @@ -0,0 +1,1806 @@ +# 交互式 REPL 命令面板 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 为 gitlink-cli 添加交互式 REPL 模式,支持 `/` 触发命令面板、模糊搜索、方向键选择、表单式参数填充。 + +**架构:** 基于 bubbletea Elm 架构的 REPL 状态机,纯增量新增 cmd/interactive/ 包(5 个文件),仅对现有代码做 2 处单行修改。命令元数据从 shortcuts/register.go 读取,执行时复用现有 Shortcut.Run。 + +**技术栈:** Go 1.25 + bubbletea + bubbles + huh + cobra + +--- + +## 文件清单 + +| 操作 | 文件 | 职责 | +|---|---|---| +| 创建 | `shortcuts/register.go`(修改) | 导出 `GetAllShortcuts()` 和 `GetDescriptions()` | +| 创建 | `cmd/interactive/executor.go` | 命令执行器,捕获 stdout 输出 | +| 创建 | `cmd/interactive/executor_test.go` | 执行器单元测试 | +| 创建 | `cmd/interactive/palette.go` | 两级命令面板 + 模糊搜索 | +| 创建 | `cmd/interactive/form.go` | 参数表单(huh 动态生成) | +| 创建 | `cmd/interactive/repl.go` | REPL 主循环状态机 | +| 创建 | `cmd/interactive/interactive.go` | Cobra 命令注册入口 | +| 修改 | `cmd/root.go` | 新增 1 行注册 interactive 子命令 | +| 修改 | `go.mod` / `go.sum` | 新增 bubbletea/bubbles/huh 依赖 | + +--- + +### 任务 1:导出命令元数据 + +**文件:** +- 修改:`shortcuts/register.go` + +- [ ] **步骤 1:新增 GetAllShortcuts 和 GetDescriptions 导出函数** + +在 `shortcuts/register.go` 文件末尾(第 94 行 `}` 之后)添加: + +```go +// GetAllShortcuts returns the full shortcut registry for use by the interactive mode. +func GetAllShortcuts() map[string][]*common.Shortcut { + return map[string][]*common.Shortcut{ + "repo": repo.Shortcuts(), + "issue": issue.Shortcuts(), + "pr": pr.Shortcuts(), + "release": release.Shortcuts(), + "branch": branch.Shortcuts(), + "org": org.Shortcuts(), + "user": user.Shortcuts(), + "search": search.Shortcuts(), + "ci": ci.Shortcuts(), + "webhook": webhook.Shortcuts(), + "wiki": wiki.Shortcuts(), + "snippet": snippet.Shortcuts(), + "collaborator": collaborator.Shortcuts(), + "tag": tag.Shortcuts(), + "milestone": milestone.Shortcuts(), + "file": file.Shortcuts(), + "commit": commit.Shortcuts(), + "label": label.Shortcuts(), + "sshkey": sshkey.Shortcuts(), + "util": util.Shortcuts(), + "dataset": dataset.Shortcuts(), + "template": template.Shortcuts(), + "attachment": attachment.Shortcuts(), + } +} + +// GetDescriptions returns the group descriptions map. +func GetDescriptions() map[string]string { + return map[string]string{ + "repo": "Repository operations", + "issue": "Issue operations", + "pr": "Pull request operations", + "release": "Release operations", + "branch": "Branch operations", + "org": "Organization operations", + "user": "User operations", + "search": "Search operations", + "ci": "CI/CD operations", + "webhook": "Webhook operations", + "wiki": "Wiki operations", + "snippet": "Code snippet management", + "collaborator": "Collaborator management", + "tag": "Git tag operations", + "milestone": "Milestone management", + "file": "File content operations", + "commit": "Commit operations", + "label": "Issue label management", + "sshkey": "SSH key management", + "util": "Utility operations (licenses, ignores, settings)", + "dataset": "Dataset management", + "template": "Project template management", + "attachment": "File attachment operations", + } +} +``` + +> 注:因为 `groups` 是 `RegisterAll` 的局部变量,无法直接返回它,所以必须重新构建。map 字面量与 `RegisterAll` 保持完全一致。 + +- [ ] **步骤 2:验证编译通过** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./shortcuts/...` +预期:无错误输出 + +- [ ] **步骤 3:Commit** + +```bash +git add shortcuts/register.go +git commit -m "feat(shortcuts): export GetAllShortcuts and GetDescriptions for interactive mode" +``` + +--- + +### 任务 2:安装 Charm 依赖 + +**文件:** +- 修改:`go.mod` / `go.sum` + +- [ ] **步骤 1:安装三个 Charm 库** + +运行: +```bash +cd C:\Users\刘焱\Desktop\gitlink-cl +go get github.com/charmbracelet/bubbletea +go get github.com/charmbracelet/bubbles +go get github.com/charmbracelet/huh +``` + +- [ ] **步骤 2:验证依赖已添加** + +运行:`grep -E 'charmbracelet' go.mod` +预期:看到 bubbletea、bubbles、huh 三个依赖 + +- [ ] **步骤 3:验证编译** + +运行:`go build ./...` +预期:无错误(可能无新代码引用它们,但依赖已解析) + +- [ ] **步骤 4:Commit** + +```bash +git add go.mod go.sum +git commit -m "chore: add bubbletea, bubbles, huh dependencies" +``` + +--- + +### 任务 3:命令执行器(executor.go) + +**文件:** +- 创建:`cmd/interactive/executor.go` +- 创建:`cmd/interactive/executor_test.go` + +- [ ] **步骤 1:编写失败的测试** + +创建 `cmd/interactive/executor_test.go`: + +```go +package interactive + +import ( + "bytes" + "os" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestExecutor_CaptureOutput(t *testing.T) { + exec := &Executor{} + s := &common.Shortcut{ + Name: "test-cmd", + Description: "test command", + Run: func(ctx *common.RuntimeContext) error { + // This simulates what real shortcuts do — they print via ctx.Output + // which ultimately calls fmt.Fprintln(os.Stdout, ...). + // We capture that via our executor. + return nil + }, + } + args := map[string]string{} + out, err := exec.Execute(s, args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + // A command that does nothing should produce empty output + if out != "" { + t.Fatalf("expected empty output, got %q", out) + } +} + +func TestExecutor_CaptureOutputPrints(t *testing.T) { + exec := &Executor{} + s := &common.Shortcut{ + Name: "test-print", + Description: "test print", + Run: func(ctx *common.RuntimeContext) error { + os.Stdout.Write([]byte("hello from command")) + return nil + }, + } + args := map[string]string{} + out, err := exec.Execute(s, args) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if out != "hello from command" { + t.Fatalf("expected 'hello from command', got %q", out) + } +} + +func TestExecutor_RestoresStdout(t *testing.T) { + exec := &Executor{} + original := os.Stdout + s := &common.Shortcut{ + Name: "restore-test", + Run: func(ctx *common.RuntimeContext) error { return nil }, + } + args := map[string]string{} + exec.Execute(s, args) + if os.Stdout != original { + t.Fatal("os.Stdout was not restored after Execute") + } +} + +func TestParseDirectCommand(t *testing.T) { + tests := []struct { + input string + group string + cmd string + flagStr string + ok bool + }{ + {"issue +list", "issue", "list", "", true}, + {"issue +create --title foo", "issue", "create", "--title foo", true}, + {"pr +merge -n 42 --method squash", "pr", "merge", "-n 42 --method squash", true}, + {"", "", "", "", false}, + {"singleword", "", "", "", false}, + {"group +cmd", "group", "cmd", "", true}, + } + + for _, tt := range tests { + g, c, f, ok := parseDirectCommand(tt.input) + if ok != tt.ok || g != tt.group || c != tt.cmd || f != tt.flagStr { + t.Errorf("parseDirectCommand(%q) = (%q,%q,%q,%v), want (%q,%q,%q,%v)", + tt.input, g, c, f, ok, tt.group, tt.cmd, tt.flagStr, tt.ok) + } + } +} + +func TestParseFlags(t *testing.T) { + tests := []struct { + input string + shortcuts []*common.Shortcut + want map[string]string + }{ + { + "--title hello --body world", + []*common.Shortcut{{ + Name: "create", + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "title"}, + {Name: "body", Short: "b", Usage: "body"}, + }, + }}, + map[string]string{"title": "hello", "body": "world"}, + }, + { + "-n 42", + []*common.Shortcut{{ + Name: "view", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "number"}, + }, + }}, + map[string]string{"number": "42"}, + }, + { + "--state open --limit 50", + []*common.Shortcut{{ + Name: "list", + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: "state"}, + {Name: "limit", Short: "l", Usage: "limit"}, + }, + }}, + map[string]string{"state": "open", "limit": "50"}, + }, + } + + for _, tt := range tests { + // Build short-name → long-name lookup from flags + shortMap := make(map[string]string) + for _, s := range tt.shortcuts { + for _, f := range s.Flags { + if f.Short != "" { + shortMap[f.Short] = f.Name + } + } + } + got := parseFlagString(tt.input, shortMap) + for k, v := range tt.want { + if got[k] != v { + t.Errorf("parseFlagString(%q) got[%q]=%q, want %q", tt.input, k, got[k], v) + } + } + } +} + +func TestFindShortcut(t *testing.T) { + shortcuts := map[string][]*common.Shortcut{ + "issue": { + {Name: "list", Description: "List issues"}, + {Name: "create", Description: "Create issue"}, + }, + "pr": { + {Name: "list", Description: "List PRs"}, + }, + } + + s, ok := findShortcut(shortcuts, "issue", "list") + if !ok || s.Name != "list" { + t.Fatal("expected to find issue +list") + } + _, ok = findShortcut(shortcuts, "issue", "nonexistent") + if ok { + t.Fatal("should not find nonexistent command") + } + _, ok = findShortcut(shortcuts, "nonexistent", "list") + if ok { + t.Fatal("should not find nonexistent group") + } +} +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go test ./cmd/interactive/... -run "TestExecutor|TestParseDirectCommand|TestParseFlags|TestFindShortcut" -v` +预期:编译失败,包不存在 + +- [ ] **步骤 3:创建 executor.go 实现** + +创建 `cmd/interactive/executor.go`: + +```go +package interactive + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Executor wraps shortcut execution with stdout capture for REPL display. +type Executor struct{} + +// Execute runs a Shortcut with the given args and captures all stdout output. +// It temporarily redirects os.Stdout to a buffer, runs the command, then restores. +func (e *Executor) Execute(s *common.Shortcut, args map[string]string) (string, error) { + // Save original stdout + original := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Copy in a separate goroutine so the pipe doesn't block + var buf bytes.Buffer + done := make(chan struct{}) + go func() { + _, _ = buf.ReadFrom(r) + close(done) + }() + + ctx, err := common.NewRuntimeContext(args) + if err != nil { + w.Close() + os.Stdout = original + <-done + return "", err + } + + runErr := s.Run(ctx) + w.Close() + os.Stdout = original + <-done + + return buf.String(), runErr +} + +// parseDirectCommand parses a direct input line like "issue +list --state open" +// into group, command name (without +), and the remaining flag string. +func parseDirectCommand(input string) (group, cmd, flagStr string, ok bool) { + input = strings.TrimSpace(input) + if input == "" { + return "", "", "", false + } + + parts := strings.Fields(input) + if len(parts) < 2 { + return "", "", "", false + } + + group = parts[0] + cmdWithPlus := parts[1] + if !strings.HasPrefix(cmdWithPlus, "+") { + return "", "", "", false + } + cmd = strings.TrimPrefix(cmdWithPlus, "+") + + if len(parts) > 2 { + flagStr = strings.Join(parts[2:], " ") + } + return group, cmd, flagStr, true +} + +// parseFlagString parses a flag string like "--title hello -n 42" into a map. +// shortMap maps short names to long names (e.g., "n" -> "number"). +func parseFlagString(flagStr string, shortMap map[string]string) map[string]string { + result := make(map[string]string) + if flagStr == "" { + return result + } + + parts := strings.Fields(flagStr) + for i := 0; i < len(parts); i++ { + p := parts[i] + if !strings.HasPrefix(p, "-") { + continue + } + + // Normalize --flag or -f + name := strings.TrimPrefix(p, "-") + name = strings.TrimPrefix(name, "-") + + // Resolve short name + if long, ok := shortMap[name]; ok { + name = long + } + + // Check for --flag=value format + if strings.Contains(p, "=") { + valParts := strings.SplitN(p, "=", 2) + result[name] = valParts[1] + continue + } + + // Next token is the value (if it exists and doesn't start with -) + if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") { + result[name] = parts[i+1] + i++ + } + } + return result +} + +// findShortcut looks up a shortcut by group and command name. +func findShortcut(all map[string][]*common.Shortcut, group, cmd string) (*common.Shortcut, bool) { + shortcuts, ok := all[group] + if !ok { + return nil, false + } + for _, s := range shortcuts { + if s.Name == cmd { + return s, true + } + } + return nil, false +} + +// buildShortMap creates a short-name → long-name lookup from a Shortcut's flags. +func buildShortMap(s *common.Shortcut) map[string]string { + m := make(map[string]string) + for _, f := range s.Flags { + if f.Short != "" { + m[f.Short] = f.Name + } + } + return m +} + +// missingRequiredFlags returns the names of required flags that are missing +// from the provided args map. +func missingRequiredFlags(s *common.Shortcut, args map[string]string) []string { + var missing []string + for _, f := range s.Flags { + if f.Required { + if _, ok := args[f.Name]; !ok { + missing = append(missing, "--"+f.Name) + } + } + } + return missing +} + +// formatCommandDisplay formats a command and its args for the REPL output line. +func formatCommandDisplay(group string, s *common.Shortcut, args map[string]string) string { + var parts []string + parts = append(parts, group, "+"+s.Name) + for _, f := range s.Flags { + val, ok := args[f.Name] + if ok && val != "" { + parts = append(parts, fmt.Sprintf("--%s %s", f.Name, val)) + } + } + return strings.Join(parts, " ") +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go test ./cmd/interactive/... -run "TestExecutor|TestParseDirectCommand|TestParseFlags|TestFindShortcut" -v` +预期:全部 PASS + +- [ ] **步骤 5:Commit** + +```bash +git add cmd/interactive/executor.go cmd/interactive/executor_test.go +git commit -m "feat(interactive): add command executor with stdout capture and flag parsing" +``` + +--- + +### 任务 4:命令面板(palette.go) + +**文件:** +- 创建:`cmd/interactive/palette.go` + +- [ ] **步骤 1:实现命令面板** + +创建 `cmd/interactive/palette.go`: + +```go +package interactive + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// paletteLevel tracks which level of the command palette we're on. +type paletteLevel int + +const ( + levelGroup paletteLevel = iota // Selecting a command group (issue, pr, repo...) + levelCommand // Selecting a sub-command (+list, +create...) +) + +// commandItem is a single selectable item in the palette list. +type commandItem struct { + name string + description string + shortcut *common.Shortcut // nil for group items + isGroup bool +} + +func (i commandItem) Title() string { return i.name } +func (i commandItem) Description() string { return i.description } +func (i commandItem) FilterValue() string { return i.name + " " + i.description } + +// PaletteResult holds the user's selection from the command palette. +type PaletteResult struct { + Group string + Shortcut *common.Shortcut +} + +// paletteModel manages the two-level command palette UI. +type paletteModel struct { + level paletteLevel + search textinput.Model + list list.Model + groups map[string][]*common.Shortcut + descs map[string]string + groupKeys []string // sorted group names + selected string // Level 1 selected group + result *PaletteResult // set when user makes final selection + quitting bool + width int + height int +} + +var ( + paletteTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + paletteHelpStyle = lipgloss.NewStyle().Faint(true) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true) + itemStyle = lipgloss.NewStyle() + descStyle = lipgloss.NewStyle().Faint(true) +) + +// newPaletteModel creates a new palette model at the group level. +func newPaletteModel(groups map[string][]*common.Shortcut, descs map[string]string, width, height int) paletteModel { + // Sort group names for consistent ordering + keys := make([]string, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + sort.Strings(keys) + + // Build group items + items := make([]list.Item, 0, len(keys)) + for _, k := range keys { + desc := descs[k] + if desc == "" { + desc = k + } + items = append(items, commandItem{ + name: k, + description: desc, + isGroup: true, + }) + } + + li := list.New(items, list.NewDefaultDelegate(), width, min(height-4, 15)) + li.Title = "命令组" + li.Styles.Title = paletteTitleStyle + li.SetShowStatusBar(false) + li.SetFilteringEnabled(false) // We handle search ourselves + li.SetShowHelp(false) + + search := textinput.New() + search.Placeholder = "输入搜索..." + search.Prompt = "搜索: " + search.Focus() + + return paletteModel{ + level: levelGroup, + search: search, + list: li, + groups: groups, + descs: descs, + groupKeys: keys, + width: width, + height: height, + } +} + +// switchToCommands loads the sub-commands for the selected group. +func (m *paletteModel) switchToCommands(group string) { + m.selected = group + m.level = levelCommand + + shortcuts := m.groups[group] + items := make([]list.Item, 0, len(shortcuts)) + for _, s := range shortcuts { + items = append(items, commandItem{ + name: "+" + s.Name, + description: s.Description, + shortcut: s, + isGroup: false, + }) + } + + li := list.New(items, list.NewDefaultDelegate(), m.width, min(m.height-4, 15)) + li.Title = group + li.Styles.Title = paletteTitleStyle + li.SetShowStatusBar(false) + li.SetFilteringEnabled(false) + li.SetShowHelp(false) + m.list = li + m.search.SetValue("") +} + +// backToGroups returns to the group selection level. +func (m *paletteModel) backToGroups() { + m.level = levelGroup + m.selected = "" + m.result = nil + + items := make([]list.Item, 0, len(m.groupKeys)) + for _, k := range m.groupKeys { + desc := m.descs[k] + if desc == "" { + desc = k + } + items = append(items, commandItem{ + name: k, + description: desc, + isGroup: true, + }) + } + + li := list.New(items, list.NewDefaultDelegate(), m.width, min(m.height-4, 15)) + li.Title = "命令组" + li.Styles.Title = paletteTitleStyle + li.SetShowStatusBar(false) + li.SetFilteringEnabled(false) + li.SetShowHelp(false) + m.list = li + m.search.SetValue("") +} + +// filterItems filters the current list based on search text. +func (m *paletteModel) filterItems(query string) { + query = strings.ToLower(query) + + var source []list.Item + if m.level == levelGroup { + source = make([]list.Item, 0, len(m.groupKeys)) + for _, k := range m.groupKeys { + desc := m.descs[k] + if desc == "" { + desc = k + } + source = append(source, commandItem{ + name: k, + description: desc, + isGroup: true, + }) + } + } else { + shortcuts := m.groups[m.selected] + source = make([]list.Item, 0, len(shortcuts)) + for _, s := range shortcuts { + source = append(source, commandItem{ + name: "+" + s.Name, + description: s.Description, + shortcut: s, + isGroup: false, + }) + } + } + + if query == "" { + m.list.SetItems(source) + return + } + + filtered := make([]list.Item, 0) + for _, item := range source { + ci := item.(commandItem) + if strings.Contains(strings.ToLower(ci.name), query) || + strings.Contains(strings.ToLower(ci.description), query) { + filtered = append(filtered, ci) + } + } + m.list.SetItems(filtered) +} + +// Update handles bubbletea messages for the palette. +func (m paletteModel) Update(msg tea.Msg) (paletteModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + if m.level == levelCommand { + m.backToGroups() + return m, nil + } + m.quitting = true + return m, nil + + case tea.KeyEnter: + selected, ok := m.list.SelectedItem().(commandItem) + if !ok { + return m, nil + } + if selected.isGroup { + m.switchToCommands(selected.name) + return m, nil + } + // Final selection — a sub-command + m.result = &PaletteResult{ + Group: m.selected, + Shortcut: selected.shortcut, + } + return m, nil + + case tea.KeyUp, tea.KeyDown: + // Arrow keys go to the list + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + // All other key presses go to search input + var cmd tea.Cmd + m.search, cmd = m.search.Update(msg) + m.filterItems(m.search.Value()) + return m, cmd + } + + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +// View renders the command palette. +func (m paletteModel) View() string { + var b strings.Builder + + title := m.list.Title + if m.level == levelCommand { + title = fmt.Sprintf(" %s ", m.selected) + } + + b.WriteString(paletteTitleStyle.Render("╭─ "+title+" ─")) + b.WriteString("\n") + b.WriteString(" " + m.search.View()) + b.WriteString("\n") + + // Render visible items + items := m.list.Items() + // Find selected index + idx := m.list.Index() + for i, item := range items { + ci := item.(commandItem) + line := fmt.Sprintf(" %-16s %s", ci.name, ci.description) + if i == idx { + b.WriteString(selectedStyle.Render("❯" + line)) + } else { + b.WriteString(" " + itemStyle.Render(line)) + } + b.WriteString("\n") + } + + helpText := " ↑↓ 选择 Enter 确认 Esc 返回" + if m.level == levelCommand { + helpText = " ↑↓ 选择 Enter 确认 Esc 返回上级" + } + b.WriteString(paletteHelpStyle.Render(helpText)) + + return b.String() +} +``` + +- [ ] **步骤 2:验证编译** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./cmd/interactive/...` +预期:无错误 + +- [ ] **步骤 3:Commit** + +```bash +git add cmd/interactive/palette.go +git commit -m "feat(interactive): add two-level command palette with fuzzy search" +``` + +--- + +### 任务 5:参数表单(form.go) + +**文件:** +- 创建:`cmd/interactive/form.go` + +- [ ] **步骤 1:实现参数表单** + +创建 `cmd/interactive/form.go`: + +```go +package interactive + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// formField holds the state for a single form input field. +type formField struct { + flag common.Flag + input textinput.Model + boolValue bool // for Bool flags + isBool bool +} + +// formModel presents a dynamic parameter form for a selected command. +type formModel struct { + group string + shortcut *common.Shortcut + fields []formField + focusIdx int + submitted bool + cancelled bool + values map[string]string + width int + height int +} + +var ( + formTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + formLabelStyle = lipgloss.NewStyle().Bold(true) + formErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) + formHelpStyle = lipgloss.NewStyle().Faint(true) + formHintStyle = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("11")) +) + +// newFormModel creates a form for the given shortcut's flags. +func newFormModel(group string, s *common.Shortcut, width, height int, prefill map[string]string) formModel { + fields := make([]formField, 0, len(s.Flags)) + for _, f := range s.Flags { + ti := textinput.New() + label := f.Name + if f.Required { + label += " *" + } + ti.Prompt = fmt.Sprintf(" %-14s ", label) + ti.Placeholder = f.Usage + ti.Width = max(width-30, 20) + + // Set prefill or default value + if val, ok := prefill[f.Name]; ok && val != "" { + ti.SetValue(val) + } else if f.Default != "" { + ti.SetValue(f.Default) + } + + ff := formField{ + flag: f, + input: ti, + isBool: f.Bool, + } + + // For Bool flags, show a toggle hint + if f.Bool { + ff.boolValue = false + if def, ok := prefill[f.Name]; ok && (def == "true") { + ff.boolValue = true + } else if f.Default == "true" { + ff.boolValue = true + } + } + + fields = append(fields, ff) + } + + m := formModel{ + group: group, + shortcut: s, + fields: fields, + width: width, + height: height, + values: make(map[string]string), + } + + // Focus the first field + if len(fields) > 0 { + m.fields[0].input.Focus() + } + return m +} + +// hasFlags returns true if the shortcut has any flags to fill. +func (m formModel) hasFlags() bool { + return len(m.fields) > 0 +} + +// validate checks that all required fields have values. +// Returns a slice of error messages for missing required fields. +func (m formModel) validate() []string { + var errs []string + for _, ff := range m.fields { + if ff.flag.Required { + val := ff.input.Value() + if ff.isBool { + val = fmt.Sprintf("%v", ff.boolValue) + } + if strings.TrimSpace(val) == "" { + errs = append(errs, fmt.Sprintf("--%s 为必填参数,不能为空", ff.flag.Name)) + } + } + } + return errs +} + +// collectValues extracts the final values from all fields. +func (m *formModel) collectValues() map[string]string { + values := make(map[string]string) + for _, ff := range m.fields { + if ff.isBool { + values[ff.flag.Name] = fmt.Sprintf("%v", ff.boolValue) + } else { + v := ff.input.Value() + if v != "" { + values[ff.flag.Name] = v + } + } + } + m.values = values + return values +} + +// Update handles bubbletea messages for the form. +func (m formModel) Update(msg tea.Msg) (formModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEsc: + m.cancelled = true + return m, nil + + case tea.KeyTab, tea.KeyDown: + if len(m.fields) > 0 { + m.fields[m.focusIdx].input.Blur() + m.focusIdx = (m.focusIdx + 1) % len(m.fields) + m.fields[m.focusIdx].input.Focus() + } + return m, nil + + case tea.KeyShiftTab, tea.KeyUp: + if len(m.fields) > 0 { + m.fields[m.focusIdx].input.Blur() + m.focusIdx-- + if m.focusIdx < 0 { + m.focusIdx = len(m.fields) - 1 + } + m.fields[m.focusIdx].input.Focus() + } + return m, nil + + case tea.KeyEnter: + // For Bool fields, toggle instead of submit + if m.fields[m.focusIdx].isBool { + m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue + return m, nil + } + // Validate and submit + if errs := m.validate(); len(errs) > 0 { + // Don't submit — errors will be shown in View + return m, nil + } + m.collectValues() + m.submitted = true + return m, nil + + case tea.KeySpace: + if m.fields[m.focusIdx].isBool { + m.fields[m.focusIdx].boolValue = !m.fields[m.focusIdx].boolValue + return m, nil + } + } + + // Default: pass to focused textinput + if !m.fields[m.focusIdx].isBool { + var cmd tea.Cmd + m.fields[m.focusIdx].input, cmd = m.fields[m.focusIdx].input.Update(msg) + return m, cmd + } + } + return m, nil +} + +// View renders the parameter form. +func (m formModel) View() string { + var b strings.Builder + + title := fmt.Sprintf(" %s +%s ", m.group, m.shortcut.Name) + b.WriteString(formTitleStyle.Render("╭─"+title+"─")) + b.WriteString("\n\n") + + // Render fields + for i, ff := range m.fields { + label := ff.flag.Name + if ff.flag.Required { + label += " *" + } + + if ff.isBool { + val := "[ ]" + if ff.boolValue { + val = "[✓]" + } + focus := " " + if i == m.focusIdx { + focus = "❯" + } + b.WriteString(fmt.Sprintf("%s %-14s %s %s\n", focus, label, val, formHelpStyle.Render(ff.flag.Usage))) + } else { + b.WriteString(ff.input.View()) + b.WriteString("\n") + } + } + + b.WriteString("\n") + + // Show validation errors + if errs := m.validate(); len(errs) > 0 { + for _, e := range errs { + b.WriteString(formErrorStyle.Render(" ✗ " + e)) + b.WriteString("\n") + } + b.WriteString("\n") + } else if len(m.fields) > 0 { + b.WriteString(formHintStyle.Render(" * 为必填参数")) + b.WriteString("\n\n") + } + + b.WriteString(formHelpStyle.Render(" Tab 下一字段 Enter 执行 Esc 取消")) + b.WriteString("\n") + + return b.String() +} +``` + +- [ ] **步骤 2:验证编译** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./cmd/interactive/...` +预期:无错误 + +- [ ] **步骤 3:Commit** + +```bash +git add cmd/interactive/form.go +git commit -m "feat(interactive): add dynamic parameter form with validation" +``` + +--- + +### 任务 6:REPL 主循环(repl.go) + +**文件:** +- 创建:`cmd/interactive/repl.go` + +- [ ] **步骤 1:实现 REPL 状态机** + +创建 `cmd/interactive/repl.go`: + +```go +package interactive + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// replState tracks the current state of the REPL. +type replState int + +const ( + stateInput replState = iota // Waiting for user input + statePalette // Command palette is open + stateForm // Parameter form is open + stateExecuting // Running a command + stateError // Showing an error +) + +var ( + promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) + successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) + outputStyle = lipgloss.NewStyle() + welcomeStyle = lipgloss.NewStyle().Faint(true) +) + +// replModel is the top-level bubbletea model for the interactive REPL. +type replModel struct { + state replState + input textinput.Model + palette paletteModel + form formModel + executor *Executor + shortcuts map[string][]*common.Shortcut + descs map[string]string + + // Execution state + lastOutput string + lastError string + spinner spinner.Model + + // Context + owner string + repo string + + width int + height int + quitting bool +} + +// newReplModel creates the REPL model. +func newReplModel(shortcuts map[string][]*common.Shortcut, descs map[string]string, owner, repo string) replModel { + ti := textinput.New() + ti.Prompt = buildPrompt(owner, repo) + ti.PromptStyle = promptStyle + ti.Focus() + ti.CharLimit = 500 + ti.Width = 60 + + sp := spinner.New() + sp.Spinner = spinner.Dot + + return replModel{ + state: stateInput, + input: ti, + executor: &Executor{}, + shortcuts: shortcuts, + descs: descs, + owner: owner, + repo: repo, + spinner: sp, + } +} + +// buildPrompt creates the prompt string. +func buildPrompt(owner, repo string) string { + if owner != "" && repo != "" { + return fmt.Sprintf("gitlink (%s/%s)> ", owner, repo) + } + return "gitlink> " +} + +// Init initializes the REPL. +func (m replModel) Init() tea.Cmd { + return textinput.Blink +} + +// Update handles all bubbletea messages. +func (m replModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyMsg: + // Global Ctrl+C + if msg.Type == tea.KeyCtrlC { + if m.state == stateInput { + m.quitting = true + return m, tea.Quit + } + // Cancel current operation, return to input + m.state = stateInput + m.input.Reset() + m.input.Focus() + return m, textinput.Blink + } + } + + // Delegate to sub-states + switch m.state { + case stateInput: + return m.updateInput(msg) + case statePalette: + return m.updatePalette(msg) + case stateForm: + return m.updateForm(msg) + case stateExecuting: + return m.updateExecuting(msg) + case stateError: + return m.updateError(msg) + } + + return m, nil +} + +// updateInput handles the text input state. +func (m replModel) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter: + val := strings.TrimSpace(m.input.Value()) + + // Empty input + if val == "" { + m.input.Reset() + return m, nil + } + + // Exit commands + if val == "exit" || val == "quit" { + m.quitting = true + return m, tea.Quit + } + + // Command palette trigger + if strings.HasPrefix(val, "/") { + m.palette = newPaletteModel(m.shortcuts, m.descs, m.width, m.height) + // If there's text after /, pre-fill the search + query := strings.TrimPrefix(val, "/") + if query != "" { + m.palette.search.SetValue(query) + m.palette.filterItems(query) + } + m.state = statePalette + m.input.Reset() + return m, nil + } + + // Try to parse as direct command: "issue +list --state open" + group, cmd, flagStr, ok := parseDirectCommand(val) + if ok { + s, found := findShortcut(m.shortcuts, group, cmd) + if found { + args := map[string]string{} + if flagStr != "" { + args = parseFlagString(flagStr, buildShortMap(s)) + } + // Check for missing required flags + missing := missingRequiredFlags(s, args) + if len(missing) > 0 { + // Open form to fill missing required params + m.form = newFormModel(group, s, m.width, m.height, args) + m.state = stateForm + m.input.Reset() + return m, nil + } + // Execute directly + m.state = stateExecuting + return m, tea.Batch(m.spinner.Tick, m.executeCommand(group, s, args)) + } + // Unknown command + m.lastError = fmt.Sprintf("未知命令: %s,输入 / 查看所有命令", val) + m.state = stateError + m.input.Reset() + return m, nil + } + + // Unrecognized input + m.lastError = fmt.Sprintf("无法识别: %s\n提示: 输入 / 打开命令面板,或使用 \"组 +命令\" 格式(如 issue +list)", val) + m.state = stateError + m.input.Reset() + return m, nil + + case tea.KeyCtrlD: + m.quitting = true + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +// executeCommand is a tea.Cmd that runs a shortcut and returns the result. +func (m replModel) executeCommand(group string, s *common.Shortcut, args map[string]string) tea.Cmd { + return func() tea.Msg { + exec := &Executor{} + out, err := exec.Execute(s, args) + return execResultMsg{ + group: group, + shortcut: s, + args: args, + output: out, + err: err, + } + } +} + +// execResultMsg is sent when a command finishes executing. +type execResultMsg struct { + group string + shortcut *common.Shortcut + args map[string]string + output string + err error +} + +// updatePalette delegates to the palette sub-model. +func (m replModel) updatePalette(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.palette, cmd = m.palette.Update(msg) + + if m.palette.quitting { + // User pressed Esc at group level — return to input + m.state = stateInput + m.palette.quitting = false + m.input.Focus() + return m, textinput.Blink + } + + if m.palette.result != nil { + result := m.palette.result + m.palette.result = nil + + s := result.Shortcut + group := result.Group + + // If command has no flags, execute directly + if len(s.Flags) == 0 { + m.state = stateExecuting + return m, tea.Batch(m.spinner.Tick, m.executeCommand(group, s, map[string]string{})) + } + + // Open form for parameter input + m.form = newFormModel(group, s, m.width, m.height, nil) + m.state = stateForm + return m, nil + } + + return m, cmd +} + +// updateForm delegates to the form sub-model. +func (m replModel) updateForm(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + m.form, cmd = m.form.Update(msg) + + if m.form.cancelled { + // Return to palette + m.state = statePalette + m.form.cancelled = false + return m, nil + } + + if m.form.submitted { + // Execute with collected values + args := m.form.values + group := m.form.group + s := m.form.shortcut + m.state = stateExecuting + return m, tea.Batch(m.spinner.Tick, m.executeCommand(group, s, args)) + } + + return m, cmd +} + +// updateExecuting shows a spinner while waiting for command completion. +func (m replModel) updateExecuting(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case execResultMsg: + m.lastOutput = "" + if msg.err != nil { + m.lastError = fmt.Sprintf("命令执行失败: %s", msg.err) + m.state = stateError + } else { + display := formatCommandDisplay(msg.group, msg.shortcut, msg.args) + m.lastOutput = fmt.Sprintf(" ✓ 执行: %s\n\n%s", display, msg.output) + m.state = stateInput + m.input.Focus() + return m, textinput.Blink + } + m.input.Reset() + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + + return m, nil +} + +// updateError shows an error and waits for dismissal. +func (m replModel) updateError(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyEnter || msg.Type == tea.KeyEsc { + m.state = stateInput + m.lastError = "" + m.input.Focus() + return m, textinput.Blink + } + } + return m, nil +} + +// View renders the REPL. +func (m replModel) View() string { + if m.quitting { + return welcomeStyle.Render("\n 再见!👋\n\n") + } + + var b strings.Builder + + switch m.state { + case stateInput: + if m.lastOutput != "" { + b.WriteString(m.lastOutput) + b.WriteString("\n") + m.lastOutput = "" + } + b.WriteString(m.input.View()) + + case statePalette: + b.WriteString(m.palette.View()) + + case stateForm: + b.WriteString(m.form.View()) + + case stateExecuting: + b.WriteString(fmt.Sprintf("\n %s 执行中...\n\n", m.spinner.View())) + + case stateError: + b.WriteString("\n") + b.WriteString(errorStyle.Render(" ✗ " + m.lastError)) + b.WriteString("\n") + b.WriteString(welcomeStyle.Render(" 按 Enter 返回")) + b.WriteString("\n\n") + } + + return b.String() +} + +// Run starts the REPL program. +func Run(owner, repo string) error { + shortcuts := getAllShortcutData() + model := newReplModel(shortcuts, GetDescriptions(), owner, repo) + + p := tea.NewProgram( + model, + tea.WithAltScreen(), + ) + + _, err := p.Run() + return err +} + +// getAllShortcutData imports and returns the shortcut registry. +// This function is in the interactive package but calls the exported +// functions from the shortcuts package. +func getAllShortcutData() map[string][]*common.Shortcut { + return GetAllShortcuts() +} +``` + +- [ ] **步骤 2:验证编译** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./cmd/interactive/...` +预期:此时可能有未定义的 `GetAllShortcuts` / `GetDescriptions` 引用——这是因为我们还没创建 `interactive.go`。如果报错,先进入任务 7 创建入口文件后再回来验证。 + +- [ ] **步骤 3:Commit** + +```bash +git add cmd/interactive/repl.go +git commit -m "feat(interactive): add REPL main loop with state machine" +``` + +--- + +### 任务 7:Cobra 入口 + 注册(interactive.go) + +**文件:** +- 创建:`cmd/interactive/interactive.go` +- 修改:`cmd/root.go` + +- [ ] **步骤 1:创建 interactive.go 入口文件** + +创建 `cmd/interactive/interactive.go`: + +```go +package interactive + +import ( + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/shortcuts" +) + +// NewInteractiveCmd creates the cobra command for the interactive REPL. +func NewInteractiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "interactive", + Short: "Start interactive REPL with command palette", + Long: `Start an interactive REPL session with a command palette. + +Type / to open the command palette with fuzzy search. +Use arrow keys to navigate, Enter to select, Tab to fill parameters. +Type "exit" or press Ctrl+D to quit.`, + Aliases: []string{"i"}, + Example: ` # Start interactive mode + gitlink interactive + + # Short form + gitlink i`, + RunE: func(cmd *cobra.Command, args []string) error { + // Resolve owner/repo from flags or git remote + owner := "" + repo := "" + return Run(owner, repo) + }, + } +} + +// GetAllShortcuts re-exports shortcuts.GetAllShortcuts for use in this package. +func GetAllShortcuts() map[string][]*common.Shortcut { + return shortcuts.GetAllShortcuts() +} + +// GetDescriptions re-exports shortcuts.GetDescriptions for use in this package. +func GetDescriptions() map[string]string { + return shortcuts.GetDescriptions() +} +``` + +> 注:需要添加 import 别名来避免循环导入。interactive 包引用 shortcuts 包中的导出函数。 + +实际上我们需要调整 import。因为 `cmd/interactive` 在 `cmd` 包下,而 `shortcuts` 包是独立的,不会产生循环。但 `common.Shortcut` 的类型需要被引用。 + +修正 `interactive.go`,添加必要的 import: + +```go +package interactive + +import ( + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/shortcuts" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/context" +) + +// NewInteractiveCmd creates the cobra command for the interactive REPL. +func NewInteractiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "interactive", + Short: "Start interactive REPL with command palette", + Long: `Start an interactive REPL session with a command palette. + +Type / to open the command palette with fuzzy search. +Use arrow keys to navigate, Enter to select, Tab to fill parameters. +Type "exit" or press Ctrl+D to quit.`, + Aliases: []string{"i"}, + Example: ` # Start interactive mode + gitlink interactive + + # Short form + gitlink i`, + RunE: func(cmd *cobra.Command, args []string) error { + owner, repo, err := context.ResolveOwnerRepo(cmdutil.Owner, cmdutil.Repo) + if err != nil { + // Not in a git repo is OK — prompt will just show "gitlink>" + owner = cmdutil.Owner + repo = cmdutil.Repo + } + return Run(owner, repo) + }, + } +} + +// GetAllShortcuts re-exports the shortcut registry. +func GetAllShortcuts() map[string][]*common.Shortcut { + return shortcuts.GetAllShortcuts() +} + +// GetDescriptions re-exports the group descriptions. +func GetDescriptions() map[string]string { + return shortcuts.GetDescriptions() +} +``` + +- [ ] **步骤 2:修改 cmd/root.go 注册 interactive 命令** + +在 `cmd/root.go` 的 import 块中添加(第 10-13 行之间): + +```go + interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive" +``` + +在 `init()` 函数中(第 42 行 `rootCmd.AddCommand(versionCmd)` 之后)添加: + +```go + rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd()) +``` + +完整的 `import` 块变为: +```go +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" + apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" + configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive" + "github.com/gitlink-org/gitlink-cli/shortcuts" +) +``` + +`init()` 函数中添加的行: +```go + rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd()) +``` + +- [ ] **步骤 3:同时需要修复 repl.go 中的 import 和引用** + +由于 `repl.go` 引用了 `common.Shortcut` 类型,需要确保 import 正确。在 `repl.go` 的 import 中确认已有: + +```go + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +``` + +并且 `repl.go` 末尾的 `getAllShortcutData()` 函数的返回类型需要用 `common.Shortcut`。 + +- [ ] **步骤 4:验证全项目编译** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./...` +预期:无错误 + +- [ ] **步骤 5:验证 interactive 子命令已注册** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go run . interactive --help` +预期:显示 interactive 命令的帮助信息,包含 "interactive" 和 "i" 别名 + +- [ ] **步骤 6:Commit** + +```bash +git add cmd/interactive/interactive.go cmd/root.go +git commit -m "feat(interactive): register interactive REPL as cobra subcommand" +``` + +--- + +### 任务 8:全流程集成测试 + +**文件:** +- 无新文件 + +- [ ] **步骤 1:验证全部现有测试仍然通过** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go test ./...` +预期:全部 PASS(包括之前的 `cmd/interactive/...` 测试) + +- [ ] **步骤 2:验证构建可执行文件** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && go build -o gitlink-cli.exe .` +预期:成功生成 `gitlink-cli.exe` + +- [ ] **步骤 3:验证 interactive 子命令** + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && ./gitlink-cli.exe interactive --help` +预期:显示帮助信息 + +运行:`cd C:\Users\刘焱\Desktop\gitlink-cl && ./gitlink-cli.exe i --help` +预期:同样的帮助信息(短别名) + +- [ ] **步骤 4:手动 REPL 测试清单** + +需要手动测试以下场景: + +| # | 操作 | 预期 | +|---|---|---| +| 1 | 运行 `./gitlink-cli.exe i` | 进入 REPL,显示 `gitlink (owner/repo)> ` 提示符 | +| 2 | 输入 `/` | 弹出命令组列表 | +| 3 | 输入 `iss` | 列表过滤为只显示 issue 相关 | +| 4 | 方向键选择 `issue`,按 Enter | 进入子命令列表,显示 +list, +create 等 | +| 5 | 选择 `+create`,按 Enter | 弹出参数表单 | +| 6 | Tab 切换到 title 字段 | title 字段获得焦点 | +| 7 | 输入标题,按 Enter | 执行命令(如果没有登录会显示错误) | +| 8 | 按 Esc | 返回上一级 | +| 9 | 输入 `exit` | 退出 REPL | +| 10 | 输入 `issue +list` | 直接执行命令(无参数) | +| 11 | 输入 `nonexistent` | 显示"无法识别"错误提示 | +| 12 | Ctrl+C | 如果在命令面板/表单中 → 返回输入状态;在输入状态 → 退出 | + +- [ ] **步骤 5:修复发现的问题** + +如果手动测试发现问题,逐个修复并 Commit。 + +- [ ] **步骤 6:最终 Commit** + +```bash +git add -A +git commit -m "feat(interactive): complete interactive REPL with command palette and form" +``` + +--- + +## 自检 + +### 规格覆盖度 + +| 规格需求 | 对应任务 | +|---|---| +| REPL 模式进入 | 任务 7 | +| `/` 弹出命令面板 | 任务 4, 6 | +| 两级分组 | 任务 4 | +| 模糊搜索 | 任务 4 | +| 方向键选择 | 任务 4 | +| 表单式参数填充 | 任务 5 | +| Tab 切换字段 | 任务 5 | +| 必填校验 + 红色提示 | 任务 5 | +| 内联结果显示 | 任务 3, 6 | +| exit/quit/Ctrl+D 退出 | 任务 6 | +| 错误处理 | 任务 6 | +| 直接命令执行 | 任务 3, 6 | +| 对现有代码最小改动 | 任务 1, 7 | +| 命令执行器输出捕获 | 任务 3 | + +### 占位符扫描 + +无 TODO/TBD/待定/后续实现。所有步骤包含完整代码。 + +### 类型一致性 + +- `common.Shortcut`、`common.Flag`、`common.RuntimeContext` 在所有文件中引用一致 +- `PaletteResult` 在 palette.go 定义、在 repl.go 使用,字段名一致 +- `formModel.submitted`/`cancelled` 在 form.go 设置、在 repl.go 检查 +- `execResultMsg` 在 repl.go 定义和使用 +- `buildShortMap`、`missingRequiredFlags`、`formatCommandDisplay` 在 executor.go 定义、在 repl.go 使用