Skip to content

Commit eb63680

Browse files
committed
feat: Phase 1 — core agent loop with eyrie streaming
- engine/ package: Session manager with Stream() returning channel of events - prompt/ package: system prompt templates - Bubbletea TUI with token-by-token streaming via Program.Send - Multi-turn conversation history - --model, --prompt, --provider flags - Auto-detects provider from env (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
1 parent 67c1ed9 commit eb63680

5 files changed

Lines changed: 262 additions & 45 deletions

File tree

‎cmd/chat.go‎

Lines changed: 141 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,63 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"strings"
7+
"sync"
68

79
"github.com/charmbracelet/bubbles/spinner"
810
"github.com/charmbracelet/bubbles/textarea"
9-
"github.com/charmbracelet/bubbletea"
11+
tea "github.com/charmbracelet/bubbletea"
1012
"github.com/charmbracelet/lipgloss"
13+
14+
"github.com/GrayCodeAI/hawk/engine"
15+
"github.com/GrayCodeAI/hawk/prompt"
1116
)
1217

13-
// Styles
1418
var (
15-
tealColor = lipgloss.Color("#4ECDC4")
16-
dimColor = lipgloss.Color("#666666")
17-
errorColor = lipgloss.Color("#e05555")
18-
userStyle = lipgloss.NewStyle().Foreground(tealColor).Bold(true)
19-
assistStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
20-
dimStyle = lipgloss.NewStyle().Foreground(dimColor)
21-
errorStyle = lipgloss.NewStyle().Foreground(errorColor)
22-
headerStyle = lipgloss.NewStyle().Foreground(tealColor).Bold(true)
19+
tealColor = lipgloss.Color("#4ECDC4")
20+
dimColor = lipgloss.Color("#666666")
21+
errorColor = lipgloss.Color("#e05555")
22+
userStyle = lipgloss.NewStyle().Foreground(tealColor).Bold(true)
23+
assistStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF"))
24+
dimStyle = lipgloss.NewStyle().Foreground(dimColor)
25+
errorStyle = lipgloss.NewStyle().Foreground(errorColor)
26+
headerStyle = lipgloss.NewStyle().Foreground(tealColor).Bold(true)
2327
)
2428

