@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/VERSION +1 -0
  4. package/bin/attach.sh +98 -0
  5. package/bin/check-agents.sh +343 -0
  6. package/bin/clawforge +257 -0
  7. package/bin/clawforge-dashboard +0 -0
  8. package/bin/clean.sh +257 -0
  9. package/bin/config.sh +111 -0
  10. package/bin/conflicts.sh +224 -0
  11. package/bin/cost.sh +273 -0
  12. package/bin/dashboard.sh +557 -0
  13. package/bin/diff.sh +109 -0
  14. package/bin/doctor.sh +196 -0
  15. package/bin/eval.sh +217 -0
  16. package/bin/history.sh +91 -0
  17. package/bin/init.sh +182 -0
  18. package/bin/learn.sh +230 -0
  19. package/bin/logs.sh +126 -0
  20. package/bin/memory.sh +207 -0
  21. package/bin/merge-helper.sh +174 -0
  22. package/bin/multi-review.sh +215 -0
  23. package/bin/notify.sh +93 -0
  24. package/bin/on-complete.sh +149 -0
  25. package/bin/parse-cost.sh +205 -0
  26. package/bin/pr.sh +167 -0
  27. package/bin/resume.sh +183 -0
  28. package/bin/review-mode.sh +163 -0
  29. package/bin/review-pr.sh +145 -0
  30. package/bin/routing.sh +88 -0
  31. package/bin/scope-task.sh +169 -0
  32. package/bin/spawn-agent.sh +190 -0
  33. package/bin/sprint.sh +320 -0
  34. package/bin/steer.sh +107 -0
  35. package/bin/stop.sh +136 -0
  36. package/bin/summary.sh +182 -0
  37. package/bin/swarm.sh +525 -0
  38. package/bin/templates.sh +244 -0
  39. package/lib/common.sh +302 -0
  40. package/lib/templates/bugfix.json +6 -0
  41. package/lib/templates/migration.json +7 -0
  42. package/lib/templates/refactor.json +6 -0
  43. package/lib/templates/security-audit.json +5 -0
  44. package/lib/templates/test-coverage.json +6 -0
  45. package/package.json +31 -0
  46. package/registry/conflicts.jsonl +0 -0
  47. package/registry/costs.jsonl +0 -0
  48. package/tui/PRD.md +106 -0
  49. package/tui/agent.go +266 -0
  50. package/tui/animation.go +192 -0
  51. package/tui/dashboard.go +219 -0
  52. package/tui/filter.go +68 -0
  53. package/tui/go.mod +25 -0
  54. package/tui/go.sum +46 -0
  55. package/tui/keybindings.go +229 -0
  56. package/tui/main.go +61 -0
  57. package/tui/model.go +166 -0
  58. package/tui/steer.go +69 -0
  59. package/tui/styles.go +69 -0
@@ -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
+ }