46 KiB
交互式 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",
}
}
注:因为
groups是RegisterAll的局部变量,无法直接返回它,所以必须重新构建。map 字面量与RegisterAll保持完全一致。
- 步骤 2:验证编译通过
运行:cd C:\Users\刘焱\Desktop\gitlink-cl && go build ./shortcuts/...
预期:无错误输出
- 步骤 3:Commit
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 ./...
预期:无错误(可能无新代码引用它们,但依赖已解析)
- 步骤 4:Commit
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
- 步骤 5:Commit
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/...
预期:无错误
- 步骤 3:Commit
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/...
预期:无错误
- 步骤 3:Commit
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:
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
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:
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:
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" 别名
- 步骤 6:Commit
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.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 使用