From 02da40e6b9da62a4103c1f990444452634b3fe33 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 15:23:28 +0200 Subject: [PATCH] fix: TUI spacebar + improved design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch to bubbles textinput for proper keyboard handling (space, cursor, backspace, clipboard all work correctly). Improved design: - ❯ user prompt, ◆ assistant prefix, ✗ error prefix - Word wrapping for long responses - Separator line between chat and input - Streaming indicator (● streaming) in status bar - Better color scheme (lighter purples/blues) - Welcome message with usage hints --- go.mod | 9 +- go.sum | 24 ++-- internal/tui/app.go | 281 ++++++++++++++++++++++++------------------ internal/tui/theme.go | 65 +++++----- 4 files changed, 216 insertions(+), 163 deletions(-) diff --git a/go.mod b/go.mod index 867b295..a254fd5 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,23 @@ module somegit.dev/Owlibou/gnoma go 1.26.1 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 github.com/BurntSushi/toml v0.3.1 github.com/VikingOwl91/mistral-go-sdk v1.2.1 github.com/anthropics/anthropic-sdk-go v1.29.0 github.com/openai/openai-go v1.12.0 golang.org/x/text v0.27.0 google.golang.org/genai v1.52.1 + mvdan.cc/sh/v3 v3.13.0 ) 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/atotto/clipboard v0.1.4 // 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 @@ -48,5 +50,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.34.2 // indirect - mvdan.cc/sh/v3 v3.13.0 // indirect ) diff --git a/go.sum b/go.sum index ffb700c..ba4e36a 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,10 @@ github.com/VikingOwl91/mistral-go-sdk v1.2.1 h1:6OQMtOzJUFcvFUEtbX9VlglUPBn+dKOr github.com/VikingOwl91/mistral-go-sdk v1.2.1/go.mod h1:f4emNtHUx2zSqY3V0LBz6lNI1jE6q/zh+SEU+/hJ0i4= 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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= 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= @@ -24,6 +28,8 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRb 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/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 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= @@ -45,6 +51,8 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -65,8 +73,6 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= @@ -76,6 +82,10 @@ 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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -89,6 +99,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= @@ -116,6 +128,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -131,18 +145,12 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -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= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/tui/app.go b/internal/tui/app.go index d053d4e..52e6a1a 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -5,19 +5,18 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "charm.land/bubbles/v2/textinput" "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 -} +type streamEventMsg struct{ event stream.Event } +type turnDoneMsg struct{ err error } -// turnDoneMsg signals that a turn is complete. -type turnDoneMsg struct { - err error +type chatMessage struct { + role string // "user", "assistant", "tool", "error" + content string } // Model is the Bubble Tea application model. @@ -26,48 +25,62 @@ type Model struct { width int height int - // Chat history - messages []chatMessage - // Current streaming response - streaming bool - streamBuf strings.Builder - currentRole string + messages []chatMessage + streaming bool + streamBuf strings.Builder + currentRole string - // Input - input string - inputCursor int - - // Status - ready bool + input textinput.Model 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 { + ti := textinput.New() + ti.Placeholder = "Type a message... (Enter to send, Ctrl+C to quit)" + ti.Prompt = "❯ " + ti.Focus() + ti.SetWidth(80) + return Model{ session: sess, - ready: true, + input: ti, } } func (m Model) Init() tea.Cmd { - return nil + return m.input.Focus() } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.input.SetWidth(m.width - 6) return m, nil case tea.KeyMsg: - return m.handleKey(msg) + switch msg.String() { + case "ctrl+c": + if m.streaming { + m.session.Cancel() + return m, nil + } + return m, tea.Quit + + case "enter": + if m.streaming { + return m, nil + } + input := strings.TrimSpace(m.input.Value()) + if input == "" { + return m, nil + } + m.input.SetValue("") + return m.submitInput(input) + } case streamEventMsg: return m.handleStreamEvent(msg.event) @@ -76,91 +89,62 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streaming = false if m.streamBuf.Len() > 0 { m.messages = append(m.messages, chatMessage{ - role: m.currentRole, - content: m.streamBuf.String(), + 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(), + role: "error", content: msg.err.Error(), }) } return m, nil } - return m, nil + // Forward to textinput + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } -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 +func (m Model) submitInput(input string) (tea.Model, tea.Cmd) { + // 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": + case cmd == "/quit" || cmd == "/exit" || cmd == "/q": 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)"}) + m.messages = append(m.messages, chatMessage{ + role: "tool", content: " incognito mode toggled (wiring pending)", + }) return m, nil default: - m.messages = append(m.messages, chatMessage{role: "error", content: fmt.Sprintf("unknown command: %s", cmd)}) + m.messages = append(m.messages, chatMessage{ + role: "error", content: fmt.Sprintf("unknown command: %s", cmd), + }) return m, nil } } @@ -172,18 +156,17 @@ func (m Model) handleStreamEvent(evt stream.Event) (tea.Model, tea.Cmd) { 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.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), + role: "tool", content: fmt.Sprintf(" [%s] executing...", evt.ToolCallName), }) } return m, m.listenForEvents() @@ -194,7 +177,6 @@ func (m Model) listenForEvents() tea.Cmd { return func() tea.Msg { evt, ok := <-ch if !ok { - // Channel closed — turn is done _, err := m.session.TurnResult() return turnDoneMsg{err: err} } @@ -204,19 +186,25 @@ func (m Model) listenForEvents() tea.Cmd { func (m Model) View() tea.View { if m.width == 0 { - return tea.NewView("loading...") + return tea.NewView("") } - // Layout: chat area + input + status bar - statusHeight := 1 - inputHeight := 3 - chatHeight := m.height - statusHeight - inputHeight + statusH := 1 + inputH := 1 + separatorH := 1 + chatH := m.height - statusH - inputH - separatorH - 1 - chat := m.renderChat(chatHeight) + chat := m.renderChat(chatH) + separator := styleSeperator.Width(m.width).Render(strings.Repeat("─", m.width)) input := m.renderInput() status := m.renderStatus() - return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, chat, input, status)) + return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, + chat, + separator, + input, + status, + )) } func (m Model) renderChat(height int) string { @@ -225,68 +213,119 @@ func (m Model) renderChat(height int) string { for _, msg := range m.messages { switch msg.role { case "user": - lines = append(lines, styleUserLabel.Render("you: ")+msg.content) + lines = append(lines, styleUserLabel.Render(" ❯ ")+styleUserText.Render(msg.content)) case "assistant": - lines = append(lines, styleAssistantLabel.Render("gnoma: ")+msg.content) + wrapped := wrapText(msg.content, m.width-6) + for i, line := range strings.Split(wrapped, "\n") { + if i == 0 { + lines = append(lines, styleAssistantLabel.Render(" ◆ ")+line) + } else { + lines = append(lines, " "+line) + } + } case "tool": - lines = append(lines, styleToolOutput.Render(" "+msg.content)) + lines = append(lines, styleToolOutput.Render(msg.content)) case "error": - lines = append(lines, styleError.Render("error: "+msg.content)) + lines = append(lines, styleError.Render(" ✗ "+msg.content)) } + lines = append(lines, "") // blank line between messages } - // Show streaming buffer + // Streaming buffer if m.streaming && m.streamBuf.Len() > 0 { - lines = append(lines, styleAssistantLabel.Render("gnoma: ")+m.streamBuf.String()+"▊") + wrapped := wrapText(m.streamBuf.String(), m.width-6) + first := true + for _, line := range strings.Split(wrapped, "\n") { + if first { + lines = append(lines, styleAssistantLabel.Render(" ◆ ")+line) + first = false + } else { + lines = append(lines, " "+line) + } + } + lines = append(lines, styleCursor.Render(" ▊")) } else if m.streaming { - lines = append(lines, styleAssistantLabel.Render("gnoma: ")+"▊") + lines = append(lines, styleAssistantLabel.Render(" ◆ ")+styleCursor.Render("▊")) } + // Empty state if len(lines) == 0 { - lines = append(lines, styleHint.Render(" Type a message and press Enter. /quit to exit.")) + lines = append(lines, "") + lines = append(lines, styleHint.Render(" gnoma — provider-agnostic coding assistant")) + lines = append(lines, "") + lines = append(lines, styleHint.Render(" Type a message and press Enter.")) + lines = append(lines, styleHint.Render(" /quit to exit, /clear to reset, Ctrl+C to cancel.")) } - 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:] + // Scroll to bottom + allLines := strings.Split(strings.Join(lines, "\n"), "\n") + if len(allLines) > height { + allLines = allLines[len(allLines)-height:] } return lipgloss.NewStyle(). Width(m.width). Height(height). - Render(strings.Join(contentLines, "\n")) + Render(strings.Join(allLines, "\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) + return " " + m.input.View() } 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), + left := styleStatusProvider.Render( + fmt.Sprintf(" %s/%s", status.Provider, status.Model), + ) + + right := fmt.Sprintf("tokens: %d │ turns: %d ", status.TokensUsed, status.TurnCount) + + if m.streaming { + right = styleStatusStreaming.Render("● streaming ") + "│ " + right } - if status.State == session.StateStreaming { - parts = append(parts, "streaming...") + // Pad middle + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 0 { + gap = 0 } + middle := strings.Repeat(" ", gap) - return styleStatusBar. - Width(m.width). - Render(strings.Join(parts, " │ ")) + return styleStatusBar.Width(m.width).Render(left + middle + right) +} + +func wrapText(text string, width int) string { + if width <= 0 { + return text + } + var result strings.Builder + for _, line := range strings.Split(text, "\n") { + if len(line) <= width { + if result.Len() > 0 { + result.WriteByte('\n') + } + result.WriteString(line) + continue + } + // Simple word wrap + words := strings.Fields(line) + lineLen := 0 + for _, word := range words { + if lineLen+len(word)+1 > width && lineLen > 0 { + result.WriteByte('\n') + lineLen = 0 + } else if lineLen > 0 { + result.WriteByte(' ') + lineLen++ + } + result.WriteString(word) + lineLen += len(word) + } + if result.Len() > 0 && !strings.HasSuffix(result.String(), "\n") { + // don't add extra newline + } + } + return result.String() } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 1b54235..4f0f976 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -4,50 +4,55 @@ 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 + colorPrimary = lipgloss.Color("#A78BFA") // light purple + colorUser = lipgloss.Color("#60A5FA") // light blue + colorAssistant = lipgloss.Color("#A78BFA") // light purple + colorTool = lipgloss.Color("#34D399") // green + colorError = lipgloss.Color("#F87171") // red + colorMuted = lipgloss.Color("#6B7280") // gray + colorStreaming = lipgloss.Color("#FBBF24") // amber + colorStatusBg = lipgloss.Color("#1E1E2E") // dark bg - // Styles + // Chat styles styleUserLabel = lipgloss.NewStyle(). Foreground(colorUser). Bold(true) + styleUserText = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#E5E7EB")) + 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) + Foreground(colorTool). + Italic(true) styleError = lipgloss.NewStyle(). Foreground(colorError) styleHint = lipgloss.NewStyle(). - Foreground(colorMuted). - Italic(true) + Foreground(colorMuted) - styleInputBorder = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorPrimary). - Padding(0, 1) + styleCursor = lipgloss.NewStyle(). + Foreground(colorStreaming) + + styleSeperator = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#374151")) + + // Status bar + styleStatusBar = lipgloss.NewStyle(). + Background(colorStatusBg). + Foreground(lipgloss.Color("#9CA3AF")) + + styleStatusProvider = lipgloss.NewStyle(). + Background(colorStatusBg). + Foreground(colorPrimary). + Bold(true) + + styleStatusStreaming = lipgloss.NewStyle(). + Background(colorStatusBg). + Foreground(colorStreaming). + Bold(true) )