gitlink-cli/docs/superpowers/plans/2026-06-08-interactive-repl.md

46 KiB
Raw Blame History

交互式 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 行 } 之后)添加:

// 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",
	}
}

注:因为 groupsRegisterAll 的局部变量无法直接返回它所以必须重新构建。map 字面量与 RegisterAll 保持完全一致。

  • 步骤 2验证编译通过

运行:cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./shortcuts/... 预期:无错误输出

  • 步骤 3Commit
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 库

运行:

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 ./... 预期:无错误(可能无新代码引用它们,但依赖已解析)

  • 步骤 4Commit
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

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

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

  • 步骤 5Commit
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

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/... 预期:无错误

  • 步骤 3Commit
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

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/... 预期:无错误

  • 步骤 3Commit
git add cmd/interactive/form.go
git commit -m "feat(interactive): add dynamic parameter form with validation"

任务 6REPL 主循环repl.go

文件:

  • 创建:cmd/interactive/repl.go

  • 步骤 1实现 REPL 状态机

创建 cmd/interactive/repl.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 创建入口文件后再回来验证。

  • 步骤 3Commit
git add cmd/interactive/repl.go
git commit -m "feat(interactive): add REPL main loop with state machine"

任务 7Cobra 入口 + 注册interactive.go

文件:

  • 创建:cmd/interactive/interactive.go

  • 修改:cmd/root.go

  • 步骤 1创建 interactive.go 入口文件

创建 cmd/interactive/interactive.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/interactivecmd 包下,而 shortcuts 包是独立的,不会产生循环。但 common.Shortcut 的类型需要被引用。

修正 interactive.go,添加必要的 import

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 行之间):

	interactiveCmd "github.com/gitlink-org/gitlink-cli/cmd/interactive"

init() 函数中(第 42 行 rootCmd.AddCommand(versionCmd) 之后)添加:

		rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())

完整的 import 块变为:

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() 函数中添加的行:

		rootCmd.AddCommand(interactiveCmd.NewInteractiveCmd())
  • 步骤 3同时需要修复 repl.go 中的 import 和引用

由于 repl.go 引用了 common.Shortcut 类型,需要确保 import 正确。在 repl.go 的 import 中确认已有:

	"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" 别名

  • 步骤 6Commit
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
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.Shortcutcommon.Flagcommon.RuntimeContext 在所有文件中引用一致
  • PaletteResult 在 palette.go 定义、在 repl.go 使用,字段名一致
  • formModel.submitted/cancelled 在 form.go 设置、在 repl.go 检查
  • execResultMsg 在 repl.go 定义和使用
  • buildShortMapmissingRequiredFlagsformatCommandDisplay 在 executor.go 定义、在 repl.go 使用