@@ -1,16 +1,21 @@
1package chat
1package chat
9+ "github.com/charmbracelet/bubbles/spinner"
9 "github.com/charmbracelet/bubbles/viewport"
10 "github.com/charmbracelet/bubbles/viewport"
10 tea "github.com/charmbracelet/bubbletea"
11 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/glamour"
12 "github.com/charmbracelet/lipgloss"
13 "github.com/charmbracelet/lipgloss"
14+ "github.com/charmbracelet/x/ansi"
13 "github.com/kujtimiihoxha/termai/internal/app"
15 "github.com/kujtimiihoxha/termai/internal/app"
16+ "github.com/kujtimiihoxha/termai/internal/llm/agent"
17+ "github.com/kujtimiihoxha/termai/internal/llm/models"
18+ "github.com/kujtimiihoxha/termai/internal/llm/tools"
14 "github.com/kujtimiihoxha/termai/internal/message"
19 "github.com/kujtimiihoxha/termai/internal/message"
15 "github.com/kujtimiihoxha/termai/internal/pubsub"
20 "github.com/kujtimiihoxha/termai/internal/pubsub"
16 "github.com/kujtimiihoxha/termai/internal/session"
21 "github.com/kujtimiihoxha/termai/internal/session"
@@ -18,10 +23,20 @@ import (
18 "github.com/kujtimiihoxha/termai/internal/tui/util"
23 "github.com/kujtimiihoxha/termai/internal/tui/util"
26+type uiMessageType int
29+ userMessageType uiMessageType = iota
21type uiMessage struct {
34type uiMessage struct {
36+ messageType uiMessageType
22− position int
37+ position int
23− height int
38+ height int
24− content string
39+ content string
27type messagesCmp struct {
42type messagesCmp struct {
@@ -32,141 +47,116 @@ type messagesCmp struct {
32 session session.Session
47 session session.Session
33 messages []message.Message
48 messages []message.Message
34 uiMessages []uiMessage
49 uiMessages []uiMessage
35− currentIndex int
50+ currentMsgID string
36 renderer *glamour.TermRenderer
51 renderer *glamour.TermRenderer
37 focusRenderer *glamour.TermRenderer
52 focusRenderer *glamour.TermRenderer
38 cachedContent map[string]string
53 cachedContent map[string]string
55+ spinner spinner.Model
41func (m *messagesCmp) Init() tea.Cmd {
60func (m *messagesCmp) Init() tea.Cmd {
42− return m.viewport.Init()
61+ return tea.Batch(m.viewport.Init())
45−var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
47−func hexToBgSGR(hex string) (string, error) {
48− hex = strings.TrimPrefix(hex, "#")
50− return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits")
53− // Parse RGB components in one block
54− rgb := make([]uint64, 3)
55− for i := 0; i < 3; i++ {
56− val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8)
63− return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil
66−func forceReplaceBackgroundColors(input string, newBg string) string {
67− return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
68− // Extract content between "\x1b[" and "m"
69− content := seq[2 : len(seq)-1]
70− tokens := strings.Split(content, ";")
71− var newTokens []string
73− // Skip background color tokens
74− for i := 0; i < len(tokens); i++ {
79− val, err := strconv.Atoi(tokens[i])
81− newTokens = append(newTokens, tokens[i])
85− // Skip background color tokens
87− // Skip "48;5;N" or "48;2;R;G;B" sequences
88− if i+1 < len(tokens) {
89− if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil {
92− i += 2 // Skip "5" and color index
94− i += 4 // Skip "2" and RGB components
98− } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 {
99− // Keep non-background tokens
100− newTokens = append(newTokens, tokens[i])
104− // Add new background if provided
106− newTokens = append(newTokens, strings.Split(newBg, ";")...)
109− if len(newTokens) == 0 {
113− return "\x1b[" + strings.Join(newTokens, ";") + "m"
117func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
64func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 switch msg := msg.(type) {
66 switch msg := msg.(type) {
67+ case AgentWorkingMsg:
68+ m.agentWorking = bool(msg)
70+ cmds = append(cmds, m.spinner.Tick)
119 case EditorFocusMsg:
72 case EditorFocusMsg:
120 m.writingMode = bool(msg)
73 m.writingMode = bool(msg)
121 case SessionSelectedMsg:
74 case SessionSelectedMsg:
122 if msg.ID != m.session.ID {
75 if msg.ID != m.session.ID {
123 cmd := m.SetSession(msg)
76 cmd := m.SetSession(msg)
77+ m.needsRerender = true
124 return m, cmd
78 return m, cmd
126 return m, nil
80 return m, nil
81+ case SessionClearedMsg:
82+ m.session = session.Session{}
83+ m.messages = make([]message.Message, 0)
85+ m.needsRerender = true
127 case pubsub.Event[message.Message]:
92 case pubsub.Event[message.Message]:
128 if msg.Type == pubsub.CreatedEvent {
93 if msg.Type == pubsub.CreatedEvent {
129 if msg.Payload.SessionID == m.session.ID {
94 if msg.Payload.SessionID == m.session.ID {
130 // check if message exists
95 // check if message exists
97+ messageExists := false
131 for _, v := range m.messages {
98 for _, v := range m.messages {
132 if v.ID == msg.Payload.ID {
99 if v.ID == msg.Payload.ID {
133− return m, nil
100+ messageExists = true
137− m.messages = append(m.messages, msg.Payload)
106+ m.messages = append(m.messages, msg.Payload)
138− m.renderView()
107+ delete(m.cachedContent, m.currentMsgID)
139− m.viewport.GotoBottom()
108+ m.currentMsgID = msg.Payload.ID
109+ m.needsRerender = true
141 for _, v := range m.messages {
112 for _, v := range m.messages {
142 for _, c := range v.ToolCalls() {
113 for _, c := range v.ToolCalls() {
143 // the message is being added to the session of a tool called
114 // the message is being added to the session of a tool called
144 if c.ID == msg.Payload.SessionID {
115 if c.ID == msg.Payload.SessionID {
145− m.renderView()
116+ m.needsRerender = true
146− m.viewport.GotoBottom()
150 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
120 } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
151 for i, v := range m.messages {
121 for i, v := range m.messages {
152 if v.ID == msg.Payload.ID {
122 if v.ID == msg.Payload.ID {
123+ if !m.messages[i].IsFinished() && msg.Payload.IsFinished() && msg.Payload.FinishReason() == "end_turn" || msg.Payload.FinishReason() == "canceled" {
124+ cmds = append(cmds, util.CmdHandler(AgentWorkingMsg(false)))
153 m.messages[i] = msg.Payload
126 m.messages[i] = msg.Payload
154 delete(m.cachedContent, msg.Payload.ID)
127 delete(m.cachedContent, msg.Payload.ID)
155− m.renderView()
128+ m.needsRerender = true
156− if i == len(m.messages)-1 {
157− m.viewport.GotoBottom()
135+ u, cmd := m.spinner.Update(msg)
137+ cmds = append(cmds, cmd)
139+ oldPos := m.viewport.YPosition
164 u, cmd := m.viewport.Update(msg)
140 u, cmd := m.viewport.Update(msg)
165 m.viewport = u
141 m.viewport = u
166− return m, cmd
142+ m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
143+ cmds = append(cmds, cmd)
144+ if m.needsRerender {
146+ if len(m.messages) > 0 {
147+ if msg, ok := msg.(pubsub.Event[message.Message]); ok {
148+ if (msg.Type == pubsub.CreatedEvent) ||
149+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
150+ m.viewport.GotoBottom()
154+ m.needsRerender = false
156+ return m, tea.Batch(cmds...)
169−func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
159+func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
170 if v, ok := m.cachedContent[msg.ID]; ok {
160 if v, ok := m.cachedContent[msg.ID]; ok {
@@ -178,7 +168,7 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
178 BorderStyle(lipgloss.ThickBorder())
168 BorderStyle(lipgloss.ThickBorder())
180 renderer := m.renderer
170 renderer := m.renderer
181− if inx == m.currentIndex {
171+ if msg.ID == m.currentMsgID {
182 style = style.
172 style = style.
183 Foreground(styles.Forground).
173 Foreground(styles.Forground).
184 BorderForeground(styles.Blue).
174 BorderForeground(styles.Blue).
@@ -186,33 +176,269 @@ func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string {
186 renderer = m.focusRenderer
176 renderer = m.focusRenderer
188 c, _ := renderer.Render(msg.Content().String())
178 c, _ := renderer.Render(msg.Content().String())
189− col, _ := hexToBgSGR(styles.Background.Dark)
179+ parts := []string{
180+ styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
182+ // remove newline at the end
183+ parts[0] = strings.TrimSuffix(parts[0], "\n")
185+ parts = append(parts, info...)
190− rendered := style.Render(forceReplaceBackgroundColors(c, col))
187+ rendered := style.Render(
188+ lipgloss.JoinVertical(
191 m.cachedContent[msg.ID] = rendered
193 m.cachedContent[msg.ID] = rendered
192 return rendered
194 return rendered
197+func formatTimeDifference(unixTime1, unixTime2 int64) string {
198+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
200+ if diffSeconds < 60 {
201+ return fmt.Sprintf("%.1fs", diffSeconds)
204+ minutes := int(diffSeconds / 60)
205+ seconds := int(diffSeconds) % 60
206+ return fmt.Sprintf("%dm%ds", minutes, seconds)
209+func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
212+ switch toolCall.Name {
213+ // TODO: add result data to the tools
214+ case agent.AgentToolName:
216+ var params agent.AgentParams
217+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
218+ value = params.Prompt
219+ // TODO: handle nested calls
220+ case tools.BashToolName:
222+ var params tools.BashParams
223+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
224+ value = params.Command
225+ case tools.EditToolName:
227+ var params tools.EditParams
228+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
229+ value = params.FilePath
230+ case tools.FetchToolName:
232+ var params tools.FetchParams
233+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
235+ case tools.GlobToolName:
237+ var params tools.GlobParams
238+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
239+ if params.Path == "" {
242+ value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
243+ case tools.GrepToolName:
245+ var params tools.GrepParams
246+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
247+ if params.Path == "" {
250+ value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
251+ case tools.LSToolName:
253+ var params tools.LSParams
254+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
255+ if params.Path == "" {
259+ case tools.SourcegraphToolName:
261+ var params tools.SourcegraphParams
262+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
263+ value = params.Query
264+ case tools.ViewToolName:
266+ var params tools.ViewParams
267+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
268+ value = params.FilePath
269+ case tools.WriteToolName:
271+ var params tools.WriteParams
272+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
273+ value = params.FilePath
276+ var params map[string]any
277+ json.Unmarshal([]byte(toolCall.Input), ¶ms)
278+ jsonData, _ := json.Marshal(params)
279+ value = string(jsonData)
282+ style := styles.BaseStyle.
285+ BorderStyle(lipgloss.ThickBorder()).
287+ BorderForeground(styles.Yellow)
289+ keyStyle := styles.BaseStyle.
290+ Foreground(styles.ForgroundDim)
291+ valyeStyle := styles.BaseStyle.
292+ Foreground(styles.Forground)
295+ valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
297+ keyValye := keyStyle.Render(
298+ fmt.Sprintf("%s: ", key),
302+ Width(m.width - lipgloss.Width(keyValye) - 2).
306+ m.width-lipgloss.Width(keyValye)-2,
311+ keyValye = keyStyle.Render(
312+ fmt.Sprintf(" └ %s: ", key),
315+ Width(m.width - lipgloss.Width(keyValye) - 2).
319+ m.width-lipgloss.Width(keyValye)-2,
325+ innerToolCalls := make([]string, 0)
326+ if toolCall.Name == agent.AgentToolName {
327+ messages, _ := m.app.Messages.List(toolCall.ID)
328+ toolCalls := make([]message.ToolCall, 0)
329+ for _, v := range messages {
330+ toolCalls = append(toolCalls, v.ToolCalls()...)
332+ for _, v := range toolCalls {
333+ call := m.renderToolCall(v, true)
334+ innerToolCalls = append(innerToolCalls, call)
339+ return lipgloss.JoinHorizontal(
345+ callContent := lipgloss.JoinHorizontal(
350+ callContent = strings.ReplaceAll(callContent, "\n", "")
351+ if len(innerToolCalls) > 0 {
352+ callContent = lipgloss.JoinVertical(
355+ lipgloss.JoinVertical(
361+ return style.Render(callContent)
364+func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
365+ // find the user message that is before this assistant message
366+ var userMsg message.Message
367+ for i := len(m.messages) - 1; i >= 0; i-- {
368+ if m.messages[i].Role == message.User {
369+ userMsg = m.messages[i]
373+ messages := make([]uiMessage, 0)
374+ if msg.Content().String() != "" {
375+ info := make([]string, 0)
376+ if msg.IsFinished() && msg.FinishReason() == "end_turn" {
377+ finish := msg.FinishPart()
378+ took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
380+ info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
381+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
384+ content := m.renderSimpleMessage(msg, info...)
385+ messages = append(messages, uiMessage{
386+ messageType: assistantMessageType,
387+ position: 0, // gets updated in renderView
388+ height: lipgloss.Height(content),
392+ for _, v := range msg.ToolCalls() {
393+ content := m.renderToolCall(v, false)
394+ messages = append(messages,
396+ messageType: toolMessageType,
397+ position: 0, // gets updated in renderView
398+ height: lipgloss.Height(content),
195func (m *messagesCmp) renderView() {
407func (m *messagesCmp) renderView() {
196 m.uiMessages = make([]uiMessage, 0)
408 m.uiMessages = make([]uiMessage, 0)
199 for _, v := range m.messages {
411 for _, v := range m.messages {
201 switch v.Role {
412 switch v.Role {
202 case message.User:
413 case message.User:
203− content = m.renderUserMessage(pos, v)
414+ content := m.renderSimpleMessage(v)
415+ m.uiMessages = append(m.uiMessages, uiMessage{
416+ messageType: userMessageType,
418+ height: lipgloss.Height(content),
421+ pos += lipgloss.Height(content) + 1 // + 1 for spacing
422+ case message.Assistant:
423+ assistantMessages := m.renderAssistantMessage(v)
424+ for _, msg := range assistantMessages {
426+ m.uiMessages = append(m.uiMessages, msg)
427+ pos += msg.height + 1 // + 1 for spacing
205− m.uiMessages = append(m.uiMessages, uiMessage{
207− height: lipgloss.Height(content),
210− pos += lipgloss.Height(content) + 1 // + 1 for spacing
213 messages := make([]string, 0)
433 messages := make([]string, 0)
214 for _, v := range m.uiMessages {
434 for _, v := range m.uiMessages {
215− messages = append(messages, v.content)
435+ messages = append(messages, v.content,
217 m.viewport.SetContent(
443 m.viewport.SetContent(
218 styles.BaseStyle.
444 styles.BaseStyle.
@@ -246,7 +472,6 @@ func (m *messagesCmp) View() string {
250 return styles.BaseStyle.
475 return styles.BaseStyle.
251 Width(m.width).
476 Width(m.width).
@@ -260,15 +485,21 @@ func (m *messagesCmp) View() string {
261func (m *messagesCmp) help() string {
486func (m *messagesCmp) help() string {
262 text := ""
487 text := ""
490+ text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
491+ fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
263 if m.writingMode {
494 if m.writingMode {
264− text = lipgloss.JoinHorizontal(
495+ text += lipgloss.JoinHorizontal(
265 lipgloss.Left,
496 lipgloss.Left,
266 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
497 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
267 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
498 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
268 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
499 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
271− text = lipgloss.JoinHorizontal(
502+ text += lipgloss.JoinHorizontal(
272 lipgloss.Left,
503 lipgloss.Left,
273 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
504 styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
274 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
505 styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
@@ -306,7 +537,15 @@ func (m *messagesCmp) SetSize(width, height int) {
306 glamour.WithWordWrap(width-1),
537 glamour.WithWordWrap(width-1),
308 m.focusRenderer = focusRenderer
539 m.focusRenderer = focusRenderer
540+ // clear the cached content
541+ for k := range m.cachedContent {
542+ delete(m.cachedContent, k)
309 m.renderer = renderer
544 m.renderer = renderer
545+ if len(m.messages) > 0 {
547+ m.viewport.GotoBottom()
312func (m *messagesCmp) GetSize() (int, int) {
551func (m *messagesCmp) GetSize() (int, int) {
@@ -320,7 +559,8 @@ func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
320 return util.ReportError(err)
559 return util.ReportError(err)
322 m.messages = messages
561 m.messages = messages
323− m.messages = append(m.messages, m.messages[0])
562+ m.currentMsgID = m.messages[len(m.messages)-1].ID
563+ m.needsRerender = true
324 return nil
564 return nil
@@ -333,6 +573,9 @@ func NewMessagesCmp(app *app.App) tea.Model {
333 glamour.WithStyles(styles.MarkdownTheme(false)),
573 glamour.WithStyles(styles.MarkdownTheme(false)),
334 glamour.WithWordWrap(80),
574 glamour.WithWordWrap(80),
578+ s.Spinner = spinner.Pulse
336 return &messagesCmp{
579 return &messagesCmp{
337 app: app,
580 app: app,
338 writingMode: true,
581 writingMode: true,
@@ -340,5 +583,6 @@ func NewMessagesCmp(app *app.App) tea.Model {
340 viewport: viewport.New(0, 0),
583 viewport: viewport.New(0, 0),
341 focusRenderer: focusRenderer,
584 focusRenderer: focusRenderer,
342 renderer: renderer,
585 renderer: renderer,