Skip to content

Commit bd6659f

Browse files
author
echoVic
committed
feat(ui): 添加状态信息框组件并替换日志输出方式
将状态信息的日志输出方式替换为更美观的框式显示组件 新增 Box 组件支持带边框的格式化输出 添加状态指示器组件显示不同状态
1 parent 04b5925 commit bd6659f

3 files changed

Lines changed: 311 additions & 11 deletions

File tree

cmd/bar/status_log.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/user/blade-agent-runtime/internal/completion"
1414
"github.com/user/blade-agent-runtime/internal/core/ledger"
15+
"github.com/user/blade-agent-runtime/internal/ui"
1516
barerrors "github.com/user/blade-agent-runtime/internal/util/errors"
1617
)
1718

@@ -62,17 +63,18 @@ func statusCmd() *cobra.Command {
6263
fmt.Fprintln(os.Stdout, string(data))
6364
return nil
6465
}
65-
app.Logger.Info("BAR Status")
66-
app.Logger.Info("Repository: %s", app.RepoRoot)
67-
app.Logger.Info("Active Task: %s (%s)", task.Name, task.ID)
68-
app.Logger.Info("Workspace: %s", task.WorkspacePath)
69-
app.Logger.Info("Branch: %s", task.Branch)
70-
app.Logger.Info("Base: %s", task.BaseRef)
71-
app.Logger.Info("Status: %s", statusString(clean))
72-
app.Logger.Info("Steps: %d", len(steps))
66+
box := ui.NewBox("🔧 BAR Status")
67+
box.AddRow("Repository", app.RepoRoot)
68+
box.AddRow("Active Task", fmt.Sprintf("%s (%s)", task.Name, task.ID))
69+
box.AddRow("Workspace", task.WorkspacePath)
70+
box.AddRow("Branch", task.Branch)
71+
box.AddRow("Base", task.BaseRef)
72+
box.AddRow("Status", ui.StatusIndicator(clean, 0))
73+
box.AddRow("Steps", fmt.Sprintf("%d", len(steps)))
7374
if last != nil {
74-
app.Logger.Info("Last Step: %s (%s)", last.StepID, last.Kind)
75+
box.AddRow("Last Step", fmt.Sprintf("%s (%s)", last.StepID, last.Kind))
7576
}
77+
fmt.Fprintln(os.Stdout, box.Render())
7678
return nil
7779
},
7880
}
@@ -108,8 +110,8 @@ func logCmd() *cobra.Command {
108110
return err
109111
}
110112
if step == nil {
111-
return barerrors.StepNotFound(stepID)
112-
}
113+
return barerrors.StepNotFound(stepID)
114+
}
113115
return writeLogOutput(format, output, renderStepDetail(step))
114116
}
115117
if limit > 0 && len(steps) > limit {

internal/ui/box.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package ui
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type Row struct {
9+
Label string
10+
Value string
11+
}
12+
13+
type Box struct {
14+
title string
15+
rows []Row
16+
width int
17+
padding int
18+
}
19+
20+
func NewBox(title string) *Box {
21+
return &Box{
22+
title: title,
23+
rows: make([]Row, 0),
24+
width: 0,
25+
padding: 1,
26+
}
27+
}
28+
29+
func (b *Box) AddRow(label, value string) {
30+
b.rows = append(b.rows, Row{Label: label, Value: value})
31+
}
32+
33+
func (b *Box) SetWidth(w int) {
34+
b.width = w
35+
}
36+
37+
func (b *Box) SetPadding(p int) {
38+
b.padding = p
39+
}
40+
41+
func (b *Box) Render() string {
42+
width := b.calculateWidth()
43+
innerWidth := width - 2
44+
45+
var sb strings.Builder
46+
47+
sb.WriteString("╭")
48+
sb.WriteString(strings.Repeat("─", innerWidth))
49+
sb.WriteString("╮\n")
50+
51+
if b.title != "" {
52+
titleLine := b.padRight(fmt.Sprintf("%s%s", strings.Repeat(" ", b.padding), b.title), innerWidth)
53+
sb.WriteString("│")
54+
sb.WriteString(titleLine)
55+
sb.WriteString("│\n")
56+
57+
sb.WriteString("├")
58+
sb.WriteString(strings.Repeat("─", innerWidth))
59+
sb.WriteString("┤\n")
60+
}
61+
62+
labelWidth := b.maxLabelWidth()
63+
for _, row := range b.rows {
64+
content := fmt.Sprintf("%s%-*s %s",
65+
strings.Repeat(" ", b.padding),
66+
labelWidth,
67+
row.Label,
68+
row.Value,
69+
)
70+
line := b.padRight(content, innerWidth)
71+
sb.WriteString("│")
72+
sb.WriteString(line)
73+
sb.WriteString("│\n")
74+
}
75+
76+
sb.WriteString("╰")
77+
sb.WriteString(strings.Repeat("─", innerWidth))
78+
sb.WriteString("╯")
79+
80+
return sb.String()
81+
}
82+
83+
func (b *Box) calculateWidth() int {
84+
if b.width > 0 {
85+
return b.width
86+
}
87+
88+
maxWidth := runeLen(b.title) + b.padding*2 + 2
89+
90+
labelWidth := b.maxLabelWidth()
91+
for _, row := range b.rows {
92+
rowWidth := b.padding + labelWidth + 2 + runeLen(row.Value) + b.padding + 2
93+
if rowWidth > maxWidth {
94+
maxWidth = rowWidth
95+
}
96+
}
97+
98+
return maxWidth
99+
}
100+
101+
func (b *Box) maxLabelWidth() int {
102+
max := 0
103+
for _, row := range b.rows {
104+
l := runeLen(row.Label)
105+
if l > max {
106+
max = l
107+
}
108+
}
109+
return max
110+
}
111+
112+
func (b *Box) padRight(s string, width int) string {
113+
currentWidth := runeLen(s)
114+
if currentWidth >= width {
115+
return s
116+
}
117+
return s + strings.Repeat(" ", width-currentWidth)
118+
}
119+
120+
func runeLen(s string) int {
121+
count := 0
122+
for _, r := range s {
123+
if isWideRune(r) {
124+
count += 2
125+
} else {
126+
count++
127+
}
128+
}
129+
return count
130+
}
131+
132+
func isWideRune(r rune) bool {
133+
return r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A ||
134+
(r >= 0x2E80 && r <= 0xA4CF && r != 0x303F) ||
135+
(r >= 0xAC00 && r <= 0xD7A3) ||
136+
(r >= 0xF900 && r <= 0xFAFF) ||
137+
(r >= 0xFE10 && r <= 0xFE1F) ||
138+
(r >= 0xFE30 && r <= 0xFE6F) ||
139+
(r >= 0xFF00 && r <= 0xFF60) ||
140+
(r >= 0xFFE0 && r <= 0xFFE6) ||
141+
(r >= 0x1F300 && r <= 0x1F9FF))
142+
}
143+
144+
func StatusIndicator(clean bool, files int) string {
145+
if clean {
146+
return "🟢 clean"
147+
}
148+
if files > 0 {
149+
return fmt.Sprintf("🟡 dirty (%d files)", files)
150+
}
151+
return "🟡 dirty"
152+
}

