Skip to content

Commit ea01dbd

Browse files
committed
feat: Implement a modern terminal UI using Bubble Tea framework to replace the legacy readline-based interface, providing better user experience with split-pane layout for chat and tool events.
1 parent 4f60508 commit ea01dbd

File tree

21 files changed

+1561
-58
lines changed

21 files changed

+1561
-58
lines changed

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- **Breaking**: Replaced custom HTTP implementation with official SDKs
2727
- **API**: Updated `llm.Client` interface for better provider abstraction
2828
- **Configuration**: Enhanced config support for multiple providers
29-
- **Default Model**: OpenAI default model changed to `gpt-4.1-mimi`
29+
- **Default Model**: OpenAI default model changed to `gpt-4.1-mini`
3030
- **Dependencies**:
3131
- Added `github.com/openai/openai-go/v2 v2.7.1`
3232
- Added `github.com/anthropics/anthropic-sdk-go v1.14.0`
@@ -98,7 +98,7 @@ model:
9898
```yaml
9999
model:
100100
provider: "openai" # or "anthropic"
101-
name: "gpt-4.1-mimi" # for OpenAI
101+
name: "gpt-4.1-mini" # for OpenAI
102102
# name: "claude-3-7-sonnet-latest" # for Anthropic
103103
```
104104

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,13 @@ go build ./cmd/goai
122122
1. **Set up your API key** (required for LLM features):
123123

124124
For OpenAI:
125+
125126
```bash
126127
export OPENAI_API_KEY="your-api-key-here"
127128
```
128129

129130
For Anthropic:
131+
130132
```bash
131133
export ANTHROPIC_API_KEY="your-api-key-here"
132134
```
@@ -167,6 +169,36 @@ Type your query or command and press Enter.
167169
>
168170
```
169171

172+
### TUI (Bubble Tea)
173+
174+
GoAI includes a full-screen TUI built with Bubble Tea. It shows assistant replies and real-time tool events side by side.
175+
176+
- Run
177+
- Default: `./goai` launches the TUI.
178+
- Fallback legacy prompt: set `GOAI_LEGACY_UI=1` to use the old readline interface.
179+
180+
- Layout
181+
- Left panel: conversation (assistant/user), with streaming updates.
182+
- Right panel: tool events and outputs (started/succeeded/failed with truncated output).
183+
- Bottom: input line; top/right: spinner status (e.g., “Running bash…”, “Thinking…”).
184+
185+
- Keys
186+
- `Enter`: send the current input
187+
- `Esc`: cancel the current request
188+
- `Ctrl+C`: quit
189+
190+
- Tool Events
191+
- Tools executed by the agent emit events in real time:
192+
- started: tool name and sanitized arguments
193+
- succeeded: duration + output (long output truncated)
194+
- failed: error message
195+
- Events do not alter conversation history; they are for visibility only.
196+
197+
- Configuration (optional)
198+
- Configure model/provider via `goai.yaml` or env vars (see below).
199+
- Tool event output length is limited internally to keep the UI responsive.
200+
- To force the legacy prompt temporarily: `GOAI_LEGACY_UI=1 ./goai`.
201+
170202
### Usage Examples
171203

172204
#### Example 1: Create a Simple Program
@@ -250,10 +282,11 @@ export OPENAI_BASE_URL="https://api.openai.com/v1" # API endpoint (default: Ope
250282
For advanced configuration, create a `goai.yaml` file in your project directory or `~/.config/goai/config.yaml`:
251283

252284
**OpenAI Configuration:**
285+
253286
```yaml
254287
model:
255288
provider: "openai"
256-
name: "gpt-4.1-mimi" # or "gpt-4", "gpt-3.5-turbo", etc.
289+
name: "gpt-4.1-mini" # or "gpt-4", "gpt-3.5-turbo", etc.
257290
max_tokens: 16000
258291
timeout: 60
259292

@@ -279,10 +312,11 @@ output:
279312
```
280313
281314
**Anthropic Configuration:**
315+
282316
```yaml
283317
model:
284318
provider: "anthropic"
285-
name: "claude-3-7-sonnet-latest" # or "claude-3-opus-latest", etc.
319+
name: "claude-3-7-sonnet-latest" # or "claude-3-opus-latest", etc.
286320
max_tokens: 16000
287321
timeout: 60
288322

cmd/goai/main.go

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010
"syscall"
1111
"time"
1212

