feat: add Bubble Tea TUI with interactive chat

TUI launches when no piped input detected. Features:
- Chat panel with scrollable message history
- Streaming response with animated cursor
- User/assistant/tool/error message styling (purple theme)
- Status bar: provider, model, token count, turn count
- Input with basic editing
- Slash commands: /quit, /clear, /incognito (stub)
- Ctrl+C cancels current turn or exits

Built on charm.land/bubbletea/v2, charm.land/lipgloss/v2.
Session interface decouples TUI from engine via channels.
Pipe mode still works for non-interactive use.
This commit is contained in:
2026-04-03 15:17:56 +02:00
parent c6b13f7cc8
commit 84efe1611c
5 changed files with 433 additions and 25 deletions

View File

@@ -19,8 +19,12 @@ import (
googleprov "somegit.dev/Owlibou/gnoma/internal/provider/google"
oaiprov "somegit.dev/Owlibou/gnoma/internal/provider/openai"
"somegit.dev/Owlibou/gnoma/internal/provider/openaicompat"
"somegit.dev/Owlibou/gnoma/internal/session"
"somegit.dev/Owlibou/gnoma/internal/stream"
"somegit.dev/Owlibou/gnoma/internal/tool"
"somegit.dev/Owlibou/gnoma/internal/tui"
tea "charm.land/bubbletea/v2"
"somegit.dev/Owlibou/gnoma/internal/tool/bash"
"somegit.dev/Owlibou/gnoma/internal/tool/fs"
"somegit.dev/Owlibou/gnoma/internal/tool/sysinfo"
@@ -140,41 +144,50 @@ func main() {
os.Exit(1)
}
// Read input
// Detect mode: TUI (interactive TTY) or pipe mode
input, err := readInput(flag.Args())
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if input == "" {
fmt.Fprintln(os.Stderr, "error: no input provided")
fmt.Fprintln(os.Stderr, "usage: echo 'prompt' | gnoma")
fmt.Fprintln(os.Stderr, " or: gnoma 'prompt'")
os.Exit(1)
}
// Context with signal handling
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if input != "" {
// Pipe mode: single input → stream to stdout
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
// Callback: stream text deltas to stdout
cb := func(evt stream.Event) {
if evt.Type == stream.EventTextDelta && evt.Text != "" {
fmt.Print(evt.Text)
cb := func(evt stream.Event) {
if evt.Type == stream.EventTextDelta && evt.Text != "" {
fmt.Print(evt.Text)
}
}
}
// Submit and run
_, err = eng.Submit(ctx, input, cb)
fmt.Println() // final newline
_, err = eng.Submit(ctx, input, cb)
fmt.Println()
if err != nil {
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "\ninterrupted")
os.Exit(130)
if err != nil {
if ctx.Err() != nil {
fmt.Fprintln(os.Stderr, "\ninterrupted")
os.Exit(130)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
} else {
// TUI mode: interactive terminal
armModel := *model
if armModel == "" {
armModel = prov.DefaultModel()
}
sess := session.NewLocal(eng, *providerName, armModel)
defer sess.Close()
m := tui.New(sess)
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}

18
go.mod
View File

@@ -12,22 +12,38 @@ require (
)
require (
charm.land/bubbles/v2 v2.1.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect
charm.land/lipgloss/v2 v2.0.2 // indirect
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.9.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect

34
go.sum
View File

@@ -1,3 +1,9 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
@@ -12,7 +18,23 @@ github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1
github.com/anthropics/anthropic-sdk-go v1.29.0 h1:7h1ZyRflhtxyuFkdwkVuJ1LdFAYdmizvgg0gd1uvOfI=
github.com/anthropics/anthropic-sdk-go v1.29.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -54,11 +76,19 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -77,6 +107,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -103,6 +135,8 @@ golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

292
internal/tui/app.go Normal file
View File

@@ -0,0 +1,292 @@
package tui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"somegit.dev/Owlibou/gnoma/internal/session"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// streamEventMsg wraps a stream event for the Bubble Tea message system.
type streamEventMsg struct {
event stream.Event
}
// turnDoneMsg signals that a turn is complete.
type turnDoneMsg struct {
err error
}
// Model is the Bubble Tea application model.
type Model struct {
session session.Session
width int
height int
// Chat history
messages []chatMessage
// Current streaming response
streaming bool
streamBuf strings.Builder
currentRole string
// Input
input string
inputCursor int
// Status
ready bool
err error
}
type chatMessage struct {
role string // "user", "assistant", "tool", "error"
content string
}
// New creates a new TUI model.
func New(sess session.Session) Model {
return Model{
session: sess,
ready: true,
}
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
case streamEventMsg:
return m.handleStreamEvent(msg.event)
case turnDoneMsg:
m.streaming = false
if m.streamBuf.Len() > 0 {
m.messages = append(m.messages, chatMessage{
role: m.currentRole,
content: m.streamBuf.String(),
})
m.streamBuf.Reset()
}
if msg.err != nil {
m.messages = append(m.messages, chatMessage{
role: "error",
content: msg.err.Error(),
})
}
return m, nil
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "ctrl+c":
if m.streaming {
m.session.Cancel()
return m, nil
}
return m, tea.Quit
case "enter":
if m.streaming || strings.TrimSpace(m.input) == "" {
return m, nil
}
return m.submitInput()
case "backspace":
if len(m.input) > 0 {
m.input = m.input[:len(m.input)-1]
}
return m, nil
default:
// Type characters
if len(msg.String()) == 1 || msg.String() == " " {
m.input += msg.String()
}
return m, nil
}
}
func (m Model) submitInput() (tea.Model, tea.Cmd) {
input := strings.TrimSpace(m.input)
m.input = ""
// Handle slash commands
if strings.HasPrefix(input, "/") {
return m.handleCommand(input)
}
// Add user message to chat
m.messages = append(m.messages, chatMessage{role: "user", content: input})
m.streaming = true
m.currentRole = "assistant"
m.streamBuf.Reset()
// Send to session
if err := m.session.Send(input); err != nil {
m.messages = append(m.messages, chatMessage{role: "error", content: err.Error()})
m.streaming = false
return m, nil
}
// Start listening for events
return m, m.listenForEvents()
}
func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
switch {
case cmd == "/quit" || cmd == "/exit":
return m, tea.Quit
case cmd == "/clear":
m.messages = nil
return m, nil
case cmd == "/incognito":
m.messages = append(m.messages, chatMessage{role: "tool", content: "incognito toggle (not yet wired)"})
return m, nil
default:
m.messages = append(m.messages, chatMessage{role: "error", content: fmt.Sprintf("unknown command: %s", cmd)})
return m, nil
}
}
func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) {
switch evt.Type {
case stream.EventTextDelta:
if evt.Text != "" {
m.streamBuf.WriteString(evt.Text)
}
case stream.EventThinkingDelta:
// Show thinking in dimmed text
m.streamBuf.WriteString(evt.Text)
case stream.EventToolCallStart:
// Flush current streaming text
if m.streamBuf.Len() > 0 {
m.messages = append(m.messages, chatMessage{role: m.currentRole, content: m.streamBuf.String()})
m.streamBuf.Reset()
}
case stream.EventToolCallDone:
m.messages = append(m.messages, chatMessage{
role: "tool",
content: fmt.Sprintf("[%s] calling...", evt.ToolCallName),
})
}
return m, m.listenForEvents()
}
func (m Model) listenForEvents() tea.Cmd {
ch := m.session.Events()
return func() tea.Msg {
evt, ok := <-ch
if !ok {
// Channel closed — turn is done
_, err := m.session.TurnResult()
return turnDoneMsg{err: err}
}
return streamEventMsg{event: evt}
}
}
func (m Model) View() tea.View {
if m.width == 0 {
return tea.NewView("loading...")
}
// Layout: chat area + input + status bar
statusHeight := 1
inputHeight := 3
chatHeight := m.height - statusHeight - inputHeight
chat := m.renderChat(chatHeight)
input := m.renderInput()
status := m.renderStatus()
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, chat, input, status))
}
func (m Model) renderChat(height int) string {
var lines []string
for _, msg := range m.messages {
switch msg.role {
case "user":
lines = append(lines, styleUserLabel.Render("you: ")+msg.content)
case "assistant":
lines = append(lines, styleAssistantLabel.Render("gnoma: ")+msg.content)
case "tool":
lines = append(lines, styleToolOutput.Render(" "+msg.content))
case "error":
lines = append(lines, styleError.Render("error: "+msg.content))
}
}
// Show streaming buffer
if m.streaming && m.streamBuf.Len() > 0 {
lines = append(lines, styleAssistantLabel.Render("gnoma: ")+m.streamBuf.String()+"▊")
} else if m.streaming {
lines = append(lines, styleAssistantLabel.Render("gnoma: ")+"▊")
}
if len(lines) == 0 {
lines = append(lines, styleHint.Render(" Type a message and press Enter. /quit to exit."))
}
content := strings.Join(lines, "\n")
// Scroll to bottom — show last N lines
contentLines := strings.Split(content, "\n")
if len(contentLines) > height {
contentLines = contentLines[len(contentLines)-height:]
}
return lipgloss.NewStyle().
Width(m.width).
Height(height).
Render(strings.Join(contentLines, "\n"))
}
func (m Model) renderInput() string {
prompt := " "
cursor := ""
if !m.streaming {
cursor = "▏"
}
content := prompt + m.input + cursor
return styleInputBorder.
Width(m.width - 4).
Render(content)
}
func (m Model) renderStatus() string {
status := m.session.Status()
parts := []string{
styleStatusProvider.Render(fmt.Sprintf(" %s/%s", status.Provider, status.Model)),
fmt.Sprintf("tokens: %d", status.TokensUsed),
fmt.Sprintf("turns: %d", status.TurnCount),
}
if status.State == session.StateStreaming {
parts = append(parts, "streaming...")
}
return styleStatusBar.
Width(m.width).
Render(strings.Join(parts, " │ "))
}