25-
type chatMsg struct {
29+
type streamChunkMsg string
30+
type streamDoneMsg struct{}
31+
type streamErrMsg struct{ err error }
32+
33+
// progRef holds a reference to the tea.Program, set after creation.
34+
type progRef struct {
35+
mu sync.Mutex
36+
p *tea.Program
37+
}
38+
39+
func (r *progRef) Set(p *tea.Program) { r.mu.Lock(); r.p = p; r.mu.Unlock() }
40+
func (r *progRef) Send(msg tea.Msg) { r.mu.Lock(); p := r.p; r.mu.Unlock(); if p != nil { p.Send(msg) } }
41+
42+
type displayMsg struct {
2643
role string
2744
content string
2845
}
2946

30-
type errMsg struct{ err error }
31-
3247
type chatModel struct {
3348
input textarea.Model
3449
spinner spinner.Model
35-
messages []chatMsg
50+
session *engine.Session
51+
ref *progRef
52+
messages []displayMsg
53+
partial strings.Builder
3654
waiting bool
3755
width int
3856
height int
39-
err error
4057
quitting bool
4158
}
4259

43-
func newChatModel() chatModel {
60+
func newChatModel(ref *progRef) chatModel {
4461
ta := textarea.New()
4562
ta.Placeholder = "Message hawk..."
4663
ta.Focus()
@@ -53,7 +70,9 @@ func newChatModel() chatModel {
5370
sp.Spinner = spinner.Dot
5471
sp.Style = lipgloss.NewStyle().Foreground(tealColor)
5572

56-
return chatModel{input: ta, spinner: sp}
73+
sess := engine.NewSession(provider, model, prompt.System())
74+
75+
return chatModel{input: ta, spinner: sp, session: sess, ref: ref}
5776
}
5877

5978
func (m chatModel) Init() tea.Cmd {
@@ -65,6 +84,13 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
6584

6685
switch msg := msg.(type) {
6786
case tea.KeyMsg:
87+
if m.waiting {
88+
if msg.Type == tea.KeyCtrlC {
89+
m.quitting = true
90+
return m, tea.Quit
91+
}
92+
return m, nil
93+
}
6894
switch msg.Type {
6995
case tea.KeyCtrlC:
7096
m.quitting = true
@@ -78,51 +104,91 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78104
m.quitting = true
79105
return m, tea.Quit
80106
}
81-
m.messages = append(m.messages, chatMsg{role: "user", content: text})
107+
m.messages = append(m.messages, displayMsg{role: "user", content: text})
108+
m.session.AddUser(text)
82109
m.input.Reset()
83110
m.waiting = true
84-
// TODO: Phase 1 — send to eyrie LLM here
85-
m.messages = append(m.messages, chatMsg{role: "assistant", content: "🚧 LLM not wired yet — eyrie integration coming in Phase 1."})
86-
m.waiting = false
111+
m.partial.Reset()
112+
m.startStream()
87113
return m, nil
88114
}
89115

116+
case streamChunkMsg:
117+
m.partial.WriteString(string(msg))
118+
return m, nil
119+
120+
case streamDoneMsg:
121+
content := m.partial.String()
122+
if content != "" {
123+
m.messages = append(m.messages, displayMsg{role: "assistant", content: content})
124+
m.session.AddAssistant(content)
125+
}
126+
m.partial.Reset()
127+
m.waiting = false
128+
m.input.Focus()
129+
return m, nil
130+
131+
case streamErrMsg:
132+
m.messages = append(m.messages, displayMsg{role: "error", content: msg.err.Error()})
133+
m.partial.Reset()
134+
m.waiting = false
135+
m.input.Focus()
136+
return m, nil
137+
90138
case tea.WindowSizeMsg:
91139
m.width = msg.Width
92140
m.height = msg.Height
93141
m.input.SetWidth(msg.Width - 2)
94142

95-
case errMsg:
96-
m.err = msg.err
97-
m.waiting = false
98-
return m, nil
99-
100143
case spinner.TickMsg:
101144
var cmd tea.Cmd
102145
m.spinner, cmd = m.spinner.Update(msg)
103146
cmds = append(cmds, cmd)
104147
}
105148

106-
var cmd tea.Cmd
107-
m.input, cmd = m.input.Update(msg)
108-
cmds = append(cmds, cmd)
149+
if !m.waiting {
150+
var cmd tea.Cmd
151+
m.input, cmd = m.input.Update(msg)
152+
cmds = append(cmds, cmd)
153+
}
109154

110155
return m, tea.Batch(cmds...)
111156
}
112157

158+
func (m *chatModel) startStream() {
159+
sess := m.session
160+
ref := m.ref
161+
go func() {
162+
ch, err := sess.Stream(context.Background())
163+
if err != nil {
164+
ref.Send(streamErrMsg{err: err})
165+
return
166+
}
167+
for ev := range ch {
168+
switch ev.Type {
169+
case "content":
170+
ref.Send(streamChunkMsg(ev.Content))
171+
case "error":
172+
ref.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)})
173+
return
174+
}
175+
}
176+
ref.Send(streamDoneMsg{})
177+
}()
178+
}
179+
113180
func (m chatModel) View() string {
114181
if m.quitting {
115182
return dimStyle.Render("Goodbye.") + "\n"
116183
}
117184

118185
var b strings.Builder
119186

120-
// Header
121187
b.WriteString(headerStyle.Render(fmt.Sprintf("🦅 hawk v%s", version)))
188+
b.WriteString(dimStyle.Render(fmt.Sprintf(" %s/%s", m.session.Provider(), m.session.Model())))
122189
b.WriteString(dimStyle.Render(" (ctrl+c to quit)"))
123190
b.WriteString("\n\n")
124191

125-
// Messages
126192
for _, msg := range m.messages {
127193
switch msg.role {
128194
case "user":
@@ -138,25 +204,59 @@ func (m chatModel) View() string {
138204
b.WriteString("\n\n")
139205
}
140206

141-
// Spinner
142207
if m.waiting {
143-
b.WriteString(m.spinner.View() + " Thinking...\n\n")
208+
partial := m.partial.String()
209+
if partial != "" {
210+
b.WriteString(assistStyle.Render("hawk: "))
211+
b.WriteString(partial)
212+
b.WriteString("\n\n")
213+
} else {
214+
b.WriteString(m.spinner.View() + " Thinking...\n\n")
215+
}
144216
}
145217

146-
// Error
147-
if m.err != nil {
148-
b.WriteString(errorStyle.Render("Error: "+m.err.Error()) + "\n\n")
218+
if !m.waiting {
219+
b.WriteString(m.input.View())
220+
b.WriteString("\n")
149221
}
150222

151-
// Input
152-
b.WriteString(m.input.View())
153-
b.WriteString("\n")
154-
155223
return b.String()
156224
}
157225

158226
func runChat() error {
159-
p := tea.NewProgram(newChatModel(), tea.WithAltScreen())
227+
ref := &progRef{}
228+
m := newChatModel(ref)
229+
230+
if promptFlag != "" {
231+
m.messages = append(m.messages, displayMsg{role: "user", content: promptFlag})
232+
m.session.AddUser(promptFlag)
233+
m.waiting = true
234+
}
235+
236+
p := tea.NewProgram(m, tea.WithAltScreen())
237+
ref.Set(p)
238+
239+
if promptFlag != "" {
240+
sess := m.session
241+
go func() {
242+
ch, err := sess.Stream(context.Background())
243+
if err != nil {
244+
p.Send(streamErrMsg{err: err})
245+
return
246+
}
247+
for ev := range ch {
248+
switch ev.Type {
249+
case "content":
250+
p.Send(streamChunkMsg(ev.Content))
251+
case "error":
252+
p.Send(streamErrMsg{err: fmt.Errorf("%s", ev.Content)})
253+
return
254+
}
255+
}
256+
p.Send(streamDoneMsg{})
257+
}()
258+
}
259+
160260
_, err := p.Run()
161261
return err
162262
}

‎cmd/root.go‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ import (
55
)
66

77
var (
8-
version string
9-
model string
10-
prompt string
8+
version string
9+
model string
10+
provider string
11+
promptFlag string
1112
)
1213

1314
// SetVersion sets the version string from main.
@@ -24,7 +25,8 @@ var rootCmd = &cobra.Command{
2425

2526
func init() {
2627
rootCmd.Flags().StringVarP(&model, "model", "m", "", "model to use (e.g. claude-sonnet-4-20250514)")
27-
rootCmd.Flags().StringVarP(&prompt, "prompt", "p", "", "send a single prompt and exit")
28+
rootCmd.Flags().StringVarP(&promptFlag, "prompt", "p", "", "send a single prompt and exit")
29+
rootCmd.Flags().StringVar(&provider, "provider", "", "LLM provider (anthropic, openai, gemini, etc.)")
2830
rootCmd.AddCommand(versionCmd)
2931
}
3032

0 commit comments

Comments
 (0)