13+
"github.com/Zerofisher/goai/cmd/goai/tui"
1314
"github.com/Zerofisher/goai/pkg/agent"
1415
"github.com/Zerofisher/goai/pkg/config"
16+
"github.com/Zerofisher/goai/pkg/dispatcher"
1517
"github.com/Zerofisher/goai/pkg/reminder"
1618
"github.com/Zerofisher/goai/pkg/todo"
1719
"github.com/Zerofisher/goai/pkg/tools/bash"
@@ -20,6 +22,8 @@ import (
2022
"github.com/Zerofisher/goai/pkg/tools/search"
2123
todotool "github.com/Zerofisher/goai/pkg/tools/todo"
2224

25+
tea "github.com/charmbracelet/bubbletea"
26+
2327
// Import LLM providers to register their factories
2428
_ "github.com/Zerofisher/goai/pkg/llm/anthropic"
2529
_ "github.com/Zerofisher/goai/pkg/llm/openai"
@@ -68,11 +72,18 @@ func main() {
6872
fmt.Println()
6973
}
7074

71-
// Print welcome message
72-
printWelcome()
73-
74-
// Run interactive loop
75-
runInteractiveLoop(ctx, agent)
75+
// Check if we should use legacy interactive mode
76+
// Set GOAI_LEGACY_UI=1 to use the old readline-based interface
77+
useLegacyUI := os.Getenv("GOAI_LEGACY_UI") == "1"
78+
79+
if useLegacyUI {
80+
// Use legacy interactive mode
81+
printWelcome()
82+
runInteractiveLoop(ctx, agent)
83+
} else {
84+
// Use Bubble Tea TUI
85+
runTUI(ctx, agent, cfg)
86+
}
7687
}
7788

7889
// loadConfig loads the configuration from file or environment
@@ -265,4 +276,40 @@ func printHelp() {
265276
fmt.Println(" - The agent can help with code generation, debugging, and explanations")
266277
fmt.Println(" - Use available tools to read/write files and execute commands")
267278
fmt.Println()
268-
}
279+
}
280+
281+
// runTUI starts the Bubble Tea TUI interface
282+
func runTUI(_ context.Context, a *agent.Agent, cfg *config.Config) {
283+
// Create TUI model
284+
model := tui.New(a, cfg)
285+
286+
// Create Bubble Tea program
287+
p := tea.NewProgram(
288+
model,
289+
tea.WithAltScreen(), // Use alternate screen buffer
290+
tea.WithMouseCellMotion(), // Enable mouse support
291+
)
292+
293+
// Set program reference in model (for goroutine message sending)
294+
model.SetProgram(p)
295+
296+
// Configure tool observer with events options
297+
eventsOpts := dispatcher.EventsOptions{
298+
MaxOutputChars: cfg.Output.ToolOutputMaxChars,
299+
MaskKeys: []string{
300+
"api_key", "apikey", "token", "password", "passwd", "pwd",
301+
"secret", "auth", "key", "access_key", "private_key",
302+
"authorization", "credential", "credentials",
303+
},
304+
}
305+
306+
// Register observer with the agent
307+
observer := tui.NewObserver(p)
308+
a.SetToolObserver(observer, eventsOpts)
309+
310+
// Start the program
311+
if _, err := p.Run(); err != nil {
312+
fmt.Printf("Error starting TUI: %v\n", err)
313+
os.Exit(1)
314+
}
315+
}

cmd/goai/tui/model.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package tui
2+
3+
import (
4+
"github.com/Zerofisher/goai/pkg/agent"
5+
"github.com/Zerofisher/goai/pkg/config"
6+
"github.com/charmbracelet/bubbles/spinner"
7+
"github.com/charmbracelet/bubbles/textinput"
8+
"github.com/charmbracelet/bubbles/viewport"
9+
tea "github.com/charmbracelet/bubbletea"
10+
)
11+
12+
// Model represents the state of the TUI application
13+
type Model struct {
14+
// Agent reference for executing queries
15+
agent *agent.Agent
16+
17+
// Configuration
18+
cfg *config.Config
19+
20+
// Program reference (for sending messages from goroutines)
21+
program *tea.Program
22+
23+
// UI Components
24+
input textinput.Model // Bottom input field
25+
chat viewport.Model // Left/top viewport for conversation
26+
tools viewport.Model // Right/bottom viewport for tool events
27+
spinner spinner.Model // Loading spinner
28+
29+
// State
30+
state struct {
31+
querying bool // Whether a query is in progress
32+
ready bool // Whether the UI is ready (window size received)
33+
width int // Terminal width
34+
height int // Terminal height
35+
}
36+
37+
// Content buffers
38+
chatContent string // Accumulated chat messages
39+
toolsContent string // Accumulated tool event logs
40+
41+
// Spinner state
42+
spinnerLabel string // Current spinner label text
43+
}
44+
45+
// New creates a new TUI model
46+
func New(a *agent.Agent, cfg *config.Config) *Model {
47+
// Create text input
48+
ti := textinput.New()
49+
ti.Placeholder = "Type your message..."
50+
ti.Focus()
51+
ti.CharLimit = 500
52+
ti.Width = 50
53+
54+
// Create spinner
55+
s := spinner.New()
56+
s.Spinner = spinner.Dot
57+
s.Style = defaultSpinnerStyle()
58+
59+
m := &Model{
60+
agent: a,
61+
cfg: cfg,
62+
input: ti,
63+
spinner: s,
64+
spinnerLabel: "Ready",
65+
}
66+
67+
m.state.ready = false
68+
m.state.querying = false
69+
70+
// Initial content
71+
m.chatContent = "Welcome to GoAI Coder!\n\n"
72+
m.toolsContent = "Tool events will appear here...\n\n"
73+
74+
return m
75+
}
76+
77+
// Init initializes the model (Bubble Tea interface)
78+
func (m *Model) Init() tea.Cmd {
79+
return tea.Batch(
80+
textinput.Blink,
81+
m.spinner.Tick,
82+
)
83+
}

