2026-02-28 22:47:30 +00:00
|
|
|
// Last modified: 2026-02-28 22:43:28 GMT
|
2026-02-28 22:29:16 +00:00
|
|
|
// Owned by gmgauthier.com
|
|
|
|
|
// Current time: 2023-10-05 14:30:00 UTC
|
|
|
|
|
|
2026-02-28 18:03:12 +00:00
|
|
|
package cmd
|
2026-02-28 18:28:27 +00:00
|
|
|
|
2026-02-28 18:41:20 +00:00
|
|
|
import (
|
2026-02-28 20:17:12 +00:00
|
|
|
"fmt"
|
2026-02-28 19:56:23 +00:00
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
|
2026-02-28 21:53:35 +00:00
|
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
2026-02-28 19:56:23 +00:00
|
|
|
"github.com/fatih/color"
|
2026-02-28 18:41:20 +00:00
|
|
|
"github.com/spf13/cobra"
|
2026-02-28 20:52:03 +00:00
|
|
|
"gmgauthier.com/grokkit/config"
|
2026-02-28 18:41:20 +00:00
|
|
|
"gmgauthier.com/grokkit/internal/grok"
|
|
|
|
|
)
|
2026-02-28 18:28:27 +00:00
|
|
|
|
2026-02-28 20:17:12 +00:00
|
|
|
var chatCmd = &cobra.Command{
|
|
|
|
|
Use: "chat",
|
2026-02-28 21:53:35 +00:00
|
|
|
Short: "Beautiful interactive TUI chat with Grok (full history + streaming)",
|
2026-02-28 20:17:12 +00:00
|
|
|
Run: func(cmd *cobra.Command, args []string) {
|
2026-02-28 20:52:03 +00:00
|
|
|
modelFlag, _ := cmd.Flags().GetString("model")
|
|
|
|
|
model := config.GetModel(modelFlag)
|
|
|
|
|
|
2026-02-28 21:53:35 +00:00
|
|
|
p := tea.NewProgram(initialModel(model), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
|
|
|
|
if _, err := p.Run(); err != nil {
|
|
|
|
|
color.Red("TUI error: %v", err)
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type replyMsg string
|
|
|
|
|
|
|
|
|
|
type model struct {
|
|
|
|
|
viewport viewport.Model
|
|
|
|
|
textarea textarea.Model
|
|
|
|
|
history []string
|
|
|
|
|
client *grok.Client
|
|
|
|
|
model string
|
|
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
quitting bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func initialModel(modelName string) model {
|
|
|
|
|
ta := textarea.New()
|
|
|
|
|
ta.Placeholder = "Type your message... (Enter to send, /quit to exit)"
|
|
|
|
|
ta.Focus()
|
|
|
|
|
ta.SetHeight(3)
|
|
|
|
|
ta.ShowLineNumbers = false
|
|
|
|
|
|
|
|
|
|
vp := viewport.New(0, 0)
|
|
|
|
|
|
|
|
|
|
return model{
|
|
|
|
|
textarea: ta,
|
|
|
|
|
viewport: vp,
|
|
|
|
|
history: []string{},
|
|
|
|
|
client: grok.NewClient(),
|
|
|
|
|
model: modelName,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m model) Init() tea.Cmd {
|
|
|
|
|
return textarea.Blink
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
|
var cmd tea.Cmd
|
|
|
|
|
var cmds []tea.Cmd
|
|
|
|
|
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
case tea.WindowSizeMsg:
|
|
|
|
|
m.width = msg.Width
|
|
|
|
|
m.height = msg.Height
|
|
|
|
|
m.viewport.Width = msg.Width
|
|
|
|
|
m.viewport.Height = msg.Height - m.textarea.Height() - 5
|
|
|
|
|
m.textarea.SetWidth(msg.Width - 4)
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
|
switch msg.String() {
|
|
|
|
|
case "ctrl+c", "esc":
|
|
|
|
|
m.quitting = true
|
|
|
|
|
return m, tea.Quit
|
|
|
|
|
case "enter":
|
|
|
|
|
input := strings.TrimSpace(m.textarea.Value())
|
2026-02-28 20:17:12 +00:00
|
|
|
if input == "" {
|
2026-02-28 21:53:35 +00:00
|
|
|
return m, nil
|
2026-02-28 19:56:23 +00:00
|
|
|
}
|
2026-02-28 20:17:12 +00:00
|
|
|
if input == "/quit" || input == "/q" {
|
2026-02-28 21:53:35 +00:00
|
|
|
m.quitting = true
|
|
|
|
|
return m, tea.Quit
|
2026-02-28 19:56:23 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:53:35 +00:00
|
|
|
m.history = append(m.history, "You > "+input)
|
|
|
|
|
m.viewport.SetContent(buildHistoryView(m.history))
|
|
|
|
|
m.viewport.GotoBottom()
|
|
|
|
|
|
|
|
|
|
m.textarea.SetValue("")
|
|
|
|
|
|
|
|
|
|
historyForAPI := buildHistoryForAPI(m.history)
|
|
|
|
|
cmds = append(cmds, streamReplyCmd(m.client, historyForAPI, m.model))
|
|
|
|
|
|
|
|
|
|
return m, tea.Batch(cmds...)
|
2026-02-28 19:56:23 +00:00
|
|
|
}
|
2026-02-28 21:53:35 +00:00
|
|
|
|
|
|
|
|
case replyMsg:
|
|
|
|
|
m.history = append(m.history, "Grok > "+string(msg))
|
|
|
|
|
m.viewport.SetContent(buildHistoryView(m.history))
|
|
|
|
|
m.viewport.GotoBottom()
|
|
|
|
|
return m, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.textarea, cmd = m.textarea.Update(msg)
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
|
|
|
|
|
|
return m, tea.Batch(cmds...)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildHistoryView applies colors and ensures wrapping
|
|
|
|
|
func buildHistoryView(lines []string) string {
|
|
|
|
|
userStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("33")) // blue
|
|
|
|
|
grokStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // green
|
|
|
|
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
if strings.HasPrefix(line, "You > ") {
|
|
|
|
|
b.WriteString(userStyle.Render(line) + "\n")
|
|
|
|
|
} else if strings.HasPrefix(line, "Grok > ") {
|
|
|
|
|
b.WriteString(grokStyle.Render(line) + "\n")
|
|
|
|
|
} else {
|
|
|
|
|
b.WriteString(line + "\n")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildHistoryForAPI(lines []string) []map[string]string {
|
|
|
|
|
h := []map[string]string{
|
|
|
|
|
{"role": "system", "content": fmt.Sprintf("You are Grok 4, the latest model from xAI (2026). You are currently running as `%s`.", "grok-4")},
|
|
|
|
|
}
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
if strings.HasPrefix(line, "You > ") {
|
|
|
|
|
h = append(h, map[string]string{"role": "user", "content": strings.TrimPrefix(line, "You > ")})
|
|
|
|
|
} else if strings.HasPrefix(line, "Grok > ") {
|
|
|
|
|
h = append(h, map[string]string{"role": "assistant", "content": strings.TrimPrefix(line, "Grok > ")})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func streamReplyCmd(client *grok.Client, history []map[string]string, model string) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
reply := client.StreamSilent(history, model)
|
|
|
|
|
return replyMsg(reply)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m model) View() string {
|
|
|
|
|
if m.quitting {
|
|
|
|
|
return "Goodbye 👋\n"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
header := lipgloss.NewStyle().
|
|
|
|
|
Bold(true).
|
|
|
|
|
Foreground(lipgloss.Color("12")).
|
|
|
|
|
Width(m.width).
|
|
|
|
|
Align(lipgloss.Center).
|
|
|
|
|
Render(fmt.Sprintf(" Grokkit Chat — Model: %s ", m.model))
|
|
|
|
|
|
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
|
|
|
header,
|
|
|
|
|
m.viewport.View(),
|
|
|
|
|
m.textarea.View(),
|
|
|
|
|
)
|
2026-02-28 22:29:16 +00:00
|
|
|
}
|