From 84efe1611c9c96390d12fc99ef3cadb1629e1980 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 15:17:56 +0200 Subject: [PATCH] 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. --- cmd/gnoma/main.go | 61 +++++---- go.mod | 18 ++- go.sum | 34 +++++ internal/tui/app.go | 292 ++++++++++++++++++++++++++++++++++++++++++ internal/tui/theme.go | 53 ++++++++ 5 files changed, 433 insertions(+), 25 deletions(-) create mode 100644 internal/tui/app.go create mode 100644 internal/tui/theme.go diff --git a/cmd/gnoma/main.go b/cmd/gnoma/main.go index 888270e..11e0f52 100644 --- a/cmd/gnoma/main.go +++ b/cmd/gnoma/main.go @@ -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) } } diff --git a/go.mod b/go.mod index df75dce..867b295 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6a712e5..ffb700c 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/tui/app.go b/internal/tui/app.go new file mode 100644 index 0000000..d053d4e --- /dev/null +++ b/internal/tui/app.go @@ -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, " │ ")) +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..1b54235 --- /dev/null +++ b/internal/tui/theme.go @@ -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) +)