From 14762556efd616cfd18ddcdfa58df19956315e95 Mon Sep 17 00:00:00 2001 From: iohub Date: Fri, 8 May 2026 11:18:58 +0800 Subject: [PATCH 1/3] refactor(tui): replace skill popup with inline autocomplete Replace the modal skill popup overlay (triggered by '/' in edit mode) with an inline autocomplete widget rendered directly below the textarea. This provides a smoother, non-blocking UX: - Inline suggestions appear as the user types after '/' - Tab cycles through suggestions, Enter selects and submits - Esc dismisses autocomplete without modal context switch - Removes ~100 lines of popup rendering and navigation code --- internal/tui/tui_model.go | 7 +- internal/tui/tui_update.go | 141 +++++++++++++++++++++++++------------ internal/tui/tui_view.go | 96 ++++++++----------------- 3 files changed, 130 insertions(+), 114 deletions(-) diff --git a/internal/tui/tui_model.go b/internal/tui/tui_model.go index 881cdbb..7207413 100644 --- a/internal/tui/tui_model.go +++ b/internal/tui/tui_model.go @@ -208,9 +208,10 @@ type model struct { lastKey string // tracks previous key for multi-key sequences (gg, ZZ) showHelpDialog bool // "?" help overlay in command mode - // Skill popup in edit mode (triggered by '/') - showSkillPopup bool - skillPopupIdx int + // Skill autocomplete in edit mode (inline, not popup) + skillAutoComplete bool // whether autocomplete suggestions are shown + skillSuggestions []string // matching skill names based on current query + skillSuggestionIdx int // currently selected suggestion index // Tool call state tracking: tool_call_id → ToolEntry toolCallEntries map[string]*ToolEntry diff --git a/internal/tui/tui_update.go b/internal/tui/tui_update.go index 730a10a..ec40634 100644 --- a/internal/tui/tui_update.go +++ b/internal/tui/tui_update.go @@ -250,7 +250,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Global popup guard: when any overlay is shown, only allow KeyMsg through. // All other message types (tickMsg, taskEventMsg, WindowSizeMsg, etc.) are // blocked to prevent viewport scrolling behind the overlay. - if m.showSkillPopup || m.showHelpDialog || m.confirmDialog.open || + if m.showHelpDialog || m.confirmDialog.open || m.taskCompleteDialog.open || m.confirmQuitDialog.open || m.confirmCancelDialog.open || m.showHistoryPanel { if _, ok := msg.(tea.KeyMsg); !ok { return m, nil @@ -275,7 +275,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } // Don't schedule next tick if any popup/dialog is showing - if m.showSkillPopup || m.showHelpDialog || m.confirmDialog.open || + if m.showHelpDialog || m.confirmDialog.open || m.taskCompleteDialog.open || m.confirmQuitDialog.open || m.confirmCancelDialog.open || m.showHistoryPanel { return m, nil } @@ -297,42 +297,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: - // Skill popup navigation (takes priority over mode-specific handling) - if m.showSkillPopup { - switch msg.String() { - case "esc", "ctrl+c": - m.showSkillPopup = false - m.skillPopupIdx = 0 - return m, nil - case "up", "k": - if m.skillPopupIdx > 0 { - m.skillPopupIdx-- - } - return m, nil - case "down", "j": - skillNames := m.assistant.SkillRegistry.List() - if m.skillPopupIdx < len(skillNames)-1 { - m.skillPopupIdx++ - } - return m, nil - case "enter": - skillNames := m.assistant.SkillRegistry.List() - if m.skillPopupIdx >= 0 && m.skillPopupIdx < len(skillNames) { - if skill, ok := m.assistant.SkillRegistry.Get(skillNames[m.skillPopupIdx]); ok { - m.showSkillPopup = false - m.skillPopupIdx = 0 - m.infoMsg = fmt.Sprintf("Executing skill: %s", skill.Name) - return m, m.submitTaskWithContent(skill.Content) - } - } - return m, nil - default: - // Any other key dismisses the popup - m.showSkillPopup = false - m.skillPopupIdx = 0 - return m, nil - } - } // Confirmation dialog key handling — takes priority over everything if m.confirmDialog.open { @@ -725,6 +689,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "esc": + // Dismiss skill autocomplete if active + if m.skillAutoComplete { + m.skillAutoComplete = false + m.skillSuggestions = nil + m.skillSuggestionIdx = 0 + return m, nil + } // Show cancel confirmation dialog if task is running if m.taskRunning && m.currentTask != nil && m.currentTask.CancelFunc != nil { m.confirmCancelDialog.open = true @@ -772,16 +743,43 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.PageUp() return m, nil - case "/": - // Trigger skill popup in edit mode - if !m.taskRunning && m.assistant.SkillRegistry != nil && m.assistant.SkillRegistry.Count() > 0 { - m.showSkillPopup = true - m.skillPopupIdx = 0 + case "tab": + // Cycle through skill autocomplete suggestions + if m.skillAutoComplete && len(m.skillSuggestions) > 0 { + m.skillSuggestionIdx = (m.skillSuggestionIdx + 1) % len(m.skillSuggestions) return m, nil } - // Fall through to default: pass '/' to textarea normally + // No autocomplete active: pass tab to textarea + var inputCmd tea.Cmd + m.input, inputCmd = m.input.Update(msg) + return m, inputCmd + + case "enter": + // If skill autocomplete is active, expand the selected skill + if m.skillAutoComplete && len(m.skillSuggestions) > 0 && m.skillSuggestionIdx >= 0 && m.skillSuggestionIdx < len(m.skillSuggestions) { + skillName := m.skillSuggestions[m.skillSuggestionIdx] + if skill, ok := m.assistant.SkillRegistry.Get(skillName); ok { + userContext := strings.TrimSpace(m.input.Value()) + // Combine skill content with user's input as context + combinedContent := skill.Content + "\n\n---\n用户上下文: " + userContext + m.skillAutoComplete = false + m.skillSuggestions = nil + m.skillSuggestionIdx = 0 + m.infoMsg = fmt.Sprintf("正在执行 skill: %s", skill.Name) + return m, m.submitTaskWithContent(combinedContent) + } + } + // Otherwise, insert newline into textarea var inputCmd tea.Cmd m.input, inputCmd = m.input.Update(msg) + m.updateSkillAutocomplete() + return m, inputCmd + + case "/": + // Pass '/' to textarea normally, then check for skill autocomplete + var inputCmd tea.Cmd + m.input, inputCmd = m.input.Update(msg) + m.updateSkillAutocomplete() return m, inputCmd case "@": @@ -814,6 +812,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // are handled in dedicated case branches above. var inputCmd tea.Cmd m.input, inputCmd = m.input.Update(msg) + m.updateSkillAutocomplete() return m, inputCmd } @@ -821,7 +820,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Don't process task events while any popup/dialog is showing. // The global guard at the top of Update() already blocks these messages, // but keep this as a defensive double-check. - if m.showSkillPopup || m.showHelpDialog || m.confirmDialog.open || + if m.showHelpDialog || m.confirmDialog.open || m.taskCompleteDialog.open || m.confirmQuitDialog.open || m.confirmCancelDialog.open || m.showHistoryPanel { return m, nil } @@ -1015,3 +1014,55 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input, cmd = m.input.Update(msg) return m, cmd } + +// updateSkillAutocomplete checks the current input for skill references (/skillname) +// and updates the autocomplete suggestions accordingly. +func (m *model) updateSkillAutocomplete() { + // Only active in edit mode, when not task running, and skills available + if m.commandMode || m.taskRunning || m.assistant.SkillRegistry == nil || m.assistant.SkillRegistry.Count() == 0 { + m.skillAutoComplete = false + m.skillSuggestions = nil + m.skillSuggestionIdx = 0 + return + } + + inputValue := m.input.Value() + + // Find the last '/' in the input to extract the skill query + lastSlash := strings.LastIndex(inputValue, "/") + if lastSlash < 0 { + m.skillAutoComplete = false + m.skillSuggestions = nil + m.skillSuggestionIdx = 0 + return + } + + // Extract the text after the last '/' as the query + query := inputValue[lastSlash+1:] + + // Don't trigger if query is empty (just typed '/') + // But do show all skills as suggestions + allSkills := m.assistant.SkillRegistry.List() + + // Filter skills that match the query (case-insensitive prefix match) + var matches []string + queryLower := strings.ToLower(query) + for _, name := range allSkills { + if strings.HasPrefix(strings.ToLower(name), queryLower) { + matches = append(matches, name) + } + } + + if len(matches) > 0 { + m.skillAutoComplete = true + m.skillSuggestions = matches + // Reset index if out of bounds + if m.skillSuggestionIdx >= len(matches) { + m.skillSuggestionIdx = 0 + } + } else { + m.skillAutoComplete = false + m.skillSuggestions = nil + m.skillSuggestionIdx = 0 + } +} diff --git a/internal/tui/tui_view.go b/internal/tui/tui_view.go index 499756e..d96c6cd 100644 --- a/internal/tui/tui_view.go +++ b/internal/tui/tui_view.go @@ -39,72 +39,6 @@ func (m model) View() string { return m.renderTaskCompleteDialog() } - // Skill popup overlay (edit mode, triggered by '/') - if m.showSkillPopup && m.assistant.SkillRegistry != nil { - // Define styles for skill popup (matching renderHelpDialog) - titleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("39")).Bold(true) - highlightStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("214")).Bold(true) - subtleStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")) - - skillNames := m.assistant.SkillRegistry.List() - if len(skillNames) > 0 { - // Calculate max width needed - maxNameWidth := 0 - for _, name := range skillNames { - if len(name) > maxNameWidth { - maxNameWidth = len(name) - } - } - dialogWidth := maxNameWidth + 20 - if dialogWidth < 50 { - dialogWidth = 50 - } - if dialogWidth > m.termWidth-4 { - dialogWidth = m.termWidth - 4 - } - - // Build content lines - var lines []string - - titleLine := titleStyle.Render(" Skills ") - lines = append(lines, titleLine) - lines = append(lines, "") - - hintLine := subtleStyle.Render("j/k/↑/↓ navigate Enter select Esc cancel") - lines = append(lines, hintLine) - lines = append(lines, "") - - for i, name := range skillNames { - desc := "" - if skill, ok := m.assistant.SkillRegistry.Get(name); ok && skill.Description != "" { - desc = " " + subtleStyle.Render(skill.Description) - } - if i == m.skillPopupIdx { - lines = append(lines, highlightStyle.Render("▶ "+name)+desc) - } else { - lines = append(lines, " "+name+desc) - } - } - - dialogContent := lipgloss.JoinVertical(lipgloss.Left, lines...) - - dialogStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("39")). - Padding(1, 2) - - dialog := dialogStyle.Width(dialogWidth).Render(dialogContent) - - return lipgloss.Place(m.termWidth, m.termHeight, - lipgloss.Center, lipgloss.Center, - dialog, - ) - } - } - var b strings.Builder // Main content area: history panel or scrollable viewport @@ -148,6 +82,36 @@ func (m model) View() string { inputLine := m.input.View() footer.WriteString(lipgloss.NewStyle().Render(inputLine)) footer.WriteString("\n") + + // Inline skill autocomplete suggestions (below textarea) + if m.skillAutoComplete && len(m.skillSuggestions) > 0 { + suggestionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("245")). + PaddingLeft(4) + highlightSuggestionStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Bold(true). + PaddingLeft(4) + + var suggestionParts []string + for i, name := range m.skillSuggestions { + displayName := name + if skill, ok := m.assistant.SkillRegistry.Get(name); ok && skill.Description != "" { + displayName = name + " - " + skill.Description + } + if i == m.skillSuggestionIdx { + suggestionParts = append(suggestionParts, highlightSuggestionStyle.Render("▶ "+displayName)) + } else { + suggestionParts = append(suggestionParts, suggestionStyle.Render(" "+displayName)) + } + } + footer.WriteString(strings.Join(suggestionParts, "\n")) + footer.WriteString("\n") + // Hint line + hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).PaddingLeft(4) + footer.WriteString(hintStyle.Render("Tab 切换 Enter 选择并执行 Esc 关闭")) + footer.WriteString("\n") + } } // Error message From 425f6a76a25dd5b12a362633707708f1c62a60b7 Mon Sep 17 00:00:00 2001 From: iohub Date: Fri, 8 May 2026 11:34:06 +0800 Subject: [PATCH 2/3] refactor(commit): add smart file filtering and automated commit flow - Detect user-staged files and skip filtering for manual staging - Add smart file filtering to exclude data, binary, media, archive, and test fixture files - Remove manual confirmation step, commit directly after filtering - Show git log after commit for post-hoc verification --- .codeactor/skills/commit.md | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/.codeactor/skills/commit.md b/.codeactor/skills/commit.md index 30b53c3..a419141 100644 --- a/.codeactor/skills/commit.md +++ b/.codeactor/skills/commit.md @@ -5,6 +5,8 @@ ## 步骤 1:检查仓库状态 运行 `git status` 和 `git diff --staged` 查看已暂存的变更。如果没有已暂存的变更,运行 `git diff` 查看未暂存的变更。同时运行 `git log --oneline -5` 查看最近的提交风格。 +**提示**:如果 `git diff --cached --name-only` 输出非空(即用户已手动暂存了特定文件),则提示「检测到用户已手动暂存 N 个文件,将只提交这些文件」,后续跳过文件过滤步骤。 + ## 步骤 2:编写 commit message 根据变更内容,编写一条专业的开源项目 commit message,要求: - 使用 Conventional Commits 格式:`(): ` @@ -15,14 +17,36 @@ - **严禁**在代码或 commit 中添加任何形式的 AI 署名 - 风格参考知名的开源项目(如 Linux kernel, Kubernetes, Rust 等) -## 步骤 3:展示并确认 -将编写好的 commit message 展示给用户,请求确认。等待用户回复确认后再继续。 +## 步骤 3:执行提交 + +**智能过滤策略**(按优先级判断): + +### 情况 A:用户已手动暂存文件 +如果 `git diff --cached --name-only` 输出非空: +- 跳过过滤,直接对这些已暂存文件执行 `git commit -m ""` + +### 情况 B:未手动暂存(智能过滤) +1. 运行 `git status --short` 获取所有变更文件列表。 +2. **过滤排除以下文件**: + + | 类别 | 排除规则 | + |------|----------| + | **数据文件** | 扩展名:`.csv`, `.tsv`, `.xlsx`, `.xls`, `.parquet`, `.arrow`, `.feather`, `.h5`, `.hdf5`, `.npz`, `.npy`, `.pkl`, `.joblib`, `.sqlite`, `.sqlite3`, `.db`, `.dta`, `.sav`, `.rds`, `.rda` | + | **二进制/编译产物** | 扩展名:`.exe`, `.dll`, `.so`, `.a`, `.o`, `.obj`, `.bin`, `.pt`, `.pth`, `.onnx`, `.safetensors`, `.gguf`, `.wasm`, `.pyc`, `.pyo`, `.class`, `.jar`, `.war`, `.apk`, `.ipa`, `.whl`, `.egg` | + | **媒体文件** | 扩展名:`.png`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.tif`, `.tiff`, `.webp`, `.svg`(非图标/UI资源时排除),`.mp3`, `.wav`, `.flac`, `.ogg`, `.mp4`, `.avi`, `.mov`, `.mkv`, `.webm` | + | **压缩包** | 扩展名:`.zip`, `.tar`, `.gz`, `.bz2`, `.7z`, `.rar`, `.xz`, `.zst`, `.tgz`, `.tar.gz`, `.tar.bz2` | + | **测试数据/夹具** | 路径包含:`test/data/`、`tests/data/`、`test/fixtures/`、`tests/fixtures/`、`testdata/`、`__test_data__/`、`sample_data/`、`*.testdata.*` | + | **大文件提醒** | 单个文件超过 **5MB** 时,跳过并提醒用户手动处理 | + +3. 对过滤后的代码文件执行 `git add ...`。 +4. 执行 `git commit -m ""` 提交。**无需用户确认,直接提交。** +5. 如果过滤后没有任何文件可提交,告知用户「没有需要提交的代码文件(数据文件、二进制文件已自动排除)」,然后结束。 -## 步骤 4:执行提交 -用户确认后,执行 `git add -A` 暂存所有变更,然后执行 `git commit -m ""` 提交。如果 commit message 包含 body,使用多个 `-m` 参数或临时文件方式。 +## 步骤 4:展示提交结果 +提交完成后,运行 `git log --oneline -3` 展示最近3条提交记录,让用户确认 commit 内容是否正确。 ## 步骤 5:询问推送 -提交成功后,询问用户:"Commit 已完成,是否需要推送到远程仓库?(git push)"。等待用户回复,如果用户同意则执行 `git push`,否则结束。 +展示 git log 后,询问用户:"提交已完成,以上是最近3条提交记录,是否需要推送到远程仓库?(git push)"。等待用户回复后执行。 --- From 1c58f189ba79d6a63df0e3c6b110c57ecda24467 Mon Sep 17 00:00:00 2001 From: iohub Date: Fri, 8 May 2026 12:22:29 +0800 Subject: [PATCH 3/3] feat(search): use embedded rg binary with system fallback --- internal/tools/search_operations.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/tools/search_operations.go b/internal/tools/search_operations.go index 72d3e2f..1f9633c 100644 --- a/internal/tools/search_operations.go +++ b/internal/tools/search_operations.go @@ -22,6 +22,16 @@ func fzfPath() string { return "fzf" // fallback to system fzf } +// rgPath returns the path to the rg binary, preferring the embedded one +func rgPath() string { + if path, err := embedbin.BinPath("rg"); err == nil { + if _, statErr := os.Stat(path); statErr == nil { + return path + } + } + return "rg" // fallback to system rg +} + // SearchOperationsTool 实现搜索相关工具 type SearchOperationsTool struct { workingDir string @@ -84,7 +94,7 @@ func (t *SearchOperationsTool) ExecuteGrepSearch(ctx context.Context, params map args = append(args, "-e", query, searchDir) - cmd := exec.CommandContext(ctx, "rg", args...) + cmd := exec.CommandContext(ctx, rgPath(), args...) output, err := cmd.CombinedOutput() if err != nil {