Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions .codeactor/skills/commit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 格式:`<type>(<scope>): <subject>`
Expand All @@ -15,14 +17,36 @@
- **严禁**在代码或 commit 中添加任何形式的 AI 署名
- 风格参考知名的开源项目(如 Linux kernel, Kubernetes, Rust 等)

## 步骤 3:展示并确认
将编写好的 commit message 展示给用户,请求确认。等待用户回复确认后再继续。
## 步骤 3:执行提交

**智能过滤策略**(按优先级判断):

### 情况 A:用户已手动暂存文件
如果 `git diff --cached --name-only` 输出非空:
- 跳过过滤,直接对这些已暂存文件执行 `git commit -m "<message>"`

### 情况 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 <file1> <file2> ...`。
4. 执行 `git commit -m "<message>"` 提交。**无需用户确认,直接提交。**
5. 如果过滤后没有任何文件可提交,告知用户「没有需要提交的代码文件(数据文件、二进制文件已自动排除)」,然后结束。

## 步骤 4:执行提交
用户确认后,执行 `git add -A` 暂存所有变更,然后执行 `git commit -m "<message>"` 提交。如果 commit message 包含 body,使用多个 `-m` 参数或临时文件方式
## 步骤 4:展示提交结果
提交完成后,运行 `git log --oneline -3` 展示最近3条提交记录,让用户确认 commit 内容是否正确

## 步骤 5:询问推送
提交成功后,询问用户:"Commit 已完成,是否需要推送到远程仓库?(git push)"。等待用户回复,如果用户同意则执行 `git push`,否则结束
展示 git log 后,询问用户:"提交已完成,以上是最近3条提交记录,是否需要推送到远程仓库?(git push)"。等待用户回复后执行

---

Expand Down
12 changes: 11 additions & 1 deletion internal/tools/search_operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions internal/tui/tui_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 96 additions & 45 deletions internal/tui/tui_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "@":
Expand Down Expand Up @@ -814,14 +812,15 @@ 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
}

case taskEventMsg:
// 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
}
Expand Down Expand Up @@ -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
}
}
96 changes: 30 additions & 66 deletions internal/tui/tui_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down