cmd/goai/tui/msgs.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package tui
2+
3+
import (
4+
"github.com/Zerofisher/goai/pkg/types"
5+
)
6+
7+
// ToolEventMsg wraps a tool event for the Bubble Tea message loop
8+
type ToolEventMsg struct {
9+
Event types.ToolEvent
10+
}
11+
12+
// LLMStreamTextMsg contains a chunk of streaming text from the LLM
13+
type LLMStreamTextMsg struct {
14+
Text string
15+
}
16+
17+
// LLMDoneMsg signals that the LLM has finished generating a response
18+
type LLMDoneMsg struct{}
19+
20+
// ErrorMsg wraps an error for display in the UI
21+
type ErrorMsg struct {
22+
Err error
23+
}
24+
25+
// QueryMsg triggers a query to the agent
26+
type QueryMsg struct {
27+
Text string
28+
}

cmd/goai/tui/observer.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
6+
"github.com/Zerofisher/goai/pkg/types"
7+
tea "github.com/charmbracelet/bubbletea"
8+
)
9+
10+
// Observer implements dispatcher.ToolObserver and sends tool events to the Bubble Tea program
11+
type Observer struct {
12+
program *tea.Program
13+
}
14+
15+
// NewObserver creates a new observer that sends events to the given Bubble Tea program
16+
func NewObserver(p *tea.Program) *Observer {
17+
return &Observer{
18+
program: p,
19+
}
20+
}
21+
22+
// OnToolEvent implements dispatcher.ToolObserver
23+
// It sends the tool event as a message to the Bubble Tea event loop
24+
func (o *Observer) OnToolEvent(_ context.Context, e types.ToolEvent) {
25+
if o.program != nil {
26+
// Non-blocking send to the Bubble Tea message loop
27+
o.program.Send(ToolEventMsg{Event: e})
28+
}
29+
}

cmd/goai/tui/styles.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package tui
2+
3+
import (
4+
"github.com/charmbracelet/lipgloss"
5+
)
6+
7+
var (
8+
// Color palette
9+
colorPrimary = lipgloss.Color("#00D7AF")
10+
colorSecondary = lipgloss.Color("#7D56F4")
11+
colorError = lipgloss.Color("#FF5F87")
12+
colorSubtle = lipgloss.Color("#6C7086")
13+
colorBorder = lipgloss.Color("#45475A")
14+
15+
// Base styles
16+
baseStyle = lipgloss.NewStyle().
17+
Padding(0, 1)
18+
19+
// Title styles
20+
titleStyle = lipgloss.NewStyle().
21+
Foreground(colorPrimary).
22+
Bold(true).
23+
Padding(0, 1)
24+
25+
// Viewport styles
26+
viewportStyle = lipgloss.NewStyle().
27+
BorderStyle(lipgloss.RoundedBorder()).
28+
BorderForeground(colorBorder).
29+
Padding(1, 2)
30+
31+
// Input styles
32+
inputStyle = lipgloss.NewStyle().
33+
BorderStyle(lipgloss.RoundedBorder()).
34+
BorderForeground(colorPrimary).
35+
Padding(0, 1)
36+
37+
// Spinner styles
38+
spinnerStyle = lipgloss.NewStyle().
39+
Foreground(colorSecondary)
40+
41+
// Status bar style
42+
statusBarStyle = lipgloss.NewStyle().
43+
Foreground(colorSubtle).
44+
Padding(0, 1)
45+
46+
// Tool event styles
47+
toolStartedStyle = lipgloss.NewStyle().
48+
Foreground(lipgloss.Color("#FFA500"))
49+
50+
toolSucceededStyle = lipgloss.NewStyle().
51+
Foreground(lipgloss.Color("#00FF00"))
52+
53+
toolFailedStyle = lipgloss.NewStyle().
54+
Foreground(colorError)
55+
)
56+
57+
// defaultSpinnerStyle returns the default spinner style
58+
func defaultSpinnerStyle() lipgloss.Style {
59+
return spinnerStyle
60+
}

0 commit comments

Comments
 (0)