@cyperx/clawforge 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/VERSION +1 -0
- package/bin/attach.sh +98 -0
- package/bin/check-agents.sh +343 -0
- package/bin/clawforge +257 -0
- package/bin/clawforge-dashboard +0 -0
- package/bin/clean.sh +257 -0
- package/bin/config.sh +111 -0
- package/bin/conflicts.sh +224 -0
- package/bin/cost.sh +273 -0
- package/bin/dashboard.sh +557 -0
- package/bin/diff.sh +109 -0
- package/bin/doctor.sh +196 -0
- package/bin/eval.sh +217 -0
- package/bin/history.sh +91 -0
- package/bin/init.sh +182 -0
- package/bin/learn.sh +230 -0
- package/bin/logs.sh +126 -0
- package/bin/memory.sh +207 -0
- package/bin/merge-helper.sh +174 -0
- package/bin/multi-review.sh +215 -0
- package/bin/notify.sh +93 -0
- package/bin/on-complete.sh +149 -0
- package/bin/parse-cost.sh +205 -0
- package/bin/pr.sh +167 -0
- package/bin/resume.sh +183 -0
- package/bin/review-mode.sh +163 -0
- package/bin/review-pr.sh +145 -0
- package/bin/routing.sh +88 -0
- package/bin/scope-task.sh +169 -0
- package/bin/spawn-agent.sh +190 -0
- package/bin/sprint.sh +320 -0
- package/bin/steer.sh +107 -0
- package/bin/stop.sh +136 -0
- package/bin/summary.sh +182 -0
- package/bin/swarm.sh +525 -0
- package/bin/templates.sh +244 -0
- package/lib/common.sh +302 -0
- package/lib/templates/bugfix.json +6 -0
- package/lib/templates/migration.json +7 -0
- package/lib/templates/refactor.json +6 -0
- package/lib/templates/security-audit.json +5 -0
- package/lib/templates/test-coverage.json +6 -0
- package/package.json +31 -0
- package/registry/conflicts.jsonl +0 -0
- package/registry/costs.jsonl +0 -0
- package/tui/PRD.md +106 -0
- package/tui/agent.go +266 -0
- package/tui/animation.go +192 -0
- package/tui/dashboard.go +219 -0
- package/tui/filter.go +68 -0
- package/tui/go.mod +25 -0
- package/tui/go.sum +46 -0
- package/tui/keybindings.go +229 -0
- package/tui/main.go +61 -0
- package/tui/model.go +166 -0
- package/tui/steer.go +69 -0
- package/tui/styles.go +69 -0
package/tui/dashboard.go
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
|
|
7
|
+
"charm.land/lipgloss/v2"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// Column widths for the agent table.
|
|
11
|
+
const (
|
|
12
|
+
colID = 5
|
|
13
|
+
colMode = 8
|
|
14
|
+
colStatus = 10
|
|
15
|
+
colRepo = 12
|
|
16
|
+
colModel = 12
|
|
17
|
+
colBranch = 18
|
|
18
|
+
colTask = 24
|
|
19
|
+
colCost = 8
|
|
20
|
+
colCI = 4
|
|
21
|
+
colConflict = 5
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// renderDashboard renders the main dashboard view.
|
|
25
|
+
func renderDashboard(m Model) string {
|
|
26
|
+
var b strings.Builder
|
|
27
|
+
|
|
28
|
+
// Header.
|
|
29
|
+
title := headerStyle.Render("⚒️ ClawForge Dashboard")
|
|
30
|
+
viewLabel := m.viewMode
|
|
31
|
+
if viewLabel == "" {
|
|
32
|
+
viewLabel = "all"
|
|
33
|
+
}
|
|
34
|
+
b.WriteString(title)
|
|
35
|
+
b.WriteString(" ")
|
|
36
|
+
b.WriteString(lipgloss.NewStyle().Faint(true).Render("view: " + viewLabel + " (1/2/3, tab)"))
|
|
37
|
+
b.WriteString("\n\n")
|
|
38
|
+
|
|
39
|
+
// Filter bar (if active).
|
|
40
|
+
if m.filterMode {
|
|
41
|
+
b.WriteString(RenderFilterBar(m))
|
|
42
|
+
b.WriteString("\n\n")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
agents := m.filteredAgents()
|
|
46
|
+
|
|
47
|
+
// Table header row.
|
|
48
|
+
hdr := tableHeaderStyle.Render(
|
|
49
|
+
padRight("ID", colID) +
|
|
50
|
+
padRight("Mode", colMode) +
|
|
51
|
+
padRight("Status", colStatus) +
|
|
52
|
+
padRight("Repo", colRepo) +
|
|
53
|
+
padRight("Model", colModel) +
|
|
54
|
+
padRight("Branch", colBranch) +
|
|
55
|
+
padRight("Task", colTask) +
|
|
56
|
+
padRight("Cost", colCost) +
|
|
57
|
+
padRight("CI", colCI) +
|
|
58
|
+
padRight("Cnfl", colConflict),
|
|
59
|
+
)
|
|
60
|
+
b.WriteString(hdr)
|
|
61
|
+
b.WriteString("\n")
|
|
62
|
+
|
|
63
|
+
// Separator.
|
|
64
|
+
totalWidth := colID + colMode + colStatus + colRepo + colModel + colBranch + colTask + colCost + colCI + colConflict
|
|
65
|
+
b.WriteString(separatorStyle.Render(strings.Repeat("─", totalWidth)))
|
|
66
|
+
b.WriteString("\n")
|
|
67
|
+
|
|
68
|
+
if len(agents) == 0 {
|
|
69
|
+
empty := lipgloss.NewStyle().Faint(true).Render(" No agents found.")
|
|
70
|
+
b.WriteString(empty)
|
|
71
|
+
b.WriteString("\n")
|
|
72
|
+
} else {
|
|
73
|
+
// Determine how many rows to show based on terminal height.
|
|
74
|
+
maxRows := len(agents)
|
|
75
|
+
if m.height > 0 {
|
|
76
|
+
// Reserve lines for header(2) + table header(1) + separator(1) + footer(2) + steer(1) + padding(1).
|
|
77
|
+
available := m.height - 8
|
|
78
|
+
if m.filterMode {
|
|
79
|
+
available -= 2
|
|
80
|
+
}
|
|
81
|
+
if available < 1 {
|
|
82
|
+
available = 1
|
|
83
|
+
}
|
|
84
|
+
if maxRows > available {
|
|
85
|
+
maxRows = available
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Scroll offset: keep selected row visible.
|
|
90
|
+
offset := 0
|
|
91
|
+
if m.selected >= maxRows {
|
|
92
|
+
offset = m.selected - maxRows + 1
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for i := offset; i < offset+maxRows && i < len(agents); i++ {
|
|
96
|
+
a := agents[i]
|
|
97
|
+
row := renderAgentRow(a)
|
|
98
|
+
if i == m.selected {
|
|
99
|
+
row = selectedRowStyle.Render(row)
|
|
100
|
+
}
|
|
101
|
+
b.WriteString(row)
|
|
102
|
+
b.WriteString("\n")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Preview pane (if active).
|
|
107
|
+
if m.showPreview {
|
|
108
|
+
agents := m.filteredAgents()
|
|
109
|
+
if len(agents) > 0 && m.selected < len(agents) {
|
|
110
|
+
selAgent := agents[m.selected]
|
|
111
|
+
b.WriteString("\n")
|
|
112
|
+
previewHeader := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFA500")).Bold(true).Render(
|
|
113
|
+
fmt.Sprintf("── Preview: %s ──", selAgent.ID))
|
|
114
|
+
b.WriteString(previewHeader)
|
|
115
|
+
b.WriteString("\n")
|
|
116
|
+
if selAgent.Preview != "" {
|
|
117
|
+
b.WriteString(lipgloss.NewStyle().Faint(true).Render(selAgent.Preview))
|
|
118
|
+
} else {
|
|
119
|
+
b.WriteString(lipgloss.NewStyle().Faint(true).Render("(no output / not running)"))
|
|
120
|
+
}
|
|
121
|
+
b.WriteString("\n")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Steer input (if active).
|
|
126
|
+
if m.steerMode {
|
|
127
|
+
b.WriteString("\n")
|
|
128
|
+
b.WriteString(RenderSteerInput(m))
|
|
129
|
+
b.WriteString("\n")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Help overlay.
|
|
133
|
+
if m.showHelp {
|
|
134
|
+
b.WriteString("\n")
|
|
135
|
+
b.WriteString(renderHelpOverlay(m.width))
|
|
136
|
+
b.WriteString("\n")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Footer status bar.
|
|
140
|
+
b.WriteString("\n")
|
|
141
|
+
b.WriteString(renderStatusBar(m))
|
|
142
|
+
|
|
143
|
+
return b.String()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// renderAgentRow formats a single agent as a table row.
|
|
147
|
+
func renderAgentRow(a Agent) string {
|
|
148
|
+
id := a.ID
|
|
149
|
+
if a.ShortID > 0 {
|
|
150
|
+
id = fmt.Sprintf("#%d", a.ShortID)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
status := statusIndicator(a.Status) + " " + a.Status
|
|
154
|
+
|
|
155
|
+
conflictStr := "-"
|
|
156
|
+
if a.Conflicts > 0 {
|
|
157
|
+
conflictStr = fmt.Sprintf("%d", a.Conflicts)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return padRight(truncate(id, colID), colID) +
|
|
161
|
+
padRight(truncate(a.Mode, colMode), colMode) +
|
|
162
|
+
padRight(truncate(status, colStatus), colStatus) +
|
|
163
|
+
padRight(truncate(a.Repo, colRepo), colRepo) +
|
|
164
|
+
padRight(truncate(a.Model, colModel), colModel) +
|
|
165
|
+
padRight(truncate(a.Branch, colBranch), colBranch) +
|
|
166
|
+
padRight(truncate(a.Task, colTask), colTask) +
|
|
167
|
+
padRight(truncate(a.Cost, colCost), colCost) +
|
|
168
|
+
padRight(truncate(a.CI, colCI), colCI) +
|
|
169
|
+
padRight(truncate(conflictStr, colConflict), colConflict)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// renderStatusBar renders the footer status bar.
|
|
173
|
+
func renderStatusBar(m Model) string {
|
|
174
|
+
agents := m.filteredAgents()
|
|
175
|
+
total := len(agents)
|
|
176
|
+
running := 0
|
|
177
|
+
var totalCost float64
|
|
178
|
+
|
|
179
|
+
for _, a := range agents {
|
|
180
|
+
if a.Status == "running" {
|
|
181
|
+
running++
|
|
182
|
+
}
|
|
183
|
+
// Parse cost for summary.
|
|
184
|
+
if a.Cost != "-" && a.Cost != "" {
|
|
185
|
+
var c float64
|
|
186
|
+
fmt.Sscanf(a.Cost, "$%f", &c)
|
|
187
|
+
totalCost += c
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
costStr := "-"
|
|
192
|
+
if totalCost > 0 {
|
|
193
|
+
costStr = fmt.Sprintf("$%.2f", totalCost)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
bar := fmt.Sprintf(" %d agents | %d running | %s total | view:%s | j/k navigate | n nudge | ? help | q quit",
|
|
197
|
+
total, running, costStr, m.viewMode)
|
|
198
|
+
|
|
199
|
+
return statusBarStyle.Render(bar)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// truncate shortens a string to maxLen, adding "…" if needed.
|
|
203
|
+
func truncate(s string, maxLen int) string {
|
|
204
|
+
if len(s) <= maxLen {
|
|
205
|
+
return s
|
|
206
|
+
}
|
|
207
|
+
if maxLen <= 1 {
|
|
208
|
+
return "…"
|
|
209
|
+
}
|
|
210
|
+
return s[:maxLen-1] + "…"
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// padRight pads a string with spaces to the given width.
|
|
214
|
+
func padRight(s string, width int) string {
|
|
215
|
+
if len(s) >= width {
|
|
216
|
+
return s
|
|
217
|
+
}
|
|
218
|
+
return s + strings.Repeat(" ", width-len(s))
|
|
219
|
+
}
|
package/tui/filter.go
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
|
|
6
|
+
tea "charm.land/bubbletea/v2"
|
|
7
|
+
"charm.land/lipgloss/v2"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// handleFilterKey processes key input while in filter mode.
|
|
11
|
+
func handleFilterKey(m Model, key string) (Model, tea.Cmd) {
|
|
12
|
+
switch key {
|
|
13
|
+
case "esc":
|
|
14
|
+
m.filterMode = false
|
|
15
|
+
m.filter = ""
|
|
16
|
+
m.selected = 0
|
|
17
|
+
return m, nil
|
|
18
|
+
case "enter":
|
|
19
|
+
m.filterMode = false
|
|
20
|
+
return m, nil
|
|
21
|
+
case "backspace":
|
|
22
|
+
if len(m.filter) > 0 {
|
|
23
|
+
m.filter = m.filter[:len(m.filter)-1]
|
|
24
|
+
// Reset selection when filter changes.
|
|
25
|
+
m.selected = 0
|
|
26
|
+
}
|
|
27
|
+
return m, nil
|
|
28
|
+
default:
|
|
29
|
+
if len(key) == 1 {
|
|
30
|
+
m.filter += key
|
|
31
|
+
m.selected = 0
|
|
32
|
+
} else if key == "space" {
|
|
33
|
+
m.filter += " "
|
|
34
|
+
m.selected = 0
|
|
35
|
+
}
|
|
36
|
+
return m, nil
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// FilterAgents returns agents matching the query via case-insensitive substring
|
|
41
|
+
// match across all display fields.
|
|
42
|
+
func FilterAgents(agents []Agent, query string) []Agent {
|
|
43
|
+
if query == "" {
|
|
44
|
+
return agents
|
|
45
|
+
}
|
|
46
|
+
q := strings.ToLower(query)
|
|
47
|
+
var results []Agent
|
|
48
|
+
for _, a := range agents {
|
|
49
|
+
haystack := strings.ToLower(
|
|
50
|
+
a.ID + " " + a.Mode + " " + a.Status + " " + a.Branch + " " + a.Description,
|
|
51
|
+
)
|
|
52
|
+
if strings.Contains(haystack, q) {
|
|
53
|
+
results = append(results, a)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return results
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// RenderFilterBar renders the filter input bar shown at the top of the screen.
|
|
60
|
+
func RenderFilterBar(m Model) string {
|
|
61
|
+
prompt := "/ " + m.filter
|
|
62
|
+
cursor := "█"
|
|
63
|
+
|
|
64
|
+
return lipgloss.NewStyle().
|
|
65
|
+
Foreground(lipgloss.Color("#FFA500")).
|
|
66
|
+
Bold(true).
|
|
67
|
+
Render(prompt + cursor)
|
|
68
|
+
}
|
package/tui/go.mod
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module github.com/cyperx84/clawforge/tui
|
|
2
|
+
|
|
3
|
+
go 1.25.0
|
|
4
|
+
|
|
5
|
+
require (
|
|
6
|
+
charm.land/bubbletea/v2 v2.0.1 // indirect
|
|
7
|
+
charm.land/lipgloss/v2 v2.0.0 // indirect
|
|
8
|
+
github.com/charmbracelet/bubbles v1.0.0 // indirect
|
|
9
|
+
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
|
10
|
+
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
|
11
|
+
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
|
12
|
+
github.com/charmbracelet/x/term v0.2.2 // indirect
|
|
13
|
+
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
|
14
|
+
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
|
15
|
+
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
|
16
|
+
github.com/clipperhouse/stringish v0.1.1 // indirect
|
|
17
|
+
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
|
18
|
+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
|
19
|
+
github.com/mattn/go-runewidth v0.0.19 // indirect
|
|
20
|
+
github.com/muesli/cancelreader v0.2.2 // indirect
|
|
21
|
+
github.com/rivo/uniseg v0.4.7 // indirect
|
|
22
|
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
|
23
|
+
golang.org/x/sync v0.19.0 // indirect
|
|
24
|
+
golang.org/x/sys v0.41.0 // indirect
|
|
25
|
+
)
|
package/tui/go.sum
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ=
|
|
2
|
+
charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
|
3
|
+
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
|
4
|
+
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
|
5
|
+
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
|
6
|
+
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
|
7
|
+
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
|
8
|
+
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
|
9
|
+
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
|
10
|
+
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
|
11
|
+
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
|
12
|
+
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
|
13
|
+
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
|
14
|
+
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
|
15
|
+
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
|
16
|
+
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
|
17
|
+
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
|
18
|
+
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
|
19
|
+
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
|
20
|
+
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
|
21
|
+
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
|
22
|
+
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
|
23
|
+
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
|
24
|
+
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
|
25
|
+
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
|
26
|
+
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
|
27
|
+
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
|
28
|
+
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
|
29
|
+
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
|
30
|
+
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
|
31
|
+
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
|
32
|
+
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
|
33
|
+
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
|
34
|
+
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
|
35
|
+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
|
36
|
+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
|
37
|
+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
|
38
|
+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
|
39
|
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
|
40
|
+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
|
41
|
+
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
|
42
|
+
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
|
43
|
+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
|
44
|
+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
45
|
+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
46
|
+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os/exec"
|
|
6
|
+
"strings"
|
|
7
|
+
|
|
8
|
+
tea "charm.land/bubbletea/v2"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// stopDoneMsg is sent when a stop command finishes.
|
|
12
|
+
type stopDoneMsg struct{ err error }
|
|
13
|
+
|
|
14
|
+
// attachDoneMsg is sent when an attach (tmux) command finishes.
|
|
15
|
+
type attachDoneMsg struct{ err error }
|
|
16
|
+
|
|
17
|
+
// nudgeDoneMsg is sent when a nudge command finishes.
|
|
18
|
+
type nudgeDoneMsg struct{ err error }
|
|
19
|
+
|
|
20
|
+
// handleKeyPress dispatches key events to the appropriate handler based on
|
|
21
|
+
// current mode (filter, steer, or normal dashboard).
|
|
22
|
+
func handleKeyPress(m Model, msg tea.KeyPressMsg) (Model, tea.Cmd) {
|
|
23
|
+
key := msg.String()
|
|
24
|
+
|
|
25
|
+
// If in filter mode, delegate to filter handler.
|
|
26
|
+
if m.filterMode {
|
|
27
|
+
return handleFilterKey(m, key)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// If in steer mode, delegate to steer handler.
|
|
31
|
+
if m.steerMode {
|
|
32
|
+
return handleSteerKey(m, key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// If confirming stop, handle y/n.
|
|
36
|
+
if m.confirmStop {
|
|
37
|
+
return handleConfirmStop(m, key)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Normal dashboard mode.
|
|
41
|
+
agents := m.filteredAgents()
|
|
42
|
+
count := len(agents)
|
|
43
|
+
|
|
44
|
+
switch key {
|
|
45
|
+
case "q", "ctrl+c":
|
|
46
|
+
return m, tea.Quit
|
|
47
|
+
|
|
48
|
+
case "j", "down":
|
|
49
|
+
if count > 0 && m.selected < count-1 {
|
|
50
|
+
m.selected++
|
|
51
|
+
}
|
|
52
|
+
return m, nil
|
|
53
|
+
|
|
54
|
+
case "k", "up":
|
|
55
|
+
if m.selected > 0 {
|
|
56
|
+
m.selected--
|
|
57
|
+
}
|
|
58
|
+
return m, nil
|
|
59
|
+
|
|
60
|
+
case "g":
|
|
61
|
+
m.selected = 0
|
|
62
|
+
return m, nil
|
|
63
|
+
|
|
64
|
+
case "G":
|
|
65
|
+
if count > 0 {
|
|
66
|
+
m.selected = count - 1
|
|
67
|
+
}
|
|
68
|
+
return m, nil
|
|
69
|
+
|
|
70
|
+
case "enter":
|
|
71
|
+
// Attach to selected agent's tmux session.
|
|
72
|
+
if count > 0 {
|
|
73
|
+
agent := agents[m.selected]
|
|
74
|
+
session := agent.TmuxSession
|
|
75
|
+
if session == "" {
|
|
76
|
+
session = "clawforge-" + agent.ID
|
|
77
|
+
}
|
|
78
|
+
cmd := exec.Command("tmux", "attach-session", "-t", session)
|
|
79
|
+
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
80
|
+
return attachDoneMsg{err}
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
return m, nil
|
|
84
|
+
|
|
85
|
+
case "s":
|
|
86
|
+
// Open steer input modal.
|
|
87
|
+
if count > 0 {
|
|
88
|
+
m.steerMode = true
|
|
89
|
+
m.steerInput = ""
|
|
90
|
+
}
|
|
91
|
+
return m, nil
|
|
92
|
+
|
|
93
|
+
case "x":
|
|
94
|
+
// Stop selected agent (with confirmation).
|
|
95
|
+
if count > 0 {
|
|
96
|
+
m.confirmStop = true
|
|
97
|
+
}
|
|
98
|
+
return m, nil
|
|
99
|
+
|
|
100
|
+
case "/":
|
|
101
|
+
// Open filter input.
|
|
102
|
+
m.filterMode = true
|
|
103
|
+
m.filter = ""
|
|
104
|
+
return m, nil
|
|
105
|
+
|
|
106
|
+
case "1":
|
|
107
|
+
m.viewMode = "all"
|
|
108
|
+
m.selected = 0
|
|
109
|
+
return m, nil
|
|
110
|
+
|
|
111
|
+
case "2":
|
|
112
|
+
m.viewMode = "running"
|
|
113
|
+
m.selected = 0
|
|
114
|
+
return m, nil
|
|
115
|
+
|
|
116
|
+
case "3":
|
|
117
|
+
m.viewMode = "finished"
|
|
118
|
+
m.selected = 0
|
|
119
|
+
return m, nil
|
|
120
|
+
|
|
121
|
+
case "tab":
|
|
122
|
+
if m.viewMode == "all" {
|
|
123
|
+
m.viewMode = "running"
|
|
124
|
+
} else if m.viewMode == "running" {
|
|
125
|
+
m.viewMode = "finished"
|
|
126
|
+
} else {
|
|
127
|
+
m.viewMode = "all"
|
|
128
|
+
}
|
|
129
|
+
m.selected = 0
|
|
130
|
+
return m, nil
|
|
131
|
+
|
|
132
|
+
case "r":
|
|
133
|
+
// Force refresh.
|
|
134
|
+
m.agents = LoadAgents()
|
|
135
|
+
// Clamp selection.
|
|
136
|
+
filtered := m.filteredAgents()
|
|
137
|
+
if m.selected >= len(filtered) {
|
|
138
|
+
m.selected = max(0, len(filtered)-1)
|
|
139
|
+
}
|
|
140
|
+
return m, nil
|
|
141
|
+
|
|
142
|
+
case "p":
|
|
143
|
+
m.showPreview = !m.showPreview
|
|
144
|
+
return m, nil
|
|
145
|
+
|
|
146
|
+
case "n":
|
|
147
|
+
// Nudge selected running agent with a lightweight progress prompt.
|
|
148
|
+
if count > 0 {
|
|
149
|
+
agent := agents[m.selected]
|
|
150
|
+
if agent.Status == "running" || agent.Status == "spawned" {
|
|
151
|
+
cmd := exec.Command("clawforge", "steer", agent.ID, "Quick nudge: share current progress, blockers, and ETA.")
|
|
152
|
+
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
153
|
+
return nudgeDoneMsg{err}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return m, nil
|
|
158
|
+
|
|
159
|
+
case "?":
|
|
160
|
+
m.showHelp = !m.showHelp
|
|
161
|
+
return m, nil
|
|
162
|
+
|
|
163
|
+
case "esc":
|
|
164
|
+
// Close any overlay.
|
|
165
|
+
if m.showHelp {
|
|
166
|
+
m.showHelp = false
|
|
167
|
+
}
|
|
168
|
+
return m, nil
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return m, nil
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// handleConfirmStop processes y/n when confirming an agent stop.
|
|
175
|
+
func handleConfirmStop(m Model, key string) (Model, tea.Cmd) {
|
|
176
|
+
switch key {
|
|
177
|
+
case "y", "Y":
|
|
178
|
+
agents := m.filteredAgents()
|
|
179
|
+
if len(agents) > 0 {
|
|
180
|
+
agent := agents[m.selected]
|
|
181
|
+
m.confirmStop = false
|
|
182
|
+
cmd := exec.Command("clawforge", "stop", agent.ID, "--yes")
|
|
183
|
+
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
|
|
184
|
+
return stopDoneMsg{err}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
m.confirmStop = false
|
|
188
|
+
return m, nil
|
|
189
|
+
case "n", "N", "esc":
|
|
190
|
+
m.confirmStop = false
|
|
191
|
+
return m, nil
|
|
192
|
+
}
|
|
193
|
+
return m, nil
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// renderHelpOverlay renders the keybinding help overlay.
|
|
197
|
+
func renderHelpOverlay(width int) string {
|
|
198
|
+
bindings := []struct {
|
|
199
|
+
key string
|
|
200
|
+
desc string
|
|
201
|
+
}{
|
|
202
|
+
{"j/k", "Navigate agent list"},
|
|
203
|
+
{"Enter", "Attach to selected agent's tmux session"},
|
|
204
|
+
{"s", "Steer selected agent (prompts for message)"},
|
|
205
|
+
{"x", "Stop selected agent"},
|
|
206
|
+
{"/", "Filter agents"},
|
|
207
|
+
{"1/2/3", "Views: all / running / finished"},
|
|
208
|
+
{"Tab", "Cycle views"},
|
|
209
|
+
{"n", "Nudge selected running agent"},
|
|
210
|
+
{"p", "Toggle output preview pane"},
|
|
211
|
+
{"r", "Force refresh"},
|
|
212
|
+
{"g/G", "Go to top/bottom"},
|
|
213
|
+
{"?", "Toggle help overlay"},
|
|
214
|
+
{"Esc", "Close modal/overlay"},
|
|
215
|
+
{"q", "Quit dashboard"},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
var lines []string
|
|
219
|
+
lines = append(lines, headerStyle.Render("Keybindings"))
|
|
220
|
+
lines = append(lines, "")
|
|
221
|
+
|
|
222
|
+
for _, b := range bindings {
|
|
223
|
+
keyStr := fmt.Sprintf(" %-10s", b.key)
|
|
224
|
+
lines = append(lines, keyStr+b.desc)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
content := strings.Join(lines, "\n")
|
|
228
|
+
return helpOverlayStyle.Render(content)
|
|
229
|
+
}
|
package/tui/main.go
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
|
|
7
|
+
tea "charm.land/bubbletea/v2"
|
|
8
|
+
"github.com/charmbracelet/x/term"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const usageText = `Usage: clawforge dashboard [options]
|
|
12
|
+
|
|
13
|
+
Live terminal UI for monitoring all ClawForge agents.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--no-anim Skip startup animation
|
|
17
|
+
--help Show this help
|
|
18
|
+
|
|
19
|
+
Keybindings:
|
|
20
|
+
j/k Navigate agent list
|
|
21
|
+
Enter Attach to selected agent's tmux session
|
|
22
|
+
s Steer selected agent (prompts for message)
|
|
23
|
+
x Stop selected agent
|
|
24
|
+
/ Filter agents
|
|
25
|
+
1/2/3 Views: all / running / finished
|
|
26
|
+
Tab Cycle views
|
|
27
|
+
n Nudge selected running agent
|
|
28
|
+
p Toggle output preview pane
|
|
29
|
+
r Force refresh
|
|
30
|
+
? Show help overlay
|
|
31
|
+
q Quit dashboard`
|
|
32
|
+
|
|
33
|
+
func main() {
|
|
34
|
+
noAnim := false
|
|
35
|
+
for _, arg := range os.Args[1:] {
|
|
36
|
+
switch arg {
|
|
37
|
+
case "--help", "-h":
|
|
38
|
+
fmt.Println(usageText)
|
|
39
|
+
os.Exit(0)
|
|
40
|
+
case "--no-anim":
|
|
41
|
+
noAnim = true
|
|
42
|
+
default:
|
|
43
|
+
fmt.Fprintf(os.Stderr, "Unknown option: %s\n", arg)
|
|
44
|
+
fmt.Fprintln(os.Stderr, usageText)
|
|
45
|
+
os.Exit(1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// If stdin or stdout is not a terminal, show help and exit (for CI/scripting).
|
|
50
|
+
if !term.IsTerminal(os.Stdin.Fd()) || !term.IsTerminal(os.Stdout.Fd()) {
|
|
51
|
+
fmt.Println(usageText)
|
|
52
|
+
os.Exit(0)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
m := NewModel(noAnim)
|
|
56
|
+
p := tea.NewProgram(m)
|
|
57
|
+
if _, err := p.Run(); err != nil {
|
|
58
|
+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
59
|
+
os.Exit(1)
|
|
60
|
+
}
|
|
61
|
+
}
|