internal/ui/box_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package ui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestBox_Render(t *testing.T) {
9+
box := NewBox("🔧 BAR Status")
10+
box.AddRow("Repository", "/path/to/repo")
11+
box.AddRow("Active Task", "fix-bug (abc123)")
12+
box.AddRow("Branch", "bar/fix-bug-abc123")
13+
box.AddRow("Status", "🟡 dirty (3 files)")
14+
box.AddRow("Steps", "5")
15+
16+
output := box.Render()
17+
18+
if !strings.Contains(output, "╭") {
19+
t.Error("output should contain top-left corner")
20+
}
21+
if !strings.Contains(output, "╰") {
22+
t.Error("output should contain bottom-left corner")
23+
}
24+
if !strings.Contains(output, "🔧 BAR Status") {
25+
t.Error("output should contain title")
26+
}
27+
if !strings.Contains(output, "Repository") {
28+
t.Error("output should contain Repository label")
29+
}
30+
if !strings.Contains(output, "/path/to/repo") {
31+
t.Error("output should contain Repository value")
32+
}
33+
if !strings.Contains(output, "fix-bug (abc123)") {
34+
t.Error("output should contain task info")
35+
}
36+
}
37+
38+
func TestBox_EmptyTitle(t *testing.T) {
39+
box := NewBox("")
40+
box.AddRow("Key", "Value")
41+
42+
output := box.Render()
43+
44+
if strings.Contains(output, "├") {
45+
t.Error("output should not contain separator when no title")
46+
}
47+
if !strings.Contains(output, "Key") {
48+
t.Error("output should contain row")
49+
}
50+
}
51+
52+
func TestBox_Width(t *testing.T) {
53+
box := NewBox("Title")
54+
box.SetWidth(30)
55+
box.AddRow("A", "B")
56+
57+
output := box.Render()
58+
lines := strings.Split(output, "\n")
59+
60+
for _, line := range lines {
61+
if line == "" {
62+
continue
63+
}
64+
runeCount := runeWidth(line)
65+
if runeCount != 30 {
66+
t.Errorf("line width = %d, want 30: %q", runeCount, line)
67+
}
68+
}
69+
}
70+
71+
func TestBox_AutoWidth(t *testing.T) {
72+
box := NewBox("Status")
73+
box.AddRow("Short", "A")
74+
box.AddRow("Very Long Label Here", "Some very long value that should expand the box")
75+
76+
output := box.Render()
77+
lines := strings.Split(output, "\n")
78+
79+
width := 0
80+
for _, line := range lines {
81+
if line == "" {
82+
continue
83+
}
84+
w := runeWidth(line)
85+
if width == 0 {
86+
width = w
87+
}
88+
if w != width {
89+
t.Errorf("inconsistent width: got %d, want %d", w, width)
90+
}
91+
}
92+
}
93+
94+
func TestBox_StatusIndicator(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
clean bool
98+
files int
99+
want string
100+
}{
101+
{"clean", true, 0, "🟢 clean"},
102+
{"dirty with files", false, 3, "🟡 dirty (3 files)"},
103+
{"dirty no files", false, 0, "🟡 dirty"},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.name, func(t *testing.T) {
108+
got := StatusIndicator(tt.clean, tt.files)
109+
if got != tt.want {
110+
t.Errorf("StatusIndicator() = %q, want %q", got, tt.want)
111+
}
112+
})
113+
}
114+
}
115+
116+
func TestBox_Padding(t *testing.T) {
117+
box := NewBox("Test")
118+
box.SetPadding(2)
119+
box.AddRow("Key", "Value")
120+
121+
output := box.Render()
122+
123+
if !strings.Contains(output, " Key") {
124+
t.Error("output should have left padding")
125+
}
126+
}
127+
128+
func runeWidth(s string) int {
129+
count := 0
130+
for _, r := range s {
131+
if r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A ||
132+
(r >= 0x2E80 && r <= 0xA4CF && r != 0x303F) ||
133+
(r >= 0xAC00 && r <= 0xD7A3) ||
134+
(r >= 0xF900 && r <= 0xFAFF) ||
135+
(r >= 0xFE10 && r <= 0xFE1F) ||
136+
(r >= 0xFE30 && r <= 0xFE6F) ||
137+
(r >= 0xFF00 && r <= 0xFF60) ||
138+
(r >= 0xFFE0 && r <= 0xFFE6) ||
139+
(r >= 0x1F300 && r <= 0x1F9FF)) {
140+
count += 2
141+
} else {
142+
count++
143+
}
144+
}
145+
return count
146+
}

0 commit comments

Comments
 (0)