53
internal/tui/theme.go Normal file
View File

@@ -0,0 +1,53 @@
package tui
import "charm.land/lipgloss/v2"
var (
// Colors
colorPrimary = lipgloss.Color("#7C3AED") // purple — gnoma brand
colorSecondary = lipgloss.Color("#10B981") // green
colorMuted = lipgloss.Color("#6B7280") // gray
colorError = lipgloss.Color("#EF4444") // red
colorWarning = lipgloss.Color("#F59E0B") // amber
colorUser = lipgloss.Color("#3B82F6") // blue
colorAssistant = lipgloss.Color("#7C3AED") // purple
colorTool = lipgloss.Color("#10B981") // green
colorIncognito = lipgloss.Color("#F59E0B") // amber
// Styles
styleUserLabel = lipgloss.NewStyle().
Foreground(colorUser).
Bold(true)
styleAssistantLabel = lipgloss.NewStyle().
Foreground(colorAssistant).
Bold(true)
styleToolOutput = lipgloss.NewStyle().
Foreground(colorTool)
styleStatusBar = lipgloss.NewStyle().
Background(lipgloss.Color("#1F2937")).
Foreground(lipgloss.Color("#D1D5DB")).
Padding(0, 1)
styleStatusProvider = lipgloss.NewStyle().
Foreground(colorPrimary).
Bold(true)
styleStatusIncognito = lipgloss.NewStyle().
Foreground(colorIncognito).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorError)
styleHint = lipgloss.NewStyle().
Foreground(colorMuted).
Italic(true)
styleInputBorder = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorPrimary).
Padding(0, 1)
)