@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/PRD.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# ClawForge TUI — Go + Bubble Tea Rewrite
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Replace the bash dashboard.sh with a flicker-free Go TUI using Bubble Tea v2 + Lipgloss v2 + Bubbles.
|
|
5
|
+
|
|
6
|
+
## Architecture
|
|
7
|
+
- Single Go binary: `clawforge-dashboard`
|
|
8
|
+
- Lives in `tui/` directory within the clawforge repo
|
|
9
|
+
- Reads data from clawforge registry (JSON files) + tmux + git
|
|
10
|
+
|
|
11
|
+
## Data Sources
|
|
12
|
+
- `registry/active-tasks.json` — task list with id, mode, status, branch, description
|
|
13
|
+
- `registry/costs.jsonl` — per-task cost data (inputTokens, outputTokens, totalCost)
|
|
14
|
+
- `registry/conflicts.jsonl` — conflict records
|
|
15
|
+
- `tmux list-sessions -F "#{session_name} #{session_activity}"` — live session status
|
|
16
|
+
- `git -C <worktree> log --oneline -1` — last commit per agent
|
|
17
|
+
|
|
18
|
+
## File Structure
|
|
19
|
+
```
|
|
20
|
+
tui/
|
|
21
|
+
├── main.go # Entry point, tea.NewProgram
|
|
22
|
+
├── model.go # Model struct, Init, Update, View
|
|
23
|
+
├── animation.go # Forge startup animation frames + logic
|
|
24
|
+
├── dashboard.go # Main dashboard view rendering
|
|
25
|
+
├── agent.go # Agent data structures + loading from registry
|
|
26
|
+
├── keybindings.go # Key handling + help overlay
|
|
27
|
+
├── styles.go # Lipgloss styles (amber/orange forge theme)
|
|
28
|
+
├── steer.go # Steer input modal
|
|
29
|
+
├── filter.go # Filter input handling
|
|
30
|
+
└── go.mod
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Phase 1: Animation (animation.go + styles.go)
|
|
34
|
+
- 8-10 frames of ASCII forge art with hammering/sparks effect
|
|
35
|
+
- Amber/orange color scheme via Lipgloss (colors: #FF8C00, #FF6600, #FFA500, #CC5500)
|
|
36
|
+
- Frame cycle: 120ms per frame, ~1.5 seconds total
|
|
37
|
+
- Transition: fade last frame → dashboard view
|
|
38
|
+
- tea.Tick for frame timing
|
|
39
|
+
- --no-anim flag skips directly to dashboard
|
|
40
|
+
|
|
41
|
+
## Phase 2: Dashboard View (dashboard.go + agent.go)
|
|
42
|
+
- Header: "ClawForge Dashboard" with forge emoji + version
|
|
43
|
+
- Agent table columns:
|
|
44
|
+
| ID | Mode | Status | Branch | Task (truncated) | Cost | CI | Conflicts |
|
|
45
|
+
- Selected row highlighted with reverse video + amber accent
|
|
46
|
+
- Status indicators: 🟢 running, 🟡 idle, 🔴 failed, ⚪ done
|
|
47
|
+
- Cost column: "$X.XX" or "-" if no cost data
|
|
48
|
+
- CI column: ✅/❌/⏳/-
|
|
49
|
+
- Conflicts column: count or "-"
|
|
50
|
+
- Footer status bar: "4 agents | 2 running | $3.42 total | ↑↓ navigate | ? help"
|
|
51
|
+
- Auto-refresh: tea.Tick every 2 seconds triggers data reload
|
|
52
|
+
- Only re-renders changed cells (Bubble Tea handles this automatically)
|
|
53
|
+
|
|
54
|
+
## Phase 3: Vim Keybindings (keybindings.go)
|
|
55
|
+
- j/k or ↑/↓: navigate agent list
|
|
56
|
+
- Enter: attach to selected agent's tmux session (tea.ExecProcess)
|
|
57
|
+
- s: open steer input modal
|
|
58
|
+
- x: stop selected agent (confirm with y/n)
|
|
59
|
+
- q or Ctrl+C: quit
|
|
60
|
+
- /: open filter input
|
|
61
|
+
- r: force refresh
|
|
62
|
+
- ?: toggle help overlay
|
|
63
|
+
- g: go to top
|
|
64
|
+
- G: go to bottom
|
|
65
|
+
- Esc: close any modal/overlay
|
|
66
|
+
|
|
67
|
+
## Phase 4: Steer Modal (steer.go)
|
|
68
|
+
- When 's' pressed: show text input at bottom
|
|
69
|
+
- Prompt: "Steer agent #X: "
|
|
70
|
+
- Enter submits: runs `clawforge steer <id> "<message>"`
|
|
71
|
+
- Esc cancels
|
|
72
|
+
- Use Bubbles textinput component
|
|
73
|
+
|
|
74
|
+
## Phase 5: Filter (filter.go)
|
|
75
|
+
- When '/' pressed: show filter input at top
|
|
76
|
+
- Filters agent list by any column match (fuzzy)
|
|
77
|
+
- Esc clears filter
|
|
78
|
+
- Live filtering as you type
|
|
79
|
+
|
|
80
|
+
## Phase 6: Help Overlay (keybindings.go)
|
|
81
|
+
- Semi-transparent overlay listing all keybindings
|
|
82
|
+
- Dismiss with ? or Esc
|
|
83
|
+
- Styled with Lipgloss border + padding
|
|
84
|
+
|
|
85
|
+
## Performance Requirements
|
|
86
|
+
- Zero flicker (Bubble Tea cell-based diff handles this)
|
|
87
|
+
- Startup to dashboard: < 500ms (excluding animation)
|
|
88
|
+
- Data refresh: < 100ms (shell out to tmux/git in background)
|
|
89
|
+
- Smooth animation: consistent 120ms frame timing
|
|
90
|
+
- Handle terminal resize gracefully (tea.WindowSizeMsg)
|
|
91
|
+
|
|
92
|
+
## Build
|
|
93
|
+
- `go build -o bin/clawforge-dashboard ./tui/`
|
|
94
|
+
- Update `bin/clawforge` to prefer Go binary when available
|
|
95
|
+
- Keep dashboard.sh as fallback
|
|
96
|
+
|
|
97
|
+
## Styling (Lipgloss)
|
|
98
|
+
- Primary: #FF8C00 (dark orange / amber)
|
|
99
|
+
- Secondary: #FFA500 (orange)
|
|
100
|
+
- Accent: #FF6600 (red-orange)
|
|
101
|
+
- Muted: #CC5500 (brown-orange)
|
|
102
|
+
- Background: terminal default (transparent)
|
|
103
|
+
- Selected row: reverse + amber foreground
|
|
104
|
+
- Header: bold + amber
|
|
105
|
+
- Status bar: dim + muted
|
|
106
|
+
- Borders: rounded, amber
|
package/tui/agent.go
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// Agent represents a single ClawForge agent with its current state.
|
|
13
|
+
type Agent struct {
|
|
14
|
+
ID string
|
|
15
|
+
ShortID int
|
|
16
|
+
Preview string
|
|
17
|
+
Mode string
|
|
18
|
+
Model string
|
|
19
|
+
Repo string
|
|
20
|
+
Status string
|
|
21
|
+
Branch string
|
|
22
|
+
Task string
|
|
23
|
+
Cost string
|
|
24
|
+
CI string
|
|
25
|
+
Conflicts int
|
|
26
|
+
Description string
|
|
27
|
+
Worktree string
|
|
28
|
+
TmuxSession string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// registryTask mirrors the JSON structure in active-tasks.json.
|
|
32
|
+
type registryTask struct {
|
|
33
|
+
ID string `json:"id"`
|
|
34
|
+
ShortID int `json:"short_id"`
|
|
35
|
+
Mode string `json:"mode"`
|
|
36
|
+
Status string `json:"status"`
|
|
37
|
+
Branch string `json:"branch"`
|
|
38
|
+
Description string `json:"description"`
|
|
39
|
+
Worktree string `json:"worktree"`
|
|
40
|
+
TmuxSession string `json:"tmux_session"`
|
|
41
|
+
Agent string `json:"agent"`
|
|
42
|
+
Model string `json:"model"`
|
|
43
|
+
Repo string `json:"repo"`
|
|
44
|
+
CIStatus string `json:"ci_status"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type registryFile struct {
|
|
48
|
+
Tasks []registryTask `json:"tasks"`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// costEntry mirrors a line in costs.jsonl.
|
|
52
|
+
type costEntry struct {
|
|
53
|
+
TaskID string `json:"taskId"`
|
|
54
|
+
TotalCost float64 `json:"totalCost"`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// conflictEntry mirrors a line in conflicts.jsonl.
|
|
58
|
+
type conflictEntry struct {
|
|
59
|
+
TaskIDs []string `json:"task_ids"`
|
|
60
|
+
Status string `json:"status"`
|
|
61
|
+
Resolved bool `json:"resolved"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// clawforgeDir returns the clawforge root directory.
|
|
65
|
+
func clawforgeDir() string {
|
|
66
|
+
// Check CLAWFORGE_DIR env first, then walk up from executable.
|
|
67
|
+
if d := os.Getenv("CLAWFORGE_DIR"); d != "" {
|
|
68
|
+
return d
|
|
69
|
+
}
|
|
70
|
+
exe, err := os.Executable()
|
|
71
|
+
if err == nil {
|
|
72
|
+
dir := filepath.Dir(exe)
|
|
73
|
+
// If binary is in bin/, go up one level.
|
|
74
|
+
if filepath.Base(dir) == "bin" {
|
|
75
|
+
return filepath.Dir(dir)
|
|
76
|
+
}
|
|
77
|
+
// If binary is in tui/, go up one level.
|
|
78
|
+
if filepath.Base(dir) == "tui" {
|
|
79
|
+
return filepath.Dir(dir)
|
|
80
|
+
}
|
|
81
|
+
return filepath.Dir(dir)
|
|
82
|
+
}
|
|
83
|
+
return "."
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// LoadAgents reads the registry, cost data, conflict data, and tmux state
|
|
87
|
+
// to produce a full list of Agent records.
|
|
88
|
+
func LoadAgents() []Agent {
|
|
89
|
+
root := clawforgeDir()
|
|
90
|
+
registryPath := filepath.Join(root, "registry", "active-tasks.json")
|
|
91
|
+
|
|
92
|
+
data, err := os.ReadFile(registryPath)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return nil
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
var reg registryFile
|
|
98
|
+
if err := json.Unmarshal(data, ®); err != nil {
|
|
99
|
+
return nil
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Load cost data.
|
|
103
|
+
costs := loadCosts(filepath.Join(root, "registry", "costs.jsonl"))
|
|
104
|
+
|
|
105
|
+
// Load conflict counts.
|
|
106
|
+
conflicts := loadConflictCounts(filepath.Join(root, "registry", "conflicts.jsonl"))
|
|
107
|
+
|
|
108
|
+
// Load tmux sessions.
|
|
109
|
+
tmuxSessions := loadTmuxSessions()
|
|
110
|
+
|
|
111
|
+
agents := make([]Agent, 0, len(reg.Tasks))
|
|
112
|
+
previewLines := 3
|
|
113
|
+
for _, t := range reg.Tasks {
|
|
114
|
+
a := Agent{
|
|
115
|
+
ID: t.ID,
|
|
116
|
+
ShortID: t.ShortID,
|
|
117
|
+
Mode: t.Mode,
|
|
118
|
+
Model: t.Model,
|
|
119
|
+
Repo: t.Repo,
|
|
120
|
+
Status: t.Status,
|
|
121
|
+
Branch: t.Branch,
|
|
122
|
+
Description: t.Description,
|
|
123
|
+
Task: t.Description,
|
|
124
|
+
Worktree: t.Worktree,
|
|
125
|
+
TmuxSession: t.TmuxSession,
|
|
126
|
+
CI: ciIndicator(t.CIStatus),
|
|
127
|
+
Cost: costs[t.ID],
|
|
128
|
+
Conflicts: conflicts[t.ID],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Enrich status from tmux if task is "running".
|
|
132
|
+
if a.Status == "running" && a.TmuxSession != "" {
|
|
133
|
+
if _, ok := tmuxSessions[a.TmuxSession]; !ok {
|
|
134
|
+
a.Status = "failed"
|
|
135
|
+
} else {
|
|
136
|
+
a.Preview = captureTmuxPreview(a.TmuxSession, previewLines)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if a.Cost == "" {
|
|
141
|
+
a.Cost = "-"
|
|
142
|
+
}
|
|
143
|
+
if a.Mode == "" {
|
|
144
|
+
a.Mode = "—"
|
|
145
|
+
}
|
|
146
|
+
if a.Model == "" {
|
|
147
|
+
a.Model = "-"
|
|
148
|
+
}
|
|
149
|
+
if a.Repo == "" {
|
|
150
|
+
a.Repo = "-"
|
|
151
|
+
} else {
|
|
152
|
+
a.Repo = filepath.Base(a.Repo)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
agents = append(agents, a)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return agents
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// loadCosts reads costs.jsonl and sums cost per task.
|
|
162
|
+
func loadCosts(path string) map[string]string {
|
|
163
|
+
result := make(map[string]string)
|
|
164
|
+
data, err := os.ReadFile(path)
|
|
165
|
+
if err != nil {
|
|
166
|
+
return result
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
totals := make(map[string]float64)
|
|
170
|
+
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
|
171
|
+
if line == "" {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
var entry costEntry
|
|
175
|
+
if json.Unmarshal([]byte(line), &entry) == nil && entry.TaskID != "" {
|
|
176
|
+
totals[entry.TaskID] += entry.TotalCost
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for id, total := range totals {
|
|
181
|
+
result[id] = fmt.Sprintf("$%.2f", total)
|
|
182
|
+
}
|
|
183
|
+
return result
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// loadConflictCounts reads conflicts.jsonl and counts unresolved conflicts per task.
|
|
187
|
+
func loadConflictCounts(path string) map[string]int {
|
|
188
|
+
result := make(map[string]int)
|
|
189
|
+
data, err := os.ReadFile(path)
|
|
190
|
+
if err != nil {
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
|
195
|
+
if line == "" {
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
var entry conflictEntry
|
|
199
|
+
if json.Unmarshal([]byte(line), &entry) == nil && !entry.Resolved {
|
|
200
|
+
for _, id := range entry.TaskIDs {
|
|
201
|
+
result[id]++
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// loadTmuxSessions returns a set of active tmux session names.
|
|
209
|
+
func loadTmuxSessions() map[string]bool {
|
|
210
|
+
sessions := make(map[string]bool)
|
|
211
|
+
out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output()
|
|
212
|
+
if err != nil {
|
|
213
|
+
return sessions
|
|
214
|
+
}
|
|
215
|
+
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
216
|
+
if line != "" {
|
|
217
|
+
sessions[line] = true
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return sessions
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ciIndicator converts a CI status string to a display indicator.
|
|
224
|
+
func ciIndicator(status string) string {
|
|
225
|
+
switch strings.ToLower(status) {
|
|
226
|
+
case "pass", "passed", "success":
|
|
227
|
+
return "✅"
|
|
228
|
+
case "fail", "failed", "failure":
|
|
229
|
+
return "❌"
|
|
230
|
+
case "pending", "running", "in_progress":
|
|
231
|
+
return "⏳"
|
|
232
|
+
default:
|
|
233
|
+
return "-"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// statusIndicator returns the colored status emoji for an agent.
|
|
238
|
+
func statusIndicator(status string) string {
|
|
239
|
+
switch strings.ToLower(status) {
|
|
240
|
+
case "running":
|
|
241
|
+
return "🟢"
|
|
242
|
+
case "idle":
|
|
243
|
+
return "🟡"
|
|
244
|
+
case "failed":
|
|
245
|
+
return "🔴"
|
|
246
|
+
case "done":
|
|
247
|
+
return "⚪"
|
|
248
|
+
default:
|
|
249
|
+
return "⚫"
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// captureTmuxPreview grabs the last N lines from a tmux pane.
|
|
254
|
+
func captureTmuxPreview(session string, lines int) string {
|
|
255
|
+
out, err := exec.Command("tmux", "capture-pane", "-t", session, "-p", "-S", fmt.Sprintf("-%d", lines)).Output()
|
|
256
|
+
if err != nil {
|
|
257
|
+
return ""
|
|
258
|
+
}
|
|
259
|
+
// Strip ANSI escape codes for clean display
|
|
260
|
+
result := strings.TrimSpace(string(out))
|
|
261
|
+
// Basic ANSI strip
|
|
262
|
+
for _, prefix := range []string{"\033[", "\x1b["} {
|
|
263
|
+
_ = prefix // handled by sed-like logic below
|
|
264
|
+
}
|
|
265
|
+
return result
|
|
266
|
+
}
|
package/tui/animation.go
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"time"
|
|
5
|
+
|
|
6
|
+
tea "charm.land/bubbletea/v2"
|
|
7
|
+
"charm.land/lipgloss/v2"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// AnimationTickMsg advances the forge startup animation by one frame.
|
|
11
|
+
type AnimationTickMsg time.Time
|
|
12
|
+
|
|
13
|
+
// AnimationDoneMsg signals that the startup animation has completed.
|
|
14
|
+
type AnimationDoneMsg struct{}
|
|
15
|
+
|
|
16
|
+
const frameDuration = 120 * time.Millisecond
|
|
17
|
+
|
|
18
|
+
// forgeFrames contains the ASCII art frames for the forge startup animation.
|
|
19
|
+
// 10 frames of a hammering/sparks effect at ~120ms each = ~1.2s total.
|
|
20
|
+
var forgeFrames = [...]string{
|
|
21
|
+
// Frame 0: Forge cold
|
|
22
|
+
`
|
|
23
|
+
_______________
|
|
24
|
+
/ \
|
|
25
|
+
/ C L A W \
|
|
26
|
+
/ F O R G E \
|
|
27
|
+
/ \
|
|
28
|
+
/_______________________\
|
|
29
|
+
| |
|
|
30
|
+
| |
|
|
31
|
+
| ___ |
|
|
32
|
+
| | | |
|
|
33
|
+
_______|_|___|_|________
|
|
34
|
+
|________________________|
|
|
35
|
+
`,
|
|
36
|
+
// Frame 1: Embers glow
|
|
37
|
+
`
|
|
38
|
+
_______________
|
|
39
|
+
/ \
|
|
40
|
+
/ C L A W \
|
|
41
|
+
/ F O R G E \
|
|
42
|
+
/ . \
|
|
43
|
+
/_______________________\
|
|
44
|
+
| |
|
|
45
|
+
| * |
|
|
46
|
+
| ___ |
|
|
47
|
+
| | | |
|
|
48
|
+
_______|_|___|_|________
|
|
49
|
+
|_______.....____________|
|
|
50
|
+
`,
|
|
51
|
+
// Frame 2: Fire rising
|
|
52
|
+
`
|
|
53
|
+
_______________
|
|
54
|
+
/ \
|
|
55
|
+
/ C L A W \
|
|
56
|
+
/ F O R G E \
|
|
57
|
+
/ . . \
|
|
58
|
+
/_______________________\
|
|
59
|
+
| * * |
|
|
60
|
+
| /|\ |
|
|
61
|
+
| ___ |
|
|
62
|
+
| |^^^| |
|
|
63
|
+
_______|_|___|_|________
|
|
64
|
+
|______*..^^^..*_________|
|
|
65
|
+
`,
|
|
66
|
+
// Frame 3: Hammer up
|
|
67
|
+
`
|
|
68
|
+
_______________
|
|
69
|
+
/ \ _____
|
|
70
|
+
/ C L A W \ | |
|
|
71
|
+
/ F O R G E \ | ))) |
|
|
72
|
+
/ * . * \|_____|
|
|
73
|
+
/_______________________\ |
|
|
74
|
+
| *** * | |
|
|
75
|
+
| /|\ | /
|
|
76
|
+
| ___ | /
|
|
77
|
+
| |^^^| |
|
|
78
|
+
_______|_|___|_|________
|
|
79
|
+
|______*..^^^..*_________|
|
|
80
|
+
`,
|
|
81
|
+
// Frame 4: Hammer strike!
|
|
82
|
+
`
|
|
83
|
+
_______________
|
|
84
|
+
/ \
|
|
85
|
+
/ C L A W \
|
|
86
|
+
/ F O R G E \ _____
|
|
87
|
+
/ * * . * * \ | |
|
|
88
|
+
/________________________| ))) |
|
|
89
|
+
| ***** | |_____|
|
|
90
|
+
| /|\ |
|
|
91
|
+
| ___ |
|
|
92
|
+
| |^^^| |
|
|
93
|
+
_______|_|___|_|________
|
|
94
|
+
|______*..^^^..*_________|
|
|
95
|
+
`,
|
|
96
|
+
// Frame 5: SPARKS!
|
|
97
|
+
`
|
|
98
|
+
_______________
|
|
99
|
+
/ * * \
|
|
100
|
+
/ C L A W * \
|
|
101
|
+
/ F O R G E * \ _____
|
|
102
|
+
/ * * * . * * * \ | |
|
|
103
|
+
/________________________\| ))) |
|
|
104
|
+
* | ***** | * |_____|
|
|
105
|
+
* | /|\ | *
|
|
106
|
+
| ___ |
|
|
107
|
+
| |^^^| |
|
|
108
|
+
_______|_|___|_|________
|
|
109
|
+
|______*..^^^..*_________|
|
|
110
|
+
`,
|
|
111
|
+
// Frame 6: Sparks fade, hammer lifts
|
|
112
|
+
`
|
|
113
|
+
_______________
|
|
114
|
+
/ * \
|
|
115
|
+
/ C L A W \
|
|
116
|
+
/ F O R G E \ _____
|
|
117
|
+
/ * . . * \ | |
|
|
118
|
+
/_______________________\ | ))) |
|
|
119
|
+
| * * * | |_____|
|
|
120
|
+
| /|\ | |
|
|
121
|
+
| ___ | /
|
|
122
|
+
| |^^^| |
|
|
123
|
+
_______|_|___|_|________
|
|
124
|
+
|______*..^^^..*_________|
|
|
125
|
+
`,
|
|
126
|
+
// Frame 7: Second strike!
|
|
127
|
+
`
|
|
128
|
+
_______________
|
|
129
|
+
/ \
|
|
130
|
+
/ C L A W \
|
|
131
|
+
/ F O R G E \ _____
|
|
132
|
+
/ * * . * * \ | |
|
|
133
|
+
/________________________| ))) |
|
|
134
|
+
| ***** | |_____|
|
|
135
|
+
| /|\ |
|
|
136
|
+
| ___ |
|
|
137
|
+
| |^^^| |
|
|
138
|
+
_______|_|___|_|________
|
|
139
|
+
|______*..^^^..*_________|
|
|
140
|
+
`,
|
|
141
|
+
// Frame 8: BIG SPARKS!
|
|
142
|
+
`
|
|
143
|
+
* _______________ *
|
|
144
|
+
* / * * * \ *
|
|
145
|
+
/ C L A W * * \
|
|
146
|
+
* / F O R G E * \ _____
|
|
147
|
+
/ * * * * . * * * * \ | |
|
|
148
|
+
/________________________\| ))) |
|
|
149
|
+
* * | ***** | * * |_____|
|
|
150
|
+
* * | /|\ | * *
|
|
151
|
+
* | ___ | *
|
|
152
|
+
| |^^^| |
|
|
153
|
+
_______|_|___|_|________
|
|
154
|
+
|______*..^^^..*_________|
|
|
155
|
+
`,
|
|
156
|
+
// Frame 9: Forge ready — blade forged
|
|
157
|
+
`
|
|
158
|
+
_______________
|
|
159
|
+
/ \
|
|
160
|
+
/ C L A W \
|
|
161
|
+
/ F O R G E \
|
|
162
|
+
/ READY \
|
|
163
|
+
/_______________________\
|
|
164
|
+
| |
|
|
165
|
+
| --- |
|
|
166
|
+
| ___ |
|
|
167
|
+
| |===| |
|
|
168
|
+
_______|_|___|_|________
|
|
169
|
+
|_______ FORGED _________|
|
|
170
|
+
`,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// animationTick returns a command that sends an AnimationTickMsg after frameDuration.
|
|
174
|
+
func animationTick() tea.Cmd {
|
|
175
|
+
return tea.Tick(frameDuration, func(t time.Time) tea.Msg {
|
|
176
|
+
return AnimationTickMsg(t)
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// renderAnimation renders the current animation frame centered in the terminal.
|
|
181
|
+
func renderAnimation(frame int, width, height int) string {
|
|
182
|
+
if frame < 0 || frame >= len(forgeFrames) {
|
|
183
|
+
return ""
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
content := animFrameStyle.Render(forgeFrames[frame])
|
|
187
|
+
|
|
188
|
+
if width > 0 && height > 0 {
|
|
189
|
+
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
|
|
190
|
+
}
|
|
191
|
+
return content
|
|
192
|
+
}
|