summaryrefslogtreecommitdiffhomepage
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/components/chat/chat.go119
-rw-r--r--internal/tui/components/chat/editor.go207
-rw-r--r--internal/tui/components/chat/list.go415
-rw-r--r--internal/tui/components/chat/message.go624
-rw-r--r--internal/tui/components/chat/sidebar.go337
-rw-r--r--internal/tui/components/core/button.go287
-rw-r--r--internal/tui/components/core/dialog.go117
-rw-r--r--internal/tui/components/core/help.go119
-rw-r--r--internal/tui/components/core/status.go192
-rw-r--r--internal/tui/components/dialog/commands.go247
-rw-r--r--internal/tui/components/dialog/help.go182
-rw-r--r--internal/tui/components/dialog/init.go191
-rw-r--r--internal/tui/components/dialog/permission.go710
-rw-r--r--internal/tui/components/dialog/quit.go162
-rw-r--r--internal/tui/components/dialog/session.go226
-rw-r--r--internal/tui/components/logs/details.go53
-rw-r--r--internal/tui/components/logs/table.go80
-rw-r--r--internal/tui/components/repl/editor.go201
-rw-r--r--internal/tui/components/repl/messages.go512
-rw-r--r--internal/tui/components/repl/sessions.go247
-rw-r--r--internal/tui/layout/bento.go392
-rw-r--r--internal/tui/layout/border.go121
-rw-r--r--internal/tui/layout/container.go226
-rw-r--r--internal/tui/layout/grid.go254
-rw-r--r--internal/tui/layout/layout.go6
-rw-r--r--internal/tui/layout/overlay.go13
-rw-r--r--internal/tui/layout/single.go189
-rw-r--r--internal/tui/layout/split.go289
-rw-r--r--internal/tui/page/chat.go167
-rw-r--r--internal/tui/page/init.go308
-rw-r--r--internal/tui/page/logs.go83
-rw-r--r--internal/tui/page/repl.go21
-rw-r--r--internal/tui/styles/background.go123
-rw-r--r--internal/tui/styles/icons.go20
-rw-r--r--internal/tui/styles/markdown.go447
-rw-r--r--internal/tui/styles/styles.go49
-rw-r--r--internal/tui/tui.go605
37 files changed, 5051 insertions, 3490 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go
new file mode 100644
index 000000000..b2b5a5c4a
--- /dev/null
+++ b/internal/tui/components/chat/chat.go
@@ -0,0 +1,119 @@
+package chat
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/version"
+)
+
+type SendMsg struct {
+ Text string
+}
+
+type SessionSelectedMsg = session.Session
+
+type SessionClearedMsg struct{}
+
+type EditorFocusMsg bool
+
+func lspsConfigured(width int) string {
+ cfg := config.Get()
+ title := "LSP Configuration"
+ title = ansi.Truncate(title, width, "…")
+
+ lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title)
+
+ // Get LSP names and sort them for consistent ordering
+ var lspNames []string
+ for name := range cfg.LSP {
+ lspNames = append(lspNames, name)
+ }
+ sort.Strings(lspNames)
+
+ var lspViews []string
+ for _, name := range lspNames {
+ lsp := cfg.LSP[name]
+ lspName := styles.BaseStyle.Foreground(styles.Forground).Render(
+ fmt.Sprintf("• %s", name),
+ )
+ cmd := lsp.Command
+ cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
+ lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" (%s)", cmd),
+ )
+ lspViews = append(lspViews,
+ styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ lspName,
+ lspPath,
+ ),
+ ),
+ )
+ }
+ return styles.BaseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lsps,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lspViews...,
+ ),
+ ),
+ )
+}
+
+func logo(width int) string {
+ logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+
+ version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version)
+
+ return styles.BaseStyle.
+ Bold(true).
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ logo,
+ " ",
+ version,
+ ),
+ )
+}
+
+func repo(width int) string {
+ repo := "https://github.com/kujtimiihoxha/opencode"
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(repo)
+}
+
+func cwd(width int) string {
+ cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
+ return styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Width(width).
+ Render(cwd)
+}
+
+func header(width int) string {
+ header := lipgloss.JoinVertical(
+ lipgloss.Top,
+ logo(width),
+ repo(width),
+ "",
+ cwd(width),
+ )
+ return header
+}
diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go
new file mode 100644
index 000000000..4f6937039
--- /dev/null
+++ b/internal/tui/components/chat/editor.go
@@ -0,0 +1,207 @@
+package chat
+
+import (
+ "os"
+ "os/exec"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+type editorCmp struct {
+ app *app.App
+ session session.Session
+ textarea textarea.Model
+}
+
+type FocusEditorMsg bool
+
+type focusedEditorKeyMaps struct {
+ Send key.Binding
+ OpenEditor key.Binding
+ Blur key.Binding
+}
+
+type bluredEditorKeyMaps struct {
+ Send key.Binding
+ Focus key.Binding
+ OpenEditor key.Binding
+}
+
+var focusedKeyMaps = focusedEditorKeyMaps{
+ Send: key.NewBinding(
+ key.WithKeys("ctrl+s"),
+ key.WithHelp("ctrl+s", "send message"),
+ ),
+ Blur: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "focus messages"),
+ ),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+}
+
+var bluredKeyMaps = bluredEditorKeyMaps{
+ Send: key.NewBinding(
+ key.WithKeys("ctrl+s", "enter"),
+ key.WithHelp("ctrl+s/enter", "send message"),
+ ),
+ Focus: key.NewBinding(
+ key.WithKeys("i"),
+ key.WithHelp("i", "focus editor"),
+ ),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+}
+
+func openEditor() tea.Cmd {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "nvim"
+ }
+
+ tmpfile, err := os.CreateTemp("", "msg_*.md")
+ if err != nil {
+ return util.ReportError(err)
+ }
+ tmpfile.Close()
+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return tea.ExecProcess(c, func(err error) tea.Msg {
+ if err != nil {
+ return util.ReportError(err)
+ }
+ content, err := os.ReadFile(tmpfile.Name())
+ if err != nil {
+ return util.ReportError(err)
+ }
+ os.Remove(tmpfile.Name())
+ return SendMsg{
+ Text: string(content),
+ }
+ })
+}
+
+func (m *editorCmp) Init() tea.Cmd {
+ return textarea.Blink
+}
+
+func (m *editorCmp) send() tea.Cmd {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+ return util.ReportWarn("Agent is working, please wait...")
+ }
+
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ m.textarea.Blur()
+ if value == "" {
+ return nil
+ }
+ return tea.Batch(
+ util.CmdHandler(SendMsg{
+ Text: value,
+ }),
+ )
+}
+
+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ switch msg := msg.(type) {
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ }
+ return m, nil
+ case FocusEditorMsg:
+ if msg {
+ m.textarea.Focus()
+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+ }
+ case tea.KeyMsg:
+ if key.Matches(msg, focusedKeyMaps.OpenEditor) {
+ if m.app.CoderAgent.IsSessionBusy(m.session.ID) {
+ return m, util.ReportWarn("Agent is working, please wait...")
+ }
+ return m, openEditor()
+ }
+ // if the key does not match any binding, return
+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Send) {
+ return m, m.send()
+ }
+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Send) {
+ return m, m.send()
+ }
+ if m.textarea.Focused() && key.Matches(msg, focusedKeyMaps.Blur) {
+ m.textarea.Blur()
+ return m, util.CmdHandler(EditorFocusMsg(false))
+ }
+ if !m.textarea.Focused() && key.Matches(msg, bluredKeyMaps.Focus) {
+ m.textarea.Focus()
+ return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true)))
+ }
+ }
+ m.textarea, cmd = m.textarea.Update(msg)
+ return m, cmd
+}
+
+func (m *editorCmp) View() string {
+ style := lipgloss.NewStyle().Padding(0, 0, 0, 1).Bold(true)
+
+ return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+}
+
+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
+ m.textarea.SetWidth(width - 3) // account for the prompt and padding right
+ m.textarea.SetHeight(height)
+ return nil
+}
+
+func (m *editorCmp) GetSize() (int, int) {
+ return m.textarea.Width(), m.textarea.Height()
+}
+
+func (m *editorCmp) BindingKeys() []key.Binding {
+ bindings := []key.Binding{}
+ if m.textarea.Focused() {
+ bindings = append(bindings, layout.KeyMapToSlice(focusedKeyMaps)...)
+ } else {
+ bindings = append(bindings, layout.KeyMapToSlice(bluredKeyMaps)...)
+ }
+
+ bindings = append(bindings, layout.KeyMapToSlice(m.textarea.KeyMap)...)
+ return bindings
+}
+
+func NewEditorCmp(app *app.App) tea.Model {
+ ti := textarea.New()
+ ti.Prompt = " "
+ ti.ShowLineNumbers = false
+ ti.BlurredStyle.Base = ti.BlurredStyle.Base.Background(styles.Background)
+ ti.BlurredStyle.CursorLine = ti.BlurredStyle.CursorLine.Background(styles.Background)
+ ti.BlurredStyle.Placeholder = ti.BlurredStyle.Placeholder.Background(styles.Background)
+ ti.BlurredStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+
+ ti.FocusedStyle.Base = ti.FocusedStyle.Base.Background(styles.Background)
+ ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background)
+ ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background)
+ ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background)
+ ti.CharLimit = -1
+ ti.Focus()
+ return &editorCmp{
+ app: app,
+ textarea: ti,
+ }
+}
diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go
new file mode 100644
index 000000000..03a50541e
--- /dev/null
+++ b/internal/tui/components/chat/list.go
@@ -0,0 +1,415 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+type cacheItem struct {
+ width int
+ content []uiMessage
+}
+type messagesCmp struct {
+ app *app.App
+ width, height int
+ writingMode bool
+ viewport viewport.Model
+ session session.Session
+ messages []message.Message
+ uiMessages []uiMessage
+ currentMsgID string
+ cachedContent map[string]cacheItem
+ spinner spinner.Model
+ rendering bool
+}
+type renderFinishedMsg struct{}
+
+func (m *messagesCmp) Init() tea.Cmd {
+ return tea.Batch(m.viewport.Init(), m.spinner.Tick)
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case EditorFocusMsg:
+ m.writingMode = bool(msg)
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ cmd := m.SetSession(msg)
+ return m, cmd
+ }
+ return m, nil
+ case SessionClearedMsg:
+ m.session = session.Session{}
+ m.messages = make([]message.Message, 0)
+ m.currentMsgID = ""
+ m.rendering = false
+ return m, nil
+
+ case renderFinishedMsg:
+ m.rendering = false
+ m.viewport.GotoBottom()
+ case tea.KeyMsg:
+ if m.writingMode {
+ return m, nil
+ }
+ case pubsub.Event[message.Message]:
+ needsRerender := false
+ if msg.Type == pubsub.CreatedEvent {
+ if msg.Payload.SessionID == m.session.ID {
+
+ messageExists := false
+ for _, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ messageExists = true
+ break
+ }
+ }
+
+ if !messageExists {
+ if len(m.messages) > 0 {
+ lastMsgID := m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, lastMsgID)
+ }
+
+ m.messages = append(m.messages, msg.Payload)
+ delete(m.cachedContent, m.currentMsgID)
+ m.currentMsgID = msg.Payload.ID
+ needsRerender = true
+ }
+ }
+ // There are tool calls from the child task
+ for _, v := range m.messages {
+ for _, c := range v.ToolCalls() {
+ if c.ID == msg.Payload.SessionID {
+ delete(m.cachedContent, v.ID)
+ needsRerender = true
+ }
+ }
+ }
+ } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
+ for i, v := range m.messages {
+ if v.ID == msg.Payload.ID {
+ m.messages[i] = msg.Payload
+ delete(m.cachedContent, msg.Payload.ID)
+ needsRerender = true
+ break
+ }
+ }
+ }
+ if needsRerender {
+ m.renderView()
+ if len(m.messages) > 0 {
+ if (msg.Type == pubsub.CreatedEvent) ||
+ (msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
+ m.viewport.GotoBottom()
+ }
+ }
+ }
+ }
+
+ u, cmd := m.viewport.Update(msg)
+ m.viewport = u
+ cmds = append(cmds, cmd)
+
+ spinner, cmd := m.spinner.Update(msg)
+ m.spinner = spinner
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+}
+
+func (m *messagesCmp) IsAgentWorking() bool {
+ return m.app.CoderAgent.IsSessionBusy(m.session.ID)
+}
+
+func formatTimeDifference(unixTime1, unixTime2 int64) string {
+ diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
+
+ if diffSeconds < 60 {
+ return fmt.Sprintf("%.1fs", diffSeconds)
+ }
+
+ minutes := int(diffSeconds / 60)
+ seconds := int(diffSeconds) % 60
+ return fmt.Sprintf("%dm%ds", minutes, seconds)
+}
+
+func (m *messagesCmp) renderView() {
+ m.uiMessages = make([]uiMessage, 0)
+ pos := 0
+
+ if m.width == 0 {
+ return
+ }
+ for inx, msg := range m.messages {
+ switch msg.Role {
+ case message.User:
+ if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
+ m.uiMessages = append(m.uiMessages, cache.content...)
+ continue
+ }
+ userMsg := renderUserMessage(
+ msg,
+ msg.ID == m.currentMsgID,
+ m.width,
+ pos,
+ )
+ m.uiMessages = append(m.uiMessages, userMsg)
+ m.cachedContent[msg.ID] = cacheItem{
+ width: m.width,
+ content: []uiMessage{userMsg},
+ }
+ pos += userMsg.height + 1 // + 1 for spacing
+ case message.Assistant:
+ if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
+ m.uiMessages = append(m.uiMessages, cache.content...)
+ continue
+ }
+ assistantMessages := renderAssistantMessage(
+ msg,
+ inx,
+ m.messages,
+ m.app.Messages,
+ m.currentMsgID,
+ m.width,
+ pos,
+ )
+ for _, msg := range assistantMessages {
+ m.uiMessages = append(m.uiMessages, msg)
+ pos += msg.height + 1 // + 1 for spacing
+ }
+ m.cachedContent[msg.ID] = cacheItem{
+ width: m.width,
+ content: assistantMessages,
+ }
+ }
+ }
+
+ messages := make([]string, 0)
+ for _, v := range m.uiMessages {
+ messages = append(messages, v.content,
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ "",
+ ),
+ )
+ }
+ m.viewport.SetContent(
+ styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ messages...,
+ ),
+ ),
+ )
+}
+
+func (m *messagesCmp) View() string {
+ if m.rendering {
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ "Loading...",
+ m.working(),
+ m.help(),
+ ),
+ )
+ }
+ if len(m.messages) == 0 {
+ content := styles.BaseStyle.
+ Width(m.width).
+ Height(m.height - 1).
+ Render(
+ m.initialScreen(),
+ )
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ content,
+ "",
+ m.help(),
+ ),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ m.viewport.View(),
+ m.working(),
+ m.help(),
+ ),
+ )
+}
+
+func hasToolsWithoutResponse(messages []message.Message) bool {
+ toolCalls := make([]message.ToolCall, 0)
+ toolResults := make([]message.ToolResult, 0)
+ for _, m := range messages {
+ toolCalls = append(toolCalls, m.ToolCalls()...)
+ toolResults = append(toolResults, m.ToolResults()...)
+ }
+
+ for _, v := range toolCalls {
+ found := false
+ for _, r := range toolResults {
+ if v.ID == r.ToolCallID {
+ found = true
+ break
+ }
+ }
+ if !found && v.Finished {
+ return true
+ }
+ }
+ return false
+}
+
+func hasUnfinishedToolCalls(messages []message.Message) bool {
+ toolCalls := make([]message.ToolCall, 0)
+ for _, m := range messages {
+ toolCalls = append(toolCalls, m.ToolCalls()...)
+ }
+ for _, v := range toolCalls {
+ if !v.Finished {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *messagesCmp) working() string {
+ text := ""
+ if m.IsAgentWorking() && len(m.messages) > 0 {
+ task := "Thinking..."
+ lastMessage := m.messages[len(m.messages)-1]
+ if hasToolsWithoutResponse(m.messages) {
+ task = "Waiting for tool response..."
+ } else if hasUnfinishedToolCalls(m.messages) {
+ task = "Building tool call..."
+ } else if !lastMessage.IsFinished() {
+ task = "Generating..."
+ }
+ if task != "" {
+ text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
+ fmt.Sprintf("%s %s ", m.spinner.View(), task),
+ )
+ }
+ }
+ return text
+}
+
+func (m *messagesCmp) help() string {
+ text := ""
+
+ if m.writingMode {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
+ )
+ } else {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
+ styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
+ )
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(text)
+}
+
+func (m *messagesCmp) initialScreen() string {
+ return styles.BaseStyle.Width(m.width).Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ "",
+ lspsConfigured(m.width),
+ ),
+ )
+}
+
+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+ if m.width == width && m.height == height {
+ return nil
+ }
+ m.width = width
+ m.height = height
+ m.viewport.Width = width
+ m.viewport.Height = height - 2
+ for _, msg := range m.messages {
+ delete(m.cachedContent, msg.ID)
+ }
+ m.uiMessages = make([]uiMessage, 0)
+ m.renderView()
+ return nil
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
+ if m.session.ID == session.ID {
+ return nil
+ }
+ m.session = session
+ messages, err := m.app.Messages.List(context.Background(), session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+ m.messages = messages
+ m.currentMsgID = m.messages[len(m.messages)-1].ID
+ delete(m.cachedContent, m.currentMsgID)
+ m.rendering = true
+ return func() tea.Msg {
+ m.renderView()
+ return renderFinishedMsg{}
+ }
+}
+
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
+ return bindings
+}
+
+func NewMessagesCmp(app *app.App) tea.Model {
+ s := spinner.New()
+ s.Spinner = spinner.Pulse
+ return &messagesCmp{
+ app: app,
+ writingMode: true,
+ cachedContent: make(map[string]cacheItem),
+ viewport: viewport.New(0, 0),
+ spinner: s,
+ }
+}
diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go
new file mode 100644
index 000000000..b8e450079
--- /dev/null
+++ b/internal/tui/components/chat/message.go
@@ -0,0 +1,624 @@
+package chat
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/charmbracelet/glamour"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/llm/agent"
+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
+ "github.com/kujtimiihoxha/opencode/internal/message"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type uiMessageType int
+
+const (
+ userMessageType uiMessageType = iota
+ assistantMessageType
+ toolMessageType
+
+ maxResultHeight = 15
+)
+
+var diffStyle = diff.NewStyleConfig(diff.WithShowHeader(false), diff.WithShowHunkHeader(false))
+
+type uiMessage struct {
+ ID string
+ messageType uiMessageType
+ position int
+ height int
+ content string
+}
+
+type renderCache struct {
+ mutex sync.Mutex
+ cache map[string][]uiMessage
+}
+
+func toMarkdown(content string, focused bool, width int) string {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(false)),
+ glamour.WithWordWrap(width),
+ )
+ if focused {
+ r, _ = glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(width),
+ )
+ }
+ rendered, _ := r.Render(content)
+ return rendered
+}
+
+func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string {
+ style := styles.BaseStyle.
+ Width(width - 1).
+ BorderLeft(true).
+ Foreground(styles.ForgroundDim).
+ BorderForeground(styles.PrimaryColor).
+ BorderStyle(lipgloss.ThickBorder())
+ if isUser {
+ style = style.
+ BorderForeground(styles.Blue)
+ }
+ parts := []string{
+ styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), styles.Background),
+ }
+
+ // remove newline at the end
+ parts[0] = strings.TrimSuffix(parts[0], "\n")
+ if len(info) > 0 {
+ parts = append(parts, info...)
+ }
+ rendered := style.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ ),
+ )
+
+ return rendered
+}
+
+func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage {
+ content := renderMessage(msg.Content().String(), true, isFocused, width)
+ userMsg := uiMessage{
+ ID: msg.ID,
+ messageType: userMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return userMsg
+}
+
+// Returns multiple uiMessages because of the tool calls
+func renderAssistantMessage(
+ msg message.Message,
+ msgIndex int,
+ allMessages []message.Message, // we need this to get tool results and the user message
+ messagesService message.Service, // We need this to get the task tool messages
+ focusedUIMessageId string,
+ width int,
+ position int,
+) []uiMessage {
+ messages := []uiMessage{}
+ content := msg.Content().String()
+ thinking := msg.IsThinking()
+ thinkingContent := msg.ReasoningContent().Thinking
+ finished := msg.IsFinished()
+ finishData := msg.FinishPart()
+ info := []string{}
+
+ // Add finish info if available
+ if finished {
+ switch finishData.Reason {
+ case message.FinishReasonEndTurn:
+ took := formatTimeDifference(msg.CreatedAt, finishData.Time)
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
+ ))
+ case message.FinishReasonCanceled:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "canceled"),
+ ))
+ case message.FinishReasonError:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "error"),
+ ))
+ case message.FinishReasonPermissionDenied:
+ info = append(info, styles.BaseStyle.Width(width-1).Foreground(styles.ForgroundDim).Render(
+ fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, "permission denied"),
+ ))
+ }
+ }
+ if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) {
+ if content == "" {
+ content = "*Finished without output*"
+ }
+
+ content = renderMessage(content, false, msg.ID == focusedUIMessageId, width, info...)
+ messages = append(messages, uiMessage{
+ ID: msg.ID,
+ messageType: assistantMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ })
+ position += messages[0].height
+ position++ // for the space
+ } else if thinking && thinkingContent != "" {
+ // Render the thinking content
+ content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width)
+ }
+
+ for i, toolCall := range msg.ToolCalls() {
+ toolCallContent := renderToolMessage(
+ toolCall,
+ allMessages,
+ messagesService,
+ focusedUIMessageId,
+ false,
+ width,
+ i+1,
+ )
+ messages = append(messages, toolCallContent)
+ position += toolCallContent.height
+ position++ // for the space
+ }
+ return messages
+}
+
+func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult {
+ for _, msg := range futureMessages {
+ for _, result := range msg.ToolResults() {
+ if result.ToolCallID == toolCallID {
+ return &result
+ }
+ }
+ }
+ return nil
+}
+
+func toolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Task"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ case tools.PatchToolName:
+ return "Patch"
+ }
+ return name
+}
+
+func getToolAction(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Preparing prompt..."
+ case tools.BashToolName:
+ return "Building command..."
+ case tools.EditToolName:
+ return "Preparing edit..."
+ case tools.FetchToolName:
+ return "Writing fetch..."
+ case tools.GlobToolName:
+ return "Finding files..."
+ case tools.GrepToolName:
+ return "Searching content..."
+ case tools.LSToolName:
+ return "Listing directory..."
+ case tools.SourcegraphToolName:
+ return "Searching code..."
+ case tools.ViewToolName:
+ return "Reading file..."
+ case tools.WriteToolName:
+ return "Preparing write..."
+ case tools.PatchToolName:
+ return "Preparing patch..."
+ }
+ return "Working..."
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if len(mainParam) > paramsWidth {
+ mainParam = mainParam[:paramsWidth-3] + "..."
+ }
+
+ if len(params) == 1 {
+ return mainParam
+ }
+ otherParams := params[1:]
+ // create pairs of key/value
+ // if odd number of params, the last one is a key without value
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
+ if remainingWidth < 30 {
+ // No space for the params, just show the main
+ return mainParam
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+func removeWorkingDirPrefix(path string) string {
+ wd := config.WorkingDirectory()
+ if strings.HasPrefix(path, wd) {
+ path = strings.TrimPrefix(path, wd)
+ }
+ if strings.HasPrefix(path, "/") {
+ path = strings.TrimPrefix(path, "/")
+ }
+ if strings.HasPrefix(path, "./") {
+ path = strings.TrimPrefix(path, "./")
+ }
+ if strings.HasPrefix(path, "../") {
+ path = strings.TrimPrefix(path, "../")
+ }
+ return path
+}
+
+func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
+ params := ""
+ switch toolCall.Name {
+ case agent.AgentToolName:
+ var params agent.AgentParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
+ return renderParams(paramWidth, prompt)
+ case tools.BashToolName:
+ var params tools.BashParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ command := strings.ReplaceAll(params.Command, "\n", " ")
+ return renderParams(paramWidth, command)
+ case tools.EditToolName:
+ var params tools.EditParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ return renderParams(paramWidth, filePath)
+ case tools.FetchToolName:
+ var params tools.FetchParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ url := params.URL
+ toolParams := []string{
+ url,
+ }
+ if params.Format != "" {
+ toolParams = append(toolParams, "format", params.Format)
+ }
+ if params.Timeout != 0 {
+ toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.GlobToolName:
+ var params tools.GlobParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ pattern := params.Pattern
+ toolParams := []string{
+ pattern,
+ }
+ if params.Path != "" {
+ toolParams = append(toolParams, "path", params.Path)
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.GrepToolName:
+ var params tools.GrepParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ pattern := params.Pattern
+ toolParams := []string{
+ pattern,
+ }
+ if params.Path != "" {
+ toolParams = append(toolParams, "path", params.Path)
+ }
+ if params.Include != "" {
+ toolParams = append(toolParams, "include", params.Include)
+ }
+ if params.LiteralText {
+ toolParams = append(toolParams, "literal", "true")
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.LSToolName:
+ var params tools.LSParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ path := params.Path
+ if path == "" {
+ path = "."
+ }
+ return renderParams(paramWidth, path)
+ case tools.SourcegraphToolName:
+ var params tools.SourcegraphParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ return renderParams(paramWidth, params.Query)
+ case tools.ViewToolName:
+ var params tools.ViewParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ toolParams := []string{
+ filePath,
+ }
+ if params.Limit != 0 {
+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+ }
+ if params.Offset != 0 {
+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.WriteToolName:
+ var params tools.WriteParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ return renderParams(paramWidth, filePath)
+ default:
+ input := strings.ReplaceAll(toolCall.Input, "\n", " ")
+ params = renderParams(paramWidth, input)
+ }
+ return params
+}
+
+func truncateHeight(content string, height int) string {
+ lines := strings.Split(content, "\n")
+ if len(lines) > height {
+ return strings.Join(lines[:height], "\n")
+ }
+ return content
+}
+
+func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
+ if response.IsError {
+ errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
+ errContent = ansi.Truncate(errContent, width-1, "...")
+ return styles.BaseStyle.
+ Width(width).
+ Foreground(styles.Error).
+ Render(errContent)
+ }
+ resultContent := truncateHeight(response.Content, maxResultHeight)
+ switch toolCall.Name {
+ case agent.AgentToolName:
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, false, width),
+ styles.Background,
+ )
+ case tools.BashToolName:
+ resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, true, width),
+ styles.Background,
+ )
+ case tools.EditToolName:
+ metadata := tools.EditResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
+ formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(width), diff.WithStyle(diffStyle))
+ return formattedDiff
+ case tools.FetchToolName:
+ var params tools.FetchParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ mdFormat := "markdown"
+ switch params.Format {
+ case "text":
+ mdFormat = "text"
+ case "html":
+ mdFormat = "html"
+ }
+ resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, true, width),
+ styles.Background,
+ )
+ case tools.GlobToolName:
+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
+ case tools.GrepToolName:
+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
+ case tools.LSToolName:
+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
+ case tools.SourcegraphToolName:
+ return styles.BaseStyle.Width(width).Foreground(styles.ForgroundMid).Render(resultContent)
+ case tools.ViewToolName:
+ metadata := tools.ViewResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ ext := filepath.Ext(metadata.FilePath)
+ if ext == "" {
+ ext = ""
+ } else {
+ ext = strings.ToLower(ext[1:])
+ }
+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, true, width),
+ styles.Background,
+ )
+ case tools.WriteToolName:
+ params := tools.WriteParams{}
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ metadata := tools.WriteResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ ext := filepath.Ext(params.FilePath)
+ if ext == "" {
+ ext = ""
+ } else {
+ ext = strings.ToLower(ext[1:])
+ }
+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, true, width),
+ styles.Background,
+ )
+ default:
+ resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, true, width),
+ styles.Background,
+ )
+ }
+}
+
+func renderToolMessage(
+ toolCall message.ToolCall,
+ allMessages []message.Message,
+ messagesService message.Service,
+ focusedUIMessageId string,
+ nested bool,
+ width int,
+ position int,
+) uiMessage {
+ if nested {
+ width = width - 3
+ }
+ style := styles.BaseStyle.
+ Width(width - 1).
+ BorderLeft(true).
+ BorderStyle(lipgloss.ThickBorder()).
+ PaddingLeft(1).
+ BorderForeground(styles.ForgroundDim)
+
+ response := findToolResponse(toolCall.ID, allMessages)
+ toolName := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%s: ", toolName(toolCall.Name)))
+
+ if !toolCall.Finished {
+ // Get a brief description of what the tool is doing
+ toolAction := getToolAction(toolCall.Name)
+
+ // toolInput := strings.ReplaceAll(toolCall.Input, "\n", " ")
+ // truncatedInput := toolInput
+ // if len(truncatedInput) > 10 {
+ // truncatedInput = truncatedInput[len(truncatedInput)-10:]
+ // }
+ //
+ // truncatedInput = styles.BaseStyle.
+ // Italic(true).
+ // Width(width - 2 - lipgloss.Width(toolName)).
+ // Background(styles.BackgroundDim).
+ // Foreground(styles.ForgroundMid).
+ // Render(truncatedInput)
+
+ progressText := styles.BaseStyle.
+ Width(width - 2 - lipgloss.Width(toolName)).
+ Foreground(styles.ForgroundDim).
+ Render(fmt.Sprintf("%s", toolAction))
+
+ content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolName, progressText))
+ toolMsg := uiMessage{
+ messageType: toolMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return toolMsg
+ }
+ params := renderToolParams(width-2-lipgloss.Width(toolName), toolCall)
+ responseContent := ""
+ if response != nil {
+ responseContent = renderToolResponse(toolCall, *response, width-2)
+ responseContent = strings.TrimSuffix(responseContent, "\n")
+ } else {
+ responseContent = styles.BaseStyle.
+ Italic(true).
+ Width(width - 2).
+ Foreground(styles.ForgroundDim).
+ Render("Waiting for response...")
+ }
+
+ parts := []string{}
+ if !nested {
+ params := styles.BaseStyle.
+ Width(width - 2 - lipgloss.Width(toolName)).
+ Foreground(styles.ForgroundDim).
+ Render(params)
+
+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolName, params))
+ } else {
+ prefix := styles.BaseStyle.
+ Foreground(styles.ForgroundDim).
+ Render(" └ ")
+ params := styles.BaseStyle.
+ Width(width - 2 - lipgloss.Width(toolName)).
+ Foreground(styles.ForgroundMid).
+ Render(params)
+ parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolName, params))
+ }
+ if toolCall.Name == agent.AgentToolName {
+ taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
+ toolCalls := []message.ToolCall{}
+ for _, v := range taskMessages {
+ toolCalls = append(toolCalls, v.ToolCalls()...)
+ }
+ for _, call := range toolCalls {
+ rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
+ parts = append(parts, rendered.content)
+ }
+ }
+ if responseContent != "" && !nested {
+ parts = append(parts, responseContent)
+ }
+
+ content := style.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ ),
+ )
+ if nested {
+ content = lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ )
+ }
+ toolMsg := uiMessage{
+ messageType: toolMessageType,
+ position: position,
+ height: lipgloss.Height(content),
+ content: content,
+ }
+ return toolMsg
+}
diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go
new file mode 100644
index 000000000..d330e592b
--- /dev/null
+++ b/internal/tui/components/chat/sidebar.go
@@ -0,0 +1,337 @@
+package chat
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/history"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type sidebarCmp struct {
+ width, height int
+ session session.Session
+ history history.Service
+ modFiles map[string]struct {
+ additions int
+ removals int
+ }
+}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+ if m.history != nil {
+ ctx := context.Background()
+ // Subscribe to file events
+ filesCh := m.history.Subscribe(ctx)
+
+ // Initialize the modified files map
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+
+ // Load initial files and calculate diffs
+ m.loadModifiedFiles(ctx)
+
+ // Return a command that will send file events to the Update method
+ return func() tea.Msg {
+ return <-filesCh
+ }
+ }
+ return nil
+}
+
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ m.session = msg
+ ctx := context.Background()
+ m.loadModifiedFiles(ctx)
+ }
+ case pubsub.Event[session.Session]:
+ if msg.Type == pubsub.UpdatedEvent {
+ if m.session.ID == msg.Payload.ID {
+ m.session = msg.Payload
+ }
+ }
+ case pubsub.Event[history.File]:
+ if msg.Payload.SessionID == m.session.ID {
+ // Process the individual file change instead of reloading all files
+ ctx := context.Background()
+ m.processFileChanges(ctx, msg.Payload)
+
+ // Return a command to continue receiving events
+ return m, func() tea.Msg {
+ ctx := context.Background()
+ filesCh := m.history.Subscribe(ctx)
+ return <-filesCh
+ }
+ }
+ }
+ return m, nil
+}
+
+func (m *sidebarCmp) View() string {
+ return styles.BaseStyle.
+ Width(m.width).
+ PaddingLeft(4).
+ PaddingRight(2).
+ Height(m.height - 1).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.width),
+ " ",
+ m.sessionSection(),
+ " ",
+ lspsConfigured(m.width),
+ " ",
+ m.modifiedFiles(),
+ ),
+ )
+}
+
+func (m *sidebarCmp) sessionSection() string {
+ sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session")
+ sessionValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(m.width - lipgloss.Width(sessionKey)).
+ Render(fmt.Sprintf(": %s", m.session.Title))
+ return lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ sessionKey,
+ sessionValue,
+ )
+}
+
+func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
+ stats := ""
+ if additions > 0 && removals > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals))
+ } else if additions > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions))
+ } else if removals > 0 {
+ stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals))
+ }
+ filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath)
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ filePathStr,
+ stats,
+ ),
+ )
+}
+
+func (m *sidebarCmp) modifiedFiles() string {
+ modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:")
+
+ // If no modified files, show a placeholder message
+ if m.modFiles == nil || len(m.modFiles) == 0 {
+ message := "No modified files"
+ remainingWidth := m.width - lipgloss.Width(message)
+ if remainingWidth > 0 {
+ message += strings.Repeat(" ", remainingWidth)
+ }
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ styles.BaseStyle.Foreground(styles.ForgroundDim).Render(message),
+ ),
+ )
+ }
+
+ // Sort file paths alphabetically for consistent ordering
+ var paths []string
+ for path := range m.modFiles {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+
+ // Create views for each file in sorted order
+ var fileViews []string
+ for _, path := range paths {
+ stats := m.modFiles[path]
+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
+ }
+
+ return styles.BaseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ fileViews...,
+ ),
+ ),
+ )
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+ m.width = width
+ m.height = height
+ return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func NewSidebarCmp(session session.Session, history history.Service) tea.Model {
+ return &sidebarCmp{
+ session: session,
+ history: history,
+ }
+}
+
+func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
+ if m.history == nil || m.session.ID == "" {
+ return
+ }
+
+ // Get all latest files for this session
+ latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Get all files for this session (to find initial versions)
+ allFiles, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return
+ }
+
+ // Clear the existing map to rebuild it
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+
+ // Process each latest file
+ for _, file := range latestFiles {
+ // Skip if this is the initial version (no changes to show)
+ if file.Version == history.InitialVersion {
+ continue
+ }
+
+ // Find the initial version for this specific file
+ var initialVersion history.File
+ for _, v := range allFiles {
+ if v.Path == file.Path && v.Version == history.InitialVersion {
+ initialVersion = v
+ break
+ }
+ }
+
+ // Skip if we can't find the initial version
+ if initialVersion.ID == "" {
+ continue
+ }
+ if initialVersion.Content == file.Content {
+ continue
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ // Remove working directory prefix from file path
+ displayPath := file.Path
+ workingDir := config.WorkingDirectory()
+ displayPath = strings.TrimPrefix(displayPath, workingDir)
+ displayPath = strings.TrimPrefix(displayPath, "/")
+
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
+ }
+ }
+}
+
+func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
+ // Skip if this is the initial version (no changes to show)
+ if file.Version == history.InitialVersion {
+ return
+ }
+
+ // Find the initial version for this file
+ initialVersion, err := m.findInitialVersion(ctx, file.Path)
+ if err != nil || initialVersion.ID == "" {
+ return
+ }
+
+ // Skip if content hasn't changed
+ if initialVersion.Content == file.Content {
+ // If this file was previously modified but now matches the initial version,
+ // remove it from the modified files list
+ displayPath := getDisplayPath(file.Path)
+ delete(m.modFiles, displayPath)
+ return
+ }
+
+ // Calculate diff between initial and latest version
+ _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
+
+ // Only add to modified files if there are changes
+ if additions > 0 || removals > 0 {
+ displayPath := getDisplayPath(file.Path)
+ m.modFiles[displayPath] = struct {
+ additions int
+ removals int
+ }{
+ additions: additions,
+ removals: removals,
+ }
+ } else {
+ // If no changes, remove from modified files
+ displayPath := getDisplayPath(file.Path)
+ delete(m.modFiles, displayPath)
+ }
+}
+
+// Helper function to find the initial version of a file
+func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
+ // Get all versions of this file for the session
+ fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
+ if err != nil {
+ return history.File{}, err
+ }
+
+ // Find the initial version
+ for _, v := range fileVersions {
+ if v.Path == path && v.Version == history.InitialVersion {
+ return v, nil
+ }
+ }
+
+ return history.File{}, fmt.Errorf("initial version not found")
+}
+
+// Helper function to get the display path for a file
+func getDisplayPath(path string) string {
+ workingDir := config.WorkingDirectory()
+ displayPath := strings.TrimPrefix(path, workingDir)
+ return strings.TrimPrefix(displayPath, "/")
+}
diff --git a/internal/tui/components/core/button.go b/internal/tui/components/core/button.go
deleted file mode 100644
index 090fbc1ee..000000000
--- a/internal/tui/components/core/button.go
+++ /dev/null
@@ -1,287 +0,0 @@
-package core
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-// ButtonKeyMap defines key bindings for the button component
-type ButtonKeyMap struct {
- Enter key.Binding
-}
-
-// DefaultButtonKeyMap returns default key bindings for the button
-func DefaultButtonKeyMap() ButtonKeyMap {
- return ButtonKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select"),
- ),
- }
-}
-
-// ShortHelp returns keybinding help
-func (k ButtonKeyMap) ShortHelp() []key.Binding {
- return []key.Binding{k.Enter}
-}
-
-// FullHelp returns full help info for keybindings
-func (k ButtonKeyMap) FullHelp() [][]key.Binding {
- return [][]key.Binding{
- {k.Enter},
- }
-}
-
-// ButtonState represents the state of a button
-type ButtonState int
-
-const (
- // ButtonNormal is the default state
- ButtonNormal ButtonState = iota
- // ButtonHovered is when the button is focused/hovered
- ButtonHovered
- // ButtonPressed is when the button is being pressed
- ButtonPressed
- // ButtonDisabled is when the button is disabled
- ButtonDisabled
-)
-
-// ButtonVariant defines the visual style variant of a button
-type ButtonVariant int
-
-const (
- // ButtonPrimary uses primary color styling
- ButtonPrimary ButtonVariant = iota
- // ButtonSecondary uses secondary color styling
- ButtonSecondary
- // ButtonDanger uses danger/error color styling
- ButtonDanger
- // ButtonWarning uses warning color styling
- ButtonWarning
- // ButtonNeutral uses neutral color styling
- ButtonNeutral
-)
-
-// ButtonMsg is sent when a button is clicked
-type ButtonMsg struct {
- ID string
- Payload any
-}
-
-// ButtonCmp represents a clickable button component
-type ButtonCmp struct {
- id string
- label string
- width int
- height int
- state ButtonState
- variant ButtonVariant
- keyMap ButtonKeyMap
- payload any
- style lipgloss.Style
- hoverStyle lipgloss.Style
-}
-
-// NewButtonCmp creates a new button component
-func NewButtonCmp(id, label string) *ButtonCmp {
- b := &ButtonCmp{
- id: id,
- label: label,
- state: ButtonNormal,
- variant: ButtonPrimary,
- keyMap: DefaultButtonKeyMap(),
- width: len(label) + 4, // add some padding
- height: 1,
- }
- b.updateStyles()
- return b
-}
-
-// WithVariant sets the button variant
-func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp {
- b.variant = variant
- b.updateStyles()
- return b
-}
-
-// WithPayload sets the payload sent with button events
-func (b *ButtonCmp) WithPayload(payload any) *ButtonCmp {
- b.payload = payload
- return b
-}
-
-// WithWidth sets a custom width
-func (b *ButtonCmp) WithWidth(width int) *ButtonCmp {
- b.width = width
- b.updateStyles()
- return b
-}
-
-// updateStyles recalculates styles based on current state and variant
-func (b *ButtonCmp) updateStyles() {
- // Base styles
- b.style = styles.Regular.
- Padding(0, 1).
- Width(b.width).
- Align(lipgloss.Center).
- BorderStyle(lipgloss.RoundedBorder())
-
- b.hoverStyle = b.style.
- Bold(true)
-
- // Variant-specific styling
- switch b.variant {
- case ButtonPrimary:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Primary).
- BorderForeground(styles.Primary)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Blue).
- BorderForeground(styles.Blue)
-
- case ButtonSecondary:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Secondary).
- BorderForeground(styles.Secondary)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Mauve).
- BorderForeground(styles.Mauve)
-
- case ButtonDanger:
- b.style = b.style.
- Foreground(styles.Base).
- Background(styles.Error).
- BorderForeground(styles.Error)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Base).
- Background(styles.Red).
- BorderForeground(styles.Red)
-
- case ButtonWarning:
- b.style = b.style.
- Foreground(styles.Text).
- Background(styles.Warning).
- BorderForeground(styles.Warning)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Text).
- Background(styles.Peach).
- BorderForeground(styles.Peach)
-
- case ButtonNeutral:
- b.style = b.style.
- Foreground(styles.Text).
- Background(styles.Grey).
- BorderForeground(styles.Grey)
-
- b.hoverStyle = b.hoverStyle.
- Foreground(styles.Text).
- Background(styles.DarkGrey).
- BorderForeground(styles.DarkGrey)
- }
-
- // Disabled style override
- if b.state == ButtonDisabled {
- b.style = b.style.
- Foreground(styles.SubText0).
- Background(styles.LightGrey).
- BorderForeground(styles.LightGrey)
- }
-}
-
-// SetSize sets the button size
-func (b *ButtonCmp) SetSize(width, height int) {
- b.width = width
- b.height = height
- b.updateStyles()
-}
-
-// Focus sets the button to focused state
-func (b *ButtonCmp) Focus() tea.Cmd {
- if b.state != ButtonDisabled {
- b.state = ButtonHovered
- }
- return nil
-}
-
-// Blur sets the button to normal state
-func (b *ButtonCmp) Blur() tea.Cmd {
- if b.state != ButtonDisabled {
- b.state = ButtonNormal
- }
- return nil
-}
-
-// Disable sets the button to disabled state
-func (b *ButtonCmp) Disable() {
- b.state = ButtonDisabled
- b.updateStyles()
-}
-
-// Enable enables the button if disabled
-func (b *ButtonCmp) Enable() {
- if b.state == ButtonDisabled {
- b.state = ButtonNormal
- b.updateStyles()
- }
-}
-
-// IsDisabled returns whether the button is disabled
-func (b *ButtonCmp) IsDisabled() bool {
- return b.state == ButtonDisabled
-}
-
-// IsFocused returns whether the button is focused
-func (b *ButtonCmp) IsFocused() bool {
- return b.state == ButtonHovered
-}
-
-// Init initializes the button
-func (b *ButtonCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update handles messages and user input
-func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- // Skip updates if disabled
- if b.state == ButtonDisabled {
- return b, nil
- }
-
- switch msg := msg.(type) {
- case tea.KeyMsg:
- // Handle key presses when focused
- if b.state == ButtonHovered {
- switch {
- case key.Matches(msg, b.keyMap.Enter):
- b.state = ButtonPressed
- return b, func() tea.Msg {
- return ButtonMsg{
- ID: b.id,
- Payload: b.payload,
- }
- }
- }
- }
- }
-
- return b, nil
-}
-
-// View renders the button
-func (b *ButtonCmp) View() string {
- if b.state == ButtonHovered || b.state == ButtonPressed {
- return b.hoverStyle.Render(b.label)
- }
- return b.style.Render(b.label)
-}
-
diff --git a/internal/tui/components/core/dialog.go b/internal/tui/components/core/dialog.go
deleted file mode 100644
index a8fef2e86..000000000
--- a/internal/tui/components/core/dialog.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package core
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-)
-
-type SizeableModel interface {
- tea.Model
- layout.Sizeable
-}
-
-type DialogMsg struct {
- Content SizeableModel
- WidthRatio float64
- HeightRatio float64
-
- MinWidth int
- MinHeight int
-}
-
-type DialogCloseMsg struct{}
-
-type KeyBindings struct {
- Return key.Binding
-}
-
-var keys = KeyBindings{
- Return: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
-}
-
-type DialogCmp interface {
- tea.Model
- layout.Bindings
-}
-
-type dialogCmp struct {
- content SizeableModel
- screenWidth int
- screenHeight int
-
- widthRatio float64
- heightRatio float64
-
- minWidth int
- minHeight int
-
- width int
- height int
-}
-
-func (d *dialogCmp) Init() tea.Cmd {
- return nil
-}
-
-func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- d.screenWidth = msg.Width
- d.screenHeight = msg.Height
- d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
- d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
- if d.content != nil {
- d.content.SetSize(d.width, d.height)
- }
- return d, nil
- case DialogMsg:
- d.content = msg.Content
- d.widthRatio = msg.WidthRatio
- d.heightRatio = msg.HeightRatio
- d.minWidth = msg.MinWidth
- d.minHeight = msg.MinHeight
- d.width = max(int(float64(d.screenWidth)*d.widthRatio), d.minWidth)
- d.height = max(int(float64(d.screenHeight)*d.heightRatio), d.minHeight)
- if d.content != nil {
- d.content.SetSize(d.width, d.height)
- }
- case DialogCloseMsg:
- d.content = nil
- return d, nil
- case tea.KeyMsg:
- if key.Matches(msg, keys.Return) {
- return d, util.CmdHandler(DialogCloseMsg{})
- }
- }
- if d.content != nil {
- u, cmd := d.content.Update(msg)
- d.content = u.(SizeableModel)
- return d, cmd
- }
- return d, nil
-}
-
-func (d *dialogCmp) BindingKeys() []key.Binding {
- bindings := []key.Binding{keys.Return}
- if d.content == nil {
- return bindings
- }
- if c, ok := d.content.(layout.Bindings); ok {
- return append(bindings, c.BindingKeys()...)
- }
- return bindings
-}
-
-func (d *dialogCmp) View() string {
- return lipgloss.NewStyle().Width(d.width).Height(d.height).Render(d.content.View())
-}
-
-func NewDialogCmp() DialogCmp {
- return &dialogCmp{}
-}
diff --git a/internal/tui/components/core/help.go b/internal/tui/components/core/help.go
deleted file mode 100644
index 4ef857c78..000000000
--- a/internal/tui/components/core/help.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package core
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-type HelpCmp interface {
- tea.Model
- SetBindings(bindings []key.Binding)
- Height() int
-}
-
-const (
- helpWidgetHeight = 12
-)
-
-type helpCmp struct {
- width int
- bindings []key.Binding
-}
-
-func (h *helpCmp) Init() tea.Cmd {
- return nil
-}
-
-func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- h.width = msg.Width
- }
- return h, nil
-}
-
-func (h *helpCmp) View() string {
- helpKeyStyle := styles.Bold.Foreground(styles.Rosewater).Margin(0, 1, 0, 0)
- helpDescStyle := styles.Regular.Foreground(styles.Flamingo)
- // Compile list of bindings to render
- bindings := removeDuplicateBindings(h.bindings)
- // Enumerate through each group of bindings, populating a series of
- // pairs of columns, one for keys, one for descriptions
- var (
- pairs []string
- width int
- rows = helpWidgetHeight - 2
- )
- for i := 0; i < len(bindings); i += rows {
- var (
- keys []string
- descs []string
- )
- for j := i; j < min(i+rows, len(bindings)); j++ {
- keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
- descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
- }
- // Render pair of columns; beyond the first pair, render a three space
- // left margin, in order to visually separate the pairs.
- var cols []string
- if len(pairs) > 0 {
- cols = []string{" "}
- }
- cols = append(cols,
- strings.Join(keys, "\n"),
- strings.Join(descs, "\n"),
- )
-
- pair := lipgloss.JoinHorizontal(lipgloss.Top, cols...)
- // check whether it exceeds the maximum width avail (the width of the
- // terminal, subtracting 2 for the borders).
- width += lipgloss.Width(pair)
- if width > h.width-2 {
- break
- }
- pairs = append(pairs, pair)
- }
-
- // Join pairs of columns and enclose in a border
- content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...)
- return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content)
-}
-
-func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
- seen := make(map[string]struct{})
- result := make([]key.Binding, 0, len(bindings))
-
- // Process bindings in reverse order
- for i := len(bindings) - 1; i >= 0; i-- {
- b := bindings[i]
- k := strings.Join(b.Keys(), " ")
- if _, ok := seen[k]; ok {
- // duplicate, skip
- continue
- }
- seen[k] = struct{}{}
- // Add to the beginning of result to maintain original order
- result = append([]key.Binding{b}, result...)
- }
-
- return result
-}
-
-func (h *helpCmp) SetBindings(bindings []key.Binding) {
- h.bindings = bindings
-}
-
-func (h helpCmp) Height() int {
- return helpWidgetHeight
-}
-
-func NewHelpCmp() HelpCmp {
- return &helpCmp{
- width: 0,
- bindings: make([]key.Binding, 0),
- }
-}
diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go
index 93ba34507..8bf3e5166 100644
--- a/internal/tui/components/core/status.go
+++ b/internal/tui/components/core/status.go
@@ -1,21 +1,34 @@
package core
import (
+ "fmt"
+ "strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/config"
- "github.com/kujtimiihoxha/termai/internal/llm/models"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/kujtimiihoxha/termai/internal/version"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/llm/models"
+ "github.com/kujtimiihoxha/opencode/internal/lsp"
+ "github.com/kujtimiihoxha/opencode/internal/lsp/protocol"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
+type StatusCmp interface {
+ tea.Model
+ SetHelpMsg(string)
+}
+
type statusCmp struct {
info util.InfoMsg
width int
messageTTL time.Duration
+ lspClients map[string]*lsp.Client
+ session session.Session
}
// clearMessageCmd is a command that clears status messages after a timeout
@@ -34,6 +47,16 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, nil
+ case chat.SessionSelectedMsg:
+ m.session = msg
+ case chat.SessionClearedMsg:
+ m.session = session.Session{}
+ case pubsub.Event[session.Session]:
+ if msg.Type == pubsub.UpdatedEvent {
+ if m.session.ID == msg.Payload.ID {
+ m.session = msg.Payload
+ }
+ }
case util.InfoMsg:
m.info = msg
ttl := msg.TTL
@@ -47,20 +70,53 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
-var (
- versionWidget = styles.Padded.Background(styles.DarkGrey).Foreground(styles.Text).Render(version.Version)
- helpWidget = styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
-)
+var helpWidget = styles.Padded.Background(styles.ForgroundMid).Foreground(styles.BackgroundDarker).Bold(true).Render("ctrl+? help")
+
+func formatTokensAndCost(tokens int64, cost float64) string {
+ // Format tokens in human-readable format (e.g., 110K, 1.2M)
+ var formattedTokens string
+ switch {
+ case tokens >= 1_000_000:
+ formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
+ case tokens >= 1_000:
+ formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
+ default:
+ formattedTokens = fmt.Sprintf("%d", tokens)
+ }
+
+ // Remove .0 suffix if present
+ if strings.HasSuffix(formattedTokens, ".0K") {
+ formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
+ }
+ if strings.HasSuffix(formattedTokens, ".0M") {
+ formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
+ }
+
+ // Format cost with $ symbol and 2 decimal places
+ formattedCost := fmt.Sprintf("$%.2f", cost)
+
+ return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
+}
func (m statusCmp) View() string {
- status := styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render("? help")
+ status := helpWidget
+ if m.session.ID != "" {
+ tokens := formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
+ tokensStyle := styles.Padded.
+ Background(styles.Forground).
+ Foreground(styles.BackgroundDim).
+ Render(tokens)
+ status += tokensStyle
+ }
+
+ diagnostics := styles.Padded.Background(styles.BackgroundDarker).Render(m.projectDiagnostics())
if m.info.Msg != "" {
infoStyle := styles.Padded.
Foreground(styles.Base).
- Width(m.availableFooterMsgWidth())
+ Width(m.availableFooterMsgWidth(diagnostics))
switch m.info.Type {
case util.InfoTypeInfo:
- infoStyle = infoStyle.Background(styles.Blue)
+ infoStyle = infoStyle.Background(styles.BorderColor)
case util.InfoTypeWarn:
infoStyle = infoStyle.Background(styles.Peach)
case util.InfoTypeError:
@@ -68,7 +124,7 @@ func (m statusCmp) View() string {
}
// Truncate message if it's longer than available width
msg := m.info.Msg
- availWidth := m.availableFooterMsgWidth() - 10
+ availWidth := m.availableFooterMsgWidth(diagnostics) - 10
if len(msg) > availWidth && availWidth > 0 {
msg = msg[:availWidth] + "..."
}
@@ -76,27 +132,121 @@ func (m statusCmp) View() string {
} else {
status += styles.Padded.
Foreground(styles.Base).
- Background(styles.LightGrey).
- Width(m.availableFooterMsgWidth()).
+ Background(styles.BackgroundDim).
+ Width(m.availableFooterMsgWidth(diagnostics)).
Render("")
}
+
+ status += diagnostics
status += m.model()
- status += versionWidget
return status
}
-func (m statusCmp) availableFooterMsgWidth() int {
- // -2 to accommodate padding
- return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(versionWidget)-lipgloss.Width(m.model()))
+func (m *statusCmp) projectDiagnostics() string {
+ // Check if any LSP server is still initializing
+ initializing := false
+ for _, client := range m.lspClients {
+ if client.GetServerState() == lsp.StateStarting {
+ initializing = true
+ break
+ }
+ }
+
+ // If any server is initializing, show that status
+ if initializing {
+ return lipgloss.NewStyle().
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Peach).
+ Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
+ }
+
+ errorDiagnostics := []protocol.Diagnostic{}
+ warnDiagnostics := []protocol.Diagnostic{}
+ hintDiagnostics := []protocol.Diagnostic{}
+ infoDiagnostics := []protocol.Diagnostic{}
+ for _, client := range m.lspClients {
+ for _, d := range client.GetDiagnostics() {
+ for _, diag := range d {
+ switch diag.Severity {
+ case protocol.SeverityError:
+ errorDiagnostics = append(errorDiagnostics, diag)
+ case protocol.SeverityWarning:
+ warnDiagnostics = append(warnDiagnostics, diag)
+ case protocol.SeverityHint:
+ hintDiagnostics = append(hintDiagnostics, diag)
+ case protocol.SeverityInformation:
+ infoDiagnostics = append(infoDiagnostics, diag)
+ }
+ }
+ }
+ }
+
+ if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
+ return "No diagnostics"
+ }
+
+ diagnostics := []string{}
+
+ if len(errorDiagnostics) > 0 {
+ errStr := lipgloss.NewStyle().
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Error).
+ Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
+ diagnostics = append(diagnostics, errStr)
+ }
+ if len(warnDiagnostics) > 0 {
+ warnStr := lipgloss.NewStyle().
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Warning).
+ Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
+ diagnostics = append(diagnostics, warnStr)
+ }
+ if len(hintDiagnostics) > 0 {
+ hintStr := lipgloss.NewStyle().
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Text).
+ Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
+ diagnostics = append(diagnostics, hintStr)
+ }
+ if len(infoDiagnostics) > 0 {
+ infoStr := lipgloss.NewStyle().
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Peach).
+ Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
+ diagnostics = append(diagnostics, infoStr)
+ }
+
+ return strings.Join(diagnostics, " ")
+}
+
+func (m statusCmp) availableFooterMsgWidth(diagnostics string) int {
+ tokens := ""
+ tokensWidth := 0
+ if m.session.ID != "" {
+ tokens = formatTokensAndCost(m.session.PromptTokens+m.session.CompletionTokens, m.session.Cost)
+ tokensWidth = lipgloss.Width(tokens) + 2
+ }
+ return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
}
func (m statusCmp) model() string {
- model := models.SupportedModels[config.Get().Model.Coder]
+ cfg := config.Get()
+
+ coder, ok := cfg.Agents[config.AgentCoder]
+ if !ok {
+ return "Unknown"
+ }
+ model := models.SupportedModels[coder.Model]
return styles.Padded.Background(styles.Grey).Foreground(styles.Text).Render(model.Name)
}
-func NewStatusCmp() tea.Model {
+func (m statusCmp) SetHelpMsg(s string) {
+ helpWidget = styles.Padded.Background(styles.Forground).Foreground(styles.BackgroundDarker).Bold(true).Render(s)
+}
+
+func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
return &statusCmp{
messageTTL: 10 * time.Second,
+ lspClients: lspClients,
}
}
diff --git a/internal/tui/components/dialog/commands.go b/internal/tui/components/dialog/commands.go
new file mode 100644
index 000000000..7b25caeb0
--- /dev/null
+++ b/internal/tui/components/dialog/commands.go
@@ -0,0 +1,247 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// Command represents a command that can be executed
+type Command struct {
+ ID string
+ Title string
+ Description string
+ Handler func(cmd Command) tea.Cmd
+}
+
+// CommandSelectedMsg is sent when a command is selected
+type CommandSelectedMsg struct {
+ Command Command
+}
+
+// CloseCommandDialogMsg is sent when the command dialog is closed
+type CloseCommandDialogMsg struct{}
+
+// CommandDialog interface for the command selection dialog
+type CommandDialog interface {
+ tea.Model
+ layout.Bindings
+ SetCommands(commands []Command)
+ SetSelectedCommand(commandID string)
+}
+
+type commandDialogCmp struct {
+ commands []Command
+ selectedIdx int
+ width int
+ height int
+ selectedCommandID string
+}
+
+type commandKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ J key.Binding
+ K key.Binding
+}
+
+var commandKeys = commandKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous command"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next command"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "select command"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ J: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next command"),
+ ),
+ K: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous command"),
+ ),
+}
+
+func (c *commandDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, commandKeys.Up) || key.Matches(msg, commandKeys.K):
+ if c.selectedIdx > 0 {
+ c.selectedIdx--
+ }
+ return c, nil
+ case key.Matches(msg, commandKeys.Down) || key.Matches(msg, commandKeys.J):
+ if c.selectedIdx < len(c.commands)-1 {
+ c.selectedIdx++
+ }
+ return c, nil
+ case key.Matches(msg, commandKeys.Enter):
+ if len(c.commands) > 0 {
+ return c, util.CmdHandler(CommandSelectedMsg{
+ Command: c.commands[c.selectedIdx],
+ })
+ }
+ case key.Matches(msg, commandKeys.Escape):
+ return c, util.CmdHandler(CloseCommandDialogMsg{})
+ }
+ case tea.WindowSizeMsg:
+ c.width = msg.Width
+ c.height = msg.Height
+ }
+ return c, nil
+}
+
+func (c *commandDialogCmp) View() string {
+ if len(c.commands) == 0 {
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(40).
+ Render("No commands available")
+ }
+
+ // Calculate max width needed for command titles
+ maxWidth := 40 // Minimum width
+ for _, cmd := range c.commands {
+ if len(cmd.Title) > maxWidth-4 { // Account for padding
+ maxWidth = len(cmd.Title) + 4
+ }
+ if len(cmd.Description) > maxWidth-4 {
+ maxWidth = len(cmd.Description) + 4
+ }
+ }
+
+ // Limit height to avoid taking up too much screen space
+ maxVisibleCommands := min(10, len(c.commands))
+
+ // Build the command list
+ commandItems := make([]string, 0, maxVisibleCommands)
+ startIdx := 0
+
+ // If we have more commands than can be displayed, adjust the start index
+ if len(c.commands) > maxVisibleCommands {
+ // Center the selected item when possible
+ halfVisible := maxVisibleCommands / 2
+ if c.selectedIdx >= halfVisible && c.selectedIdx < len(c.commands)-halfVisible {
+ startIdx = c.selectedIdx - halfVisible
+ } else if c.selectedIdx >= len(c.commands)-halfVisible {
+ startIdx = len(c.commands) - maxVisibleCommands
+ }
+ }
+
+ endIdx := min(startIdx+maxVisibleCommands, len(c.commands))
+
+ for i := startIdx; i < endIdx; i++ {
+ cmd := c.commands[i]
+ itemStyle := styles.BaseStyle.Width(maxWidth)
+ descStyle := styles.BaseStyle.Width(maxWidth).Foreground(styles.ForgroundDim)
+
+ if i == c.selectedIdx {
+ itemStyle = itemStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ descStyle = descStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background)
+ }
+
+ title := itemStyle.Padding(0, 1).Render(cmd.Title)
+ description := ""
+ if cmd.Description != "" {
+ description = descStyle.Padding(0, 1).Render(cmd.Description)
+ commandItems = append(commandItems, lipgloss.JoinVertical(lipgloss.Left, title, description))
+ } else {
+ commandItems = append(commandItems, title)
+ }
+ }
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Commands")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, commandItems...)),
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (c *commandDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(commandKeys)
+}
+
+func (c *commandDialogCmp) SetCommands(commands []Command) {
+ c.commands = commands
+
+ // If we have a selected command ID, find its index
+ if c.selectedCommandID != "" {
+ for i, cmd := range commands {
+ if cmd.ID == c.selectedCommandID {
+ c.selectedIdx = i
+ return
+ }
+ }
+ }
+
+ // Default to first command if selected not found
+ c.selectedIdx = 0
+}
+
+func (c *commandDialogCmp) SetSelectedCommand(commandID string) {
+ c.selectedCommandID = commandID
+
+ // Update the selected index if commands are already loaded
+ if len(c.commands) > 0 {
+ for i, cmd := range c.commands {
+ if cmd.ID == commandID {
+ c.selectedIdx = i
+ return
+ }
+ }
+ }
+}
+
+// NewCommandDialogCmp creates a new command selection dialog
+func NewCommandDialogCmp() CommandDialog {
+ return &commandDialogCmp{
+ commands: []Command{},
+ selectedIdx: 0,
+ selectedCommandID: "",
+ }
+}
+
diff --git a/internal/tui/components/dialog/help.go b/internal/tui/components/dialog/help.go
new file mode 100644
index 000000000..644b294cb
--- /dev/null
+++ b/internal/tui/components/dialog/help.go
@@ -0,0 +1,182 @@
+package dialog
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type helpCmp struct {
+ width int
+ height int
+ keys []key.Binding
+}
+
+func (h *helpCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (h *helpCmp) SetBindings(k []key.Binding) {
+ h.keys = k
+}
+
+func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ h.width = 90
+ h.height = msg.Height
+ }
+ return h, nil
+}
+
+func removeDuplicateBindings(bindings []key.Binding) []key.Binding {
+ seen := make(map[string]struct{})
+ result := make([]key.Binding, 0, len(bindings))
+
+ // Process bindings in reverse order
+ for i := len(bindings) - 1; i >= 0; i-- {
+ b := bindings[i]
+ k := strings.Join(b.Keys(), " ")
+ if _, ok := seen[k]; ok {
+ // duplicate, skip
+ continue
+ }
+ seen[k] = struct{}{}
+ // Add to the beginning of result to maintain original order
+ result = append([]key.Binding{b}, result...)
+ }
+
+ return result
+}
+
+func (h *helpCmp) render() string {
+ helpKeyStyle := styles.Bold.Background(styles.Background).Foreground(styles.Forground).Padding(0, 1, 0, 0)
+ helpDescStyle := styles.Regular.Background(styles.Background).Foreground(styles.ForgroundMid)
+ // Compile list of bindings to render
+ bindings := removeDuplicateBindings(h.keys)
+ // Enumerate through each group of bindings, populating a series of
+ // pairs of columns, one for keys, one for descriptions
+ var (
+ pairs []string
+ width int
+ rows = 14 - 2
+ )
+ for i := 0; i < len(bindings); i += rows {
+ var (
+ keys []string
+ descs []string
+ )
+ for j := i; j < min(i+rows, len(bindings)); j++ {
+ keys = append(keys, helpKeyStyle.Render(bindings[j].Help().Key))
+ descs = append(descs, helpDescStyle.Render(bindings[j].Help().Desc))
+ }
+ // Render pair of columns; beyond the first pair, render a three space
+ // left margin, in order to visually separate the pairs.
+ var cols []string
+ if len(pairs) > 0 {
+ cols = []string{styles.BaseStyle.Render(" ")}
+ }
+
+ maxDescWidth := 0
+ for _, desc := range descs {
+ if maxDescWidth < lipgloss.Width(desc) {
+ maxDescWidth = lipgloss.Width(desc)
+ }
+ }
+ for i := range descs {
+ remainingWidth := maxDescWidth - lipgloss.Width(descs[i])
+ if remainingWidth > 0 {
+ descs[i] = descs[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+ maxKeyWidth := 0
+ for _, key := range keys {
+ if maxKeyWidth < lipgloss.Width(key) {
+ maxKeyWidth = lipgloss.Width(key)
+ }
+ }
+ for i := range keys {
+ remainingWidth := maxKeyWidth - lipgloss.Width(keys[i])
+ if remainingWidth > 0 {
+ keys[i] = keys[i] + styles.BaseStyle.Render(strings.Repeat(" ", remainingWidth))
+ }
+ }
+
+ cols = append(cols,
+ strings.Join(keys, "\n"),
+ strings.Join(descs, "\n"),
+ )
+
+ pair := styles.BaseStyle.Render(lipgloss.JoinHorizontal(lipgloss.Top, cols...))
+ // check whether it exceeds the maximum width avail (the width of the
+ // terminal, subtracting 2 for the borders).
+ width += lipgloss.Width(pair)
+ if width > h.width-2 {
+ break
+ }
+ pairs = append(pairs, pair)
+ }
+
+ // https://github.com/charmbracelet/lipgloss/issues/209
+ if len(pairs) > 1 {
+ prefix := pairs[:len(pairs)-1]
+ lastPair := pairs[len(pairs)-1]
+ prefix = append(prefix, lipgloss.Place(
+ lipgloss.Width(lastPair), // width
+ lipgloss.Height(prefix[0]), // height
+ lipgloss.Left, // x
+ lipgloss.Top, // y
+ lastPair, // content
+ lipgloss.WithWhitespaceBackground(styles.Background), // background
+ ))
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ prefix...,
+ ),
+ )
+ return content
+ }
+ // Join pairs of columns and enclose in a border
+ content := styles.BaseStyle.Width(h.width).Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Top,
+ pairs...,
+ ),
+ )
+ return content
+}
+
+func (h *helpCmp) View() string {
+ content := h.render()
+ header := styles.BaseStyle.
+ Bold(true).
+ Width(lipgloss.Width(content)).
+ Foreground(styles.PrimaryColor).
+ Render("Keyboard Shortcuts")
+
+ return styles.BaseStyle.Padding(1).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(styles.ForgroundDim).
+ Width(h.width).
+ BorderBackground(styles.Background).
+ Render(
+ lipgloss.JoinVertical(lipgloss.Center,
+ header,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(header))),
+ content,
+ ),
+ )
+}
+
+type HelpCmp interface {
+ tea.Model
+ SetBindings([]key.Binding)
+}
+
+func NewHelpCmp() HelpCmp {
+ return &helpCmp{}
+}
diff --git a/internal/tui/components/dialog/init.go b/internal/tui/components/dialog/init.go
new file mode 100644
index 000000000..6098ca755
--- /dev/null
+++ b/internal/tui/components/dialog/init.go
@@ -0,0 +1,191 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// InitDialogCmp is a component that asks the user if they want to initialize the project.
+type InitDialogCmp struct {
+ width, height int
+ selected int
+ keys initDialogKeyMap
+}
+
+// NewInitDialogCmp creates a new InitDialogCmp.
+func NewInitDialogCmp() InitDialogCmp {
+ return InitDialogCmp{
+ selected: 0,
+ keys: initDialogKeyMap{},
+ }
+}
+
+type initDialogKeyMap struct {
+ Tab key.Binding
+ Left key.Binding
+ Right key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ Y key.Binding
+ N key.Binding
+}
+
+// ShortHelp implements key.Map.
+func (k initDialogKeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{
+ key.NewBinding(
+ key.WithKeys("tab", "left", "right"),
+ key.WithHelp("tab/←/→", "toggle selection"),
+ ),
+ key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "confirm"),
+ ),
+ key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel"),
+ ),
+ key.NewBinding(
+ key.WithKeys("y", "n"),
+ key.WithHelp("y/n", "yes/no"),
+ ),
+ }
+}
+
+// FullHelp implements key.Map.
+func (k initDialogKeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{k.ShortHelp()}
+}
+
+// Init implements tea.Model.
+func (m InitDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+// Update implements tea.Model.
+func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
+ m.selected = (m.selected + 1) % 2
+ return m, nil
+ case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
+ case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
+ return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
+ }
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ }
+ return m, nil
+}
+
+// View implements tea.Model.
+func (m InitDialogCmp) View() string {
+ // Calculate width needed for content
+ maxWidth := 60 // Width for explanation text
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialize Project")
+
+ explanation := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Initialization generates a new OpenCode.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
+
+ question := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(maxWidth).
+ Padding(1, 1).
+ Render("Would you like to initialize this project?")
+
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+
+ if m.selected == 0 {
+ yesStyle = yesStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ noStyle = noStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ } else {
+ noStyle = noStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ yesStyle = yesStyle.
+ Background(styles.Background).
+ Foreground(styles.PrimaryColor)
+ }
+
+ yes := yesStyle.Padding(0, 3).Render("Yes")
+ no := noStyle.Padding(0, 3).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, styles.BaseStyle.Render(" "), no)
+ buttons = styles.BaseStyle.
+ Width(maxWidth).
+ Padding(1, 0).
+ Render(buttons)
+
+ help := styles.BaseStyle.
+ Width(maxWidth).
+ Padding(0, 1).
+ Foreground(styles.ForgroundDim).
+ Render("tab/←/→: toggle y/n: yes/no enter: confirm esc: cancel")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ explanation,
+ question,
+ buttons,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ help,
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+// SetSize sets the size of the component.
+func (m *InitDialogCmp) SetSize(width, height int) {
+ m.width = width
+ m.height = height
+}
+
+// Bindings implements layout.Bindings.
+func (m InitDialogCmp) Bindings() []key.Binding {
+ return m.keys.ShortHelp()
+}
+
+// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
+type CloseInitDialogMsg struct {
+ Initialize bool
+}
+
+// ShowInitDialogMsg is a message that is sent to show the init dialog.
+type ShowInitDialogMsg struct {
+ Show bool
+}
diff --git a/internal/tui/components/dialog/permission.go b/internal/tui/components/dialog/permission.go
index 465f475d5..16b63815c 100644
--- a/internal/tui/components/dialog/permission.go
+++ b/internal/tui/components/dialog/permission.go
@@ -9,14 +9,12 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/llm/tools"
- "github.com/kujtimiihoxha/termai/internal/permission"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
+ "github.com/kujtimiihoxha/opencode/internal/diff"
+ "github.com/kujtimiihoxha/opencode/internal/llm/tools"
+ "github.com/kujtimiihoxha/opencode/internal/permission"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
type PermissionAction string
@@ -34,69 +32,69 @@ type PermissionResponseMsg struct {
Action PermissionAction
}
-// PermissionDialog interface for permission dialog component
-type PermissionDialog interface {
+// PermissionDialogCmp interface for permission dialog component
+type PermissionDialogCmp interface {
tea.Model
- layout.Sizeable
layout.Bindings
+ SetPermissions(permission permission.PermissionRequest) tea.Cmd
}
-type keyMap struct {
- ChangeFocus key.Binding
+type permissionsMapping struct {
+ Left key.Binding
+ Right key.Binding
+ EnterSpace key.Binding
+ Allow key.Binding
+ AllowSession key.Binding
+ Deny key.Binding
+ Tab key.Binding
}
-var keyMapValue = keyMap{
- ChangeFocus: key.NewBinding(
+var permissionsKeys = permissionsMapping{
+ Left: key.NewBinding(
+ key.WithKeys("left"),
+ key.WithHelp("←", "switch options"),
+ ),
+ Right: key.NewBinding(
+ key.WithKeys("right"),
+ key.WithHelp("→", "switch options"),
+ ),
+ EnterSpace: key.NewBinding(
+ key.WithKeys("enter", " "),
+ key.WithHelp("enter/space", "confirm"),
+ ),
+ Allow: key.NewBinding(
+ key.WithKeys("a"),
+ key.WithHelp("a", "allow"),
+ ),
+ AllowSession: key.NewBinding(
+ key.WithKeys("A"),
+ key.WithHelp("A", "allow for session"),
+ ),
+ Deny: key.NewBinding(
+ key.WithKeys("d"),
+ key.WithHelp("d", "deny"),
+ ),
+ Tab: key.NewBinding(
key.WithKeys("tab"),
- key.WithHelp("tab", "change focus"),
+ key.WithHelp("tab", "switch options"),
),
}
// permissionDialogCmp is the implementation of PermissionDialog
type permissionDialogCmp struct {
- form *huh.Form
width int
height int
permission permission.PermissionRequest
windowSize tea.WindowSizeMsg
- r *glamour.TermRenderer
contentViewPort viewport.Model
- isViewportFocus bool
- selectOption *huh.Select[string]
-}
+ selectedOption int // 0: Allow, 1: Allow for session, 2: Deny
-// formatDiff formats a diff string with colors for additions and deletions
-func formatDiff(diffText string) string {
- lines := strings.Split(diffText, "\n")
- var formattedLines []string
-
- // Define styles for different line types
- addStyle := lipgloss.NewStyle().Foreground(styles.Green)
- removeStyle := lipgloss.NewStyle().Foreground(styles.Red)
- headerStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Blue)
- contextStyle := lipgloss.NewStyle().Foreground(styles.SubText0)
-
- // Process each line
- for _, line := range lines {
- if strings.HasPrefix(line, "+") {
- formattedLines = append(formattedLines, addStyle.Render(line))
- } else if strings.HasPrefix(line, "-") {
- formattedLines = append(formattedLines, removeStyle.Render(line))
- } else if strings.HasPrefix(line, "Changes:") || strings.HasPrefix(line, " ...") {
- formattedLines = append(formattedLines, headerStyle.Render(line))
- } else if strings.HasPrefix(line, " ") {
- formattedLines = append(formattedLines, contextStyle.Render(line))
- } else {
- formattedLines = append(formattedLines, line)
- }
- }
-
- // Join all formatted lines
- return strings.Join(formattedLines, "\n")
+ diffCache map[string]string
+ markdownCache map[string]string
}
func (p *permissionDialogCmp) Init() tea.Cmd {
- return nil
+ return p.contentViewPort.Init()
}
func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -105,369 +103,381 @@ func (p *permissionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
p.windowSize = msg
+ cmd := p.SetSize()
+ cmds = append(cmds, cmd)
+ p.markdownCache = make(map[string]string)
+ p.diffCache = make(map[string]string)
case tea.KeyMsg:
- if key.Matches(msg, keyMapValue.ChangeFocus) {
- p.isViewportFocus = !p.isViewportFocus
- if p.isViewportFocus {
- p.selectOption.Blur()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.ReportInfo("Viewing content - use arrow keys to scroll"),
- ))
- } else {
- p.selectOption.Focus()
- // Add a visual indicator for focus change
- cmds = append(cmds, tea.Batch(
- util.CmdHandler(util.ReportInfo("Select an action")),
- ))
- }
- return p, tea.Batch(cmds...)
+ switch {
+ case key.Matches(msg, permissionsKeys.Right) || key.Matches(msg, permissionsKeys.Tab):
+ p.selectedOption = (p.selectedOption + 1) % 3
+ return p, nil
+ case key.Matches(msg, permissionsKeys.Left):
+ p.selectedOption = (p.selectedOption + 2) % 3
+ case key.Matches(msg, permissionsKeys.EnterSpace):
+ return p, p.selectCurrentOption()
+ case key.Matches(msg, permissionsKeys.Allow):
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllow, Permission: p.permission})
+ case key.Matches(msg, permissionsKeys.AllowSession):
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionAllowForSession, Permission: p.permission})
+ case key.Matches(msg, permissionsKeys.Deny):
+ return p, util.CmdHandler(PermissionResponseMsg{Action: PermissionDeny, Permission: p.permission})
+ default:
+ // Pass other keys to viewport
+ viewPort, cmd := p.contentViewPort.Update(msg)
+ p.contentViewPort = viewPort
+ cmds = append(cmds, cmd)
}
}
- if p.isViewportFocus {
- viewPort, cmd := p.contentViewPort.Update(msg)
- p.contentViewPort = viewPort
- cmds = append(cmds, cmd)
- } else {
- form, cmd := p.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- p.form = f
- cmds = append(cmds, cmd)
- }
+ return p, tea.Batch(cmds...)
+}
- if p.form.State == huh.StateCompleted {
- // Get the selected action
- action := p.form.GetString("action")
+func (p *permissionDialogCmp) selectCurrentOption() tea.Cmd {
+ var action PermissionAction
- // Close the dialog and return the response
- return p, tea.Batch(
- util.CmdHandler(core.DialogCloseMsg{}),
- util.CmdHandler(PermissionResponseMsg{Action: PermissionAction(action), Permission: p.permission}),
- )
- }
+ switch p.selectedOption {
+ case 0:
+ action = PermissionAllow
+ case 1:
+ action = PermissionAllowForSession
+ case 2:
+ action = PermissionDeny
}
- return p, tea.Batch(cmds...)
+
+ return util.CmdHandler(PermissionResponseMsg{Action: action, Permission: p.permission})
}
-func (p *permissionDialogCmp) render() string {
- keyStyle := lipgloss.NewStyle().Bold(true).Foreground(styles.Rosewater)
- valueStyle := lipgloss.NewStyle().Foreground(styles.Peach)
+func (p *permissionDialogCmp) renderButtons() string {
+ allowStyle := styles.BaseStyle
+ allowSessionStyle := styles.BaseStyle
+ denyStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ // Style the selected button
+ switch p.selectedOption {
+ case 0:
+ allowStyle = allowStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 1:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ denyStyle = denyStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ case 2:
+ allowStyle = allowStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ allowSessionStyle = allowSessionStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ denyStyle = denyStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ }
- form := p.form.View()
+ allowButton := allowStyle.Padding(0, 1).Render("Allow (a)")
+ allowSessionButton := allowSessionStyle.Padding(0, 1).Render("Allow for session (A)")
+ denyButton := denyStyle.Padding(0, 1).Render("Deny (d)")
+
+ content := lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ allowButton,
+ spacerStyle.Render(" "),
+ allowSessionButton,
+ spacerStyle.Render(" "),
+ denyButton,
+ spacerStyle.Render(" "),
+ )
- headerParts := []string{
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Tool:"), " ", valueStyle.Render(p.permission.ToolName)),
- " ",
- lipgloss.JoinHorizontal(lipgloss.Left, keyStyle.Render("Path:"), " ", valueStyle.Render(p.permission.Path)),
- " ",
+ remainingWidth := p.width - lipgloss.Width(content)
+ if remainingWidth > 0 {
+ content = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + content
}
+ return content
+}
- // Create the header content first so it can be used in all cases
- headerContent := lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+func (p *permissionDialogCmp) renderHeader() string {
+ toolKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Tool")
+ toolValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(toolKey)).
+ Render(fmt.Sprintf(": %s", p.permission.ToolName))
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
- glamour.WithWordWrap(p.width-10),
- glamour.WithEmoji(),
- )
+ pathKey := styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("Path")
+ pathValue := styles.BaseStyle.
+ Foreground(styles.Forground).
+ Width(p.width - lipgloss.Width(pathKey)).
+ Render(fmt.Sprintf(": %s", p.permission.Path))
- // Handle different tool types
+ headerParts := []string{
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ toolKey,
+ toolValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ pathKey,
+ pathValue,
+ ),
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width)),
+ }
+
+ // Add tool-specific header information
switch p.permission.ToolName {
case tools.BashToolName:
- pr := p.permission.Params.(tools.BashPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Command:"))
- content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Command"))
+ case tools.EditToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.WriteToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("Diff"))
+ case tools.FetchToolName:
+ headerParts = append(headerParts, styles.BaseStyle.Foreground(styles.ForgroundDim).Width(p.width).Bold(true).Render("URL"))
+ }
- // Calculate content height dynamically based on content
- contentLines := len(strings.Split(renderedContent, "\n"))
- // Set a reasonable min/max for the viewport height
- minContentHeight := 3
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
+ return lipgloss.NewStyle().Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
+}
- // Add some padding to the content lines
- contentHeight := contentLines + 2
- contentHeight = max(contentHeight, minContentHeight)
- contentHeight = min(contentHeight, maxContentHeight)
- p.contentViewPort.Height = contentHeight
+func (p *permissionDialogCmp) renderBashContent() string {
+ if pr, ok := p.permission.Params.(tools.BashPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.Command)
- p.contentViewPort.SetContent(renderedContent)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
+
+ finalContent := styles.BaseStyle.
+ Width(p.contentViewPort.Width).
+ Render(renderedContent)
+ p.contentViewPort.SetContent(finalContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
+func (p *permissionDialogCmp) renderEditContent() string {
+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+func (p *permissionDialogCmp) renderPatchContent() string {
+ if pr, ok := p.permission.Params.(tools.EditPermissionsParams); ok {
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) renderWriteContent() string {
+ if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
+ // Use the cache for diff rendering
+ diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+ return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+ })
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ p.contentViewPort.SetContent(diff)
+ return p.styleViewport()
+ }
+ return ""
+}
- case tools.EditToolName:
- pr := p.permission.Params.(tools.EditPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Update"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Format the diff with colors
- formattedDiff := formatDiff(pr.Diff)
-
- // Set up viewport for the diff content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- p.contentViewPort.SetContent(formattedDiff)
-
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
-
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+func (p *permissionDialogCmp) renderFetchContent() string {
+ if pr, ok := p.permission.Params.(tools.FetchPermissionsParams); ok {
+ content := fmt.Sprintf("```bash\n%s\n```", pr.URL)
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.MarkdownTheme(true)),
+ glamour.WithWordWrap(p.width-10),
+ )
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ p.contentViewPort.SetContent(renderedContent)
+ return p.styleViewport()
+ }
+ return ""
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) renderDefaultContent() string {
+ content := p.permission.Description
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ // Use the cache for markdown rendering
+ renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) {
+ r, _ := glamour.NewTermRenderer(
+ glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
+ glamour.WithWordWrap(p.width-10),
)
+ s, err := r.Render(content)
+ return styles.ForceReplaceBackgroundWithLipgloss(s, styles.Background), err
+ })
- case tools.WriteToolName:
- pr := p.permission.Params.(tools.WritePermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("Content"))
- // Recreate header content with the updated headerParts
- headerContent = lipgloss.NewStyle().Padding(0, 1).Render(lipgloss.JoinVertical(lipgloss.Left, headerParts...))
-
- // Format the diff with colors
- formattedDiff := formatDiff(pr.Content)
-
- // Set up viewport for the content
- p.contentViewPort.Width = p.width - 2 - 2
-
- // Calculate content height dynamically based on window size
- maxContentHeight := p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.Height = maxContentHeight
- p.contentViewPort.SetContent(formattedDiff)
-
- // Style the viewport
- var contentBorder lipgloss.Border
- var borderColor lipgloss.TerminalColor
-
- if p.isViewportFocus {
- contentBorder = lipgloss.DoubleBorder()
- borderColor = styles.Blue
- } else {
- contentBorder = lipgloss.RoundedBorder()
- borderColor = styles.Flamingo
- }
+ p.contentViewPort.SetContent(renderedContent)
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(contentBorder).
- BorderForeground(borderColor)
+ if renderedContent == "" {
+ return ""
+ }
- if p.isViewportFocus {
- contentStyle = contentStyle.BorderBackground(styles.Surface0)
- }
+ return p.styleViewport()
+}
- contentFinal := contentStyle.Render(p.contentViewPort.View())
+func (p *permissionDialogCmp) styleViewport() string {
+ contentStyle := lipgloss.NewStyle().
+ Background(styles.Background)
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
+ return contentStyle.Render(p.contentViewPort.View())
+}
+func (p *permissionDialogCmp) render() string {
+ title := styles.BaseStyle.
+ Bold(true).
+ Width(p.width - 4).
+ Foreground(styles.PrimaryColor).
+ Render("Permission Required")
+ // Render header
+ headerContent := p.renderHeader()
+ // Render buttons
+ buttons := p.renderButtons()
+
+ // Calculate content height dynamically based on window size
+ p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(buttons) - 2 - lipgloss.Height(title)
+ p.contentViewPort.Width = p.width - 4
+
+ // Render content based on tool type
+ var contentFinal string
+ switch p.permission.ToolName {
+ case tools.BashToolName:
+ contentFinal = p.renderBashContent()
+ case tools.EditToolName:
+ contentFinal = p.renderEditContent()
+ case tools.PatchToolName:
+ contentFinal = p.renderPatchContent()
+ case tools.WriteToolName:
+ contentFinal = p.renderWriteContent()
case tools.FetchToolName:
- pr := p.permission.Params.(tools.FetchPermissionsParams)
- headerParts = append(headerParts, keyStyle.Render("URL: "+pr.URL))
- content := p.permission.Description
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
-
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
-
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
-
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
- )
-
+ contentFinal = p.renderFetchContent()
default:
- content := p.permission.Description
-
- renderedContent, _ := r.Render(content)
- p.contentViewPort.Width = p.width - 2 - 2
- p.contentViewPort.Height = p.height - lipgloss.Height(headerContent) - lipgloss.Height(form) - 2 - 2 - 1
- p.contentViewPort.SetContent(renderedContent)
-
- // Style the viewport
- contentStyle := lipgloss.NewStyle().
- MarginTop(1).
- Padding(0, 1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Flamingo)
+ contentFinal = p.renderDefaultContent()
+ }
- contentFinal := contentStyle.Render(p.contentViewPort.View())
- if renderedContent == "" {
- contentFinal = ""
- }
+ // Add help text
+ helpText := styles.BaseStyle.Width(p.width - 4).Padding(0, 1).Foreground(styles.ForgroundDim).Render("←/→/tab: switch options a: allow A: allow for session d: deny enter/space: confirm")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Top,
+ title,
+ styles.BaseStyle.Render(strings.Repeat(" ", lipgloss.Width(title))),
+ headerContent,
+ contentFinal,
+ buttons,
+ styles.BaseStyle.Render(strings.Repeat(" ", p.width - 4)),
+ helpText,
+ )
- return lipgloss.JoinVertical(
- lipgloss.Top,
- headerContent,
- contentFinal,
- form,
+ return styles.BaseStyle.
+ Padding(1, 0, 0, 1).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(p.width).
+ Height(p.height).
+ Render(
+ content,
)
- }
}
func (p *permissionDialogCmp) View() string {
return p.render()
}
-func (p *permissionDialogCmp) GetSize() (int, int) {
- return p.width, p.height
+func (p *permissionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(permissionsKeys)
}
-func (p *permissionDialogCmp) SetSize(width int, height int) {
- p.width = width
- p.height = height
- p.form = p.form.WithWidth(width)
+func (p *permissionDialogCmp) SetSize() tea.Cmd {
+ if p.permission.ID == "" {
+ return nil
+ }
+ switch p.permission.ToolName {
+ case tools.BashToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.4)
+ p.height = int(float64(p.windowSize.Height) * 0.3)
+ case tools.EditToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.8)
+ p.height = int(float64(p.windowSize.Height) * 0.8)
+ case tools.WriteToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.8)
+ p.height = int(float64(p.windowSize.Height) * 0.8)
+ case tools.FetchToolName:
+ p.width = int(float64(p.windowSize.Width) * 0.4)
+ p.height = int(float64(p.windowSize.Height) * 0.3)
+ default:
+ p.width = int(float64(p.windowSize.Width) * 0.7)
+ p.height = int(float64(p.windowSize.Height) * 0.5)
+ }
+ return nil
}
-func (p *permissionDialogCmp) BindingKeys() []key.Binding {
- return p.form.KeyBinds()
+func (p *permissionDialogCmp) SetPermissions(permission permission.PermissionRequest) tea.Cmd {
+ p.permission = permission
+ return p.SetSize()
}
-func newPermissionDialogCmp(permission permission.PermissionRequest) PermissionDialog {
- // Create a note field for displaying the content
+// Helper to get or set cached diff content
+func (c *permissionDialogCmp) GetOrSetDiff(key string, generator func() (string, error)) string {
+ if cached, ok := c.diffCache[key]; ok {
+ return cached
+ }
- // Create select field for the permission options
- selectOption := huh.NewSelect[string]().
- Key("action").
- Options(
- huh.NewOption("Allow", string(PermissionAllow)),
- huh.NewOption("Allow for this session", string(PermissionAllowForSession)),
- huh.NewOption("Deny", string(PermissionDeny)),
- ).
- Title("Select an action")
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error formatting diff: %v", err)
+ }
- // Apply theme
- theme := styles.HuhTheme()
+ c.diffCache[key] = content
- // Setup form width and height
- form := huh.NewForm(huh.NewGroup(selectOption)).
- WithShowHelp(false).
- WithTheme(theme).
- WithShowErrors(false)
+ return content
+}
- // Focus the form for immediate interaction
- selectOption.Focus()
+// Helper to get or set cached markdown content
+func (c *permissionDialogCmp) GetOrSetMarkdown(key string, generator func() (string, error)) string {
+ if cached, ok := c.markdownCache[key]; ok {
+ return cached
+ }
- return &permissionDialogCmp{
- permission: permission,
- form: form,
- selectOption: selectOption,
+ content, err := generator()
+ if err != nil {
+ return fmt.Sprintf("Error rendering markdown: %v", err)
}
-}
-// NewPermissionDialogCmd creates a new permission dialog command
-func NewPermissionDialogCmd(permission permission.PermissionRequest) tea.Cmd {
- permDialog := newPermissionDialogCmp(permission)
-
- // Create the dialog layout
- dialogPane := layout.NewSinglePane(
- permDialog.(*permissionDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- layout.WithSignlePaneBorderText(map[layout.BorderPosition]string{
- layout.TopMiddleBorder: " Permission Required ",
- }),
- )
+ c.markdownCache[key] = content
- // Focus the dialog
- dialogPane.Focus()
- widthRatio := 0.7
- heightRatio := 0.6
- minWidth := 100
- minHeight := 30
+ return content
+}
- // Make the dialog size more appropriate for different tools
- switch permission.ToolName {
- case tools.BashToolName:
- // For bash commands, use a more compact dialog
- widthRatio = 0.7
- heightRatio = 0.4 // Reduced from 0.5
- minWidth = 100
- minHeight = 20 // Reduced from 30
+func NewPermissionDialogCmp() PermissionDialogCmp {
+ // Create viewport for content
+ contentViewport := viewport.New(0, 0)
+
+ return &permissionDialogCmp{
+ contentViewPort: contentViewport,
+ selectedOption: 0, // Default to "Allow"
+ diffCache: make(map[string]string),
+ markdownCache: make(map[string]string),
}
- // Return the dialog command
- return util.CmdHandler(core.DialogMsg{
- Content: dialogPane,
- WidthRatio: widthRatio,
- HeightRatio: heightRatio,
- MinWidth: minWidth,
- MinHeight: minHeight,
- })
}
diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go
index 60c1fc0d2..5bbe6696c 100644
--- a/internal/tui/components/dialog/quit.go
+++ b/internal/tui/components/dialog/quit.go
@@ -1,28 +1,58 @@
package dialog
import (
+ "strings"
+
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-
- "github.com/charmbracelet/huh"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
const question = "Are you sure you want to quit?"
+type CloseQuitMsg struct{}
+
type QuitDialog interface {
tea.Model
- layout.Sizeable
layout.Bindings
}
type quitDialogCmp struct {
- form *huh.Form
- width int
- height int
+ selectedNo bool
+}
+
+type helpMapping struct {
+ LeftRight key.Binding
+ EnterSpace key.Binding
+ Yes key.Binding
+ No key.Binding
+ Tab key.Binding
+}
+
+var helpKeys = helpMapping{
+ LeftRight: key.NewBinding(
+ key.WithKeys("left", "right"),
+ key.WithHelp("←/→", "switch options"),
+ ),
+ EnterSpace: key.NewBinding(
+ key.WithKeys("enter", " "),
+ key.WithHelp("enter/space", "confirm"),
+ ),
+ Yes: key.NewBinding(
+ key.WithKeys("y", "Y"),
+ key.WithHelp("y/Y", "yes"),
+ ),
+ No: key.NewBinding(
+ key.WithKeys("n", "N"),
+ key.WithHelp("n/N", "no"),
+ ),
+ Tab: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch options"),
+ ),
}
func (q *quitDialogCmp) Init() tea.Cmd {
@@ -30,77 +60,73 @@ func (q *quitDialogCmp) Init() tea.Cmd {
}
func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- form, cmd := q.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- q.form = f
- cmds = append(cmds, cmd)
- }
-
- if q.form.State == huh.StateCompleted {
- v := q.form.GetBool("quit")
- if v {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, helpKeys.LeftRight) || key.Matches(msg, helpKeys.Tab):
+ q.selectedNo = !q.selectedNo
+ return q, nil
+ case key.Matches(msg, helpKeys.EnterSpace):
+ if !q.selectedNo {
+ return q, tea.Quit
+ }
+ return q, util.CmdHandler(CloseQuitMsg{})
+ case key.Matches(msg, helpKeys.Yes):
return q, tea.Quit
+ case key.Matches(msg, helpKeys.No):
+ return q, util.CmdHandler(CloseQuitMsg{})
}
- cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{}))
}
-
- return q, tea.Batch(cmds...)
+ return q, nil
}
func (q *quitDialogCmp) View() string {
- return q.form.View()
-}
+ yesStyle := styles.BaseStyle
+ noStyle := styles.BaseStyle
+ spacerStyle := styles.BaseStyle.Background(styles.Background)
+
+ if q.selectedNo {
+ noStyle = noStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ yesStyle = yesStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ } else {
+ yesStyle = yesStyle.Background(styles.PrimaryColor).Foreground(styles.Background)
+ noStyle = noStyle.Background(styles.Background).Foreground(styles.PrimaryColor)
+ }
-func (q *quitDialogCmp) GetSize() (int, int) {
- return q.width, q.height
-}
+ yesButton := yesStyle.Padding(0, 1).Render("Yes")
+ noButton := noStyle.Padding(0, 1).Render("No")
+
+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, spacerStyle.Render(" "), noButton)
+
+ width := lipgloss.Width(question)
+ remainingWidth := width - lipgloss.Width(buttons)
+ if remainingWidth > 0 {
+ buttons = spacerStyle.Render(strings.Repeat(" ", remainingWidth)) + buttons
+ }
-func (q *quitDialogCmp) SetSize(width int, height int) {
- q.width = width
- q.height = height
- q.form = q.form.WithWidth(width).WithHeight(height)
+ content := styles.BaseStyle.Render(
+ lipgloss.JoinVertical(
+ lipgloss.Center,
+ question,
+ "",
+ buttons,
+ ),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
}
func (q *quitDialogCmp) BindingKeys() []key.Binding {
- return q.form.KeyBinds()
+ return layout.KeyMapToSlice(helpKeys)
}
-func newQuitDialogCmp() QuitDialog {
- confirm := huh.NewConfirm().
- Title(question).
- Affirmative("Yes!").
- Key("quit").
- Negative("No.")
-
- theme := styles.HuhTheme()
- theme.Focused.FocusedButton = theme.Focused.FocusedButton.Background(styles.Warning)
- theme.Blurred.FocusedButton = theme.Blurred.FocusedButton.Background(styles.Warning)
- form := huh.NewForm(huh.NewGroup(confirm)).
- WithShowHelp(false).
- WithWidth(0).
- WithHeight(0).
- WithTheme(theme).
- WithShowErrors(false)
- confirm.Focus()
+func NewQuitCmp() QuitDialog {
return &quitDialogCmp{
- form: form,
+ selectedNo: true,
}
}
-
-func NewQuitDialogCmd() tea.Cmd {
- content := layout.NewSinglePane(
- newQuitDialogCmp().(*quitDialogCmp),
- layout.WithSinglePaneBordered(true),
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneActiveColor(styles.Warning),
- )
- content.Focus()
- return util.CmdHandler(core.DialogMsg{
- Content: content,
- WidthRatio: 0.2,
- HeightRatio: 0.1,
- MinWidth: 40,
- MinHeight: 5,
- })
-}
diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go
new file mode 100644
index 000000000..060875f91
--- /dev/null
+++ b/internal/tui/components/dialog/session.go
@@ -0,0 +1,226 @@
+package dialog
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+// SessionSelectedMsg is sent when a session is selected
+type SessionSelectedMsg struct {
+ Session session.Session
+}
+
+// CloseSessionDialogMsg is sent when the session dialog is closed
+type CloseSessionDialogMsg struct{}
+
+// SessionDialog interface for the session switching dialog
+type SessionDialog interface {
+ tea.Model
+ layout.Bindings
+ SetSessions(sessions []session.Session)
+ SetSelectedSession(sessionID string)
+}
+
+type sessionDialogCmp struct {
+ sessions []session.Session
+ selectedIdx int
+ width int
+ height int
+ selectedSessionID string
+}
+
+type sessionKeyMap struct {
+ Up key.Binding
+ Down key.Binding
+ Enter key.Binding
+ Escape key.Binding
+ J key.Binding
+ K key.Binding
+}
+
+var sessionKeys = sessionKeyMap{
+ Up: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("↑", "previous session"),
+ ),
+ Down: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("↓", "next session"),
+ ),
+ Enter: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "select session"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ J: key.NewBinding(
+ key.WithKeys("j"),
+ key.WithHelp("j", "next session"),
+ ),
+ K: key.NewBinding(
+ key.WithKeys("k"),
+ key.WithHelp("k", "previous session"),
+ ),
+}
+
+func (s *sessionDialogCmp) Init() tea.Cmd {
+ return nil
+}
+
+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
+ if s.selectedIdx > 0 {
+ s.selectedIdx--
+ }
+ return s, nil
+ case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
+ if s.selectedIdx < len(s.sessions)-1 {
+ s.selectedIdx++
+ }
+ return s, nil
+ case key.Matches(msg, sessionKeys.Enter):
+ if len(s.sessions) > 0 {
+ return s, util.CmdHandler(SessionSelectedMsg{
+ Session: s.sessions[s.selectedIdx],
+ })
+ }
+ case key.Matches(msg, sessionKeys.Escape):
+ return s, util.CmdHandler(CloseSessionDialogMsg{})
+ }
+ case tea.WindowSizeMsg:
+ s.width = msg.Width
+ s.height = msg.Height
+ }
+ return s, nil
+}
+
+func (s *sessionDialogCmp) View() string {
+ if len(s.sessions) == 0 {
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(40).
+ Render("No sessions available")
+ }
+
+ // Calculate max width needed for session titles
+ maxWidth := 40 // Minimum width
+ for _, sess := range s.sessions {
+ if len(sess.Title) > maxWidth-4 { // Account for padding
+ maxWidth = len(sess.Title) + 4
+ }
+ }
+
+ // Limit height to avoid taking up too much screen space
+ maxVisibleSessions := min(10, len(s.sessions))
+
+ // Build the session list
+ sessionItems := make([]string, 0, maxVisibleSessions)
+ startIdx := 0
+
+ // If we have more sessions than can be displayed, adjust the start index
+ if len(s.sessions) > maxVisibleSessions {
+ // Center the selected item when possible
+ halfVisible := maxVisibleSessions / 2
+ if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
+ startIdx = s.selectedIdx - halfVisible
+ } else if s.selectedIdx >= len(s.sessions)-halfVisible {
+ startIdx = len(s.sessions) - maxVisibleSessions
+ }
+ }
+
+ endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
+
+ for i := startIdx; i < endIdx; i++ {
+ sess := s.sessions[i]
+ itemStyle := styles.BaseStyle.Width(maxWidth)
+
+ if i == s.selectedIdx {
+ itemStyle = itemStyle.
+ Background(styles.PrimaryColor).
+ Foreground(styles.Background).
+ Bold(true)
+ }
+
+ sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
+ }
+
+ title := styles.BaseStyle.
+ Foreground(styles.PrimaryColor).
+ Bold(true).
+ Width(maxWidth).
+ Padding(0, 1).
+ Render("Switch Session")
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ title,
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
+ styles.BaseStyle.Width(maxWidth).Render(""),
+ styles.BaseStyle.Width(maxWidth).Padding(0, 1).Foreground(styles.ForgroundDim).Render("↑/k: up ↓/j: down enter: select esc: cancel"),
+ )
+
+ return styles.BaseStyle.Padding(1, 2).
+ Border(lipgloss.RoundedBorder()).
+ BorderBackground(styles.Background).
+ BorderForeground(styles.ForgroundDim).
+ Width(lipgloss.Width(content) + 4).
+ Render(content)
+}
+
+func (s *sessionDialogCmp) BindingKeys() []key.Binding {
+ return layout.KeyMapToSlice(sessionKeys)
+}
+
+func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
+ s.sessions = sessions
+
+ // If we have a selected session ID, find its index
+ if s.selectedSessionID != "" {
+ for i, sess := range sessions {
+ if sess.ID == s.selectedSessionID {
+ s.selectedIdx = i
+ return
+ }
+ }
+ }
+
+ // Default to first session if selected not found
+ s.selectedIdx = 0
+}
+
+func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
+ s.selectedSessionID = sessionID
+
+ // Update the selected index if sessions are already loaded
+ if len(s.sessions) > 0 {
+ for i, sess := range s.sessions {
+ if sess.ID == sessionID {
+ s.selectedIdx = i
+ return
+ }
+ }
+ }
+}
+
+// NewSessionDialogCmp creates a new session switching dialog
+func NewSessionDialogCmp() SessionDialog {
+ return &sessionDialogCmp{
+ sessions: []session.Session{},
+ selectedIdx: 0,
+ selectedSessionID: "",
+ }
+}
+
diff --git a/internal/tui/components/logs/details.go b/internal/tui/components/logs/details.go
index dbace5508..fa49adbbb 100644
--- a/internal/tui/components/logs/details.go
+++ b/internal/tui/components/logs/details.go
@@ -9,22 +9,19 @@ import (
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/logging"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/logging"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
)
type DetailComponent interface {
tea.Model
- layout.Focusable
layout.Sizeable
layout.Bindings
- layout.Bordered
}
type detailCmp struct {
width, height int
- focused bool
currentLog logging.LogMessage
viewport viewport.Model
}
@@ -39,11 +36,6 @@ func (i *detailCmp) Init() tea.Cmd {
}
func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var (
- cmd tea.Cmd
- cmds []tea.Cmd
- )
-
switch msg := msg.(type) {
case selectedLogMsg:
if msg.ID != i.currentLog.ID {
@@ -52,12 +44,7 @@ func (i *detailCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
- if i.focused {
- i.viewport, cmd = i.viewport.Update(msg)
- cmds = append(cmds, cmd)
- }
-
- return i, tea.Batch(cmds...)
+ return i, nil
}
func (i *detailCmp) updateContent() {
@@ -125,48 +112,24 @@ func getLevelStyle(level string) lipgloss.Style {
}
func (i *detailCmp) View() string {
- return i.viewport.View()
-}
-
-func (i *detailCmp) Blur() tea.Cmd {
- i.focused = false
- return nil
-}
-
-func (i *detailCmp) Focus() tea.Cmd {
- i.focused = true
- return nil
-}
-
-func (i *detailCmp) IsFocused() bool {
- return i.focused
+ return styles.ForceReplaceBackgroundWithLipgloss(i.viewport.View(), styles.Background)
}
func (i *detailCmp) GetSize() (int, int) {
return i.width, i.height
}
-func (i *detailCmp) SetSize(width int, height int) {
+func (i *detailCmp) SetSize(width int, height int) tea.Cmd {
i.width = width
i.height = height
i.viewport.Width = i.width
i.viewport.Height = i.height
i.updateContent()
+ return nil
}
func (i *detailCmp) BindingKeys() []key.Binding {
- return []key.Binding{
- i.viewport.KeyMap.PageDown,
- i.viewport.KeyMap.PageUp,
- i.viewport.KeyMap.HalfPageDown,
- i.viewport.KeyMap.HalfPageUp,
- }
-}
-
-func (i *detailCmp) BorderText() map[layout.BorderPosition]string {
- return map[layout.BorderPosition]string{
- layout.TopLeftBorder: "Log Details",
- }
+ return layout.KeyMapToSlice(i.viewport.KeyMap)
}
func NewLogsDetails() DetailComponent {
diff --git a/internal/tui/components/logs/table.go b/internal/tui/components/logs/table.go
index 9500059b1..245714d0d 100644
--- a/internal/tui/components/logs/table.go
+++ b/internal/tui/components/logs/table.go
@@ -7,31 +7,23 @@ import (
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/logging"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
+ "github.com/kujtimiihoxha/opencode/internal/logging"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
type TableComponent interface {
tea.Model
- layout.Focusable
layout.Sizeable
layout.Bindings
- layout.Bordered
}
type tableCmp struct {
table table.Model
}
-func (i *tableCmp) BorderText() map[layout.BorderPosition]string {
- return map[layout.BorderPosition]string{
- layout.TopLeftBorder: "Logs",
- }
-}
-
type selectedLogMsg logging.LogMessage
func (i *tableCmp) Init() tea.Cmd {
@@ -41,58 +33,42 @@ func (i *tableCmp) Init() tea.Cmd {
func (i *tableCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
- if i.table.Focused() {
- switch msg.(type) {
- case pubsub.Event[logging.LogMessage]:
- i.setRows()
- return i, nil
- }
- prevSelectedRow := i.table.SelectedRow()
- t, cmd := i.table.Update(msg)
- cmds = append(cmds, cmd)
- i.table = t
- selectedRow := i.table.SelectedRow()
- if selectedRow != nil {
- if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
- var log logging.LogMessage
- for _, row := range logging.List() {
- if row.ID == selectedRow[0] {
- log = row
- break
- }
- }
- if log.ID != "" {
- cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
+ switch msg.(type) {
+ case pubsub.Event[logging.LogMessage]:
+ i.setRows()
+ return i, nil
+ }
+ prevSelectedRow := i.table.SelectedRow()
+ t, cmd := i.table.Update(msg)
+ cmds = append(cmds, cmd)
+ i.table = t
+ selectedRow := i.table.SelectedRow()
+ if selectedRow != nil {
+ if prevSelectedRow == nil || selectedRow[0] == prevSelectedRow[0] {
+ var log logging.LogMessage
+ for _, row := range logging.List() {
+ if row.ID == selectedRow[0] {
+ log = row
+ break
}
}
+ if log.ID != "" {
+ cmds = append(cmds, util.CmdHandler(selectedLogMsg(log)))
+ }
}
}
return i, tea.Batch(cmds...)
}
func (i *tableCmp) View() string {
- return i.table.View()
-}
-
-func (i *tableCmp) Blur() tea.Cmd {
- i.table.Blur()
- return nil
-}
-
-func (i *tableCmp) Focus() tea.Cmd {
- i.table.Focus()
- return nil
-}
-
-func (i *tableCmp) IsFocused() bool {
- return i.table.Focused()
+ return styles.ForceReplaceBackgroundWithLipgloss(i.table.View(), styles.Background)
}
func (i *tableCmp) GetSize() (int, int) {
return i.table.Width(), i.table.Height()
}
-func (i *tableCmp) SetSize(width int, height int) {
+func (i *tableCmp) SetSize(width int, height int) tea.Cmd {
i.table.SetWidth(width)
i.table.SetHeight(height)
cloumns := i.table.Columns()
@@ -101,6 +77,7 @@ func (i *tableCmp) SetSize(width int, height int) {
cloumns[i] = col
}
i.table.SetColumns(cloumns)
+ return nil
}
func (i *tableCmp) BindingKeys() []key.Binding {
@@ -150,6 +127,7 @@ func NewLogsTable() TableComponent {
table.WithColumns(columns),
table.WithStyles(defaultStyles),
)
+ tableModel.Focus()
return &tableCmp{
table: tableModel,
}
diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go
deleted file mode 100644
index 37ac275e3..000000000
--- a/internal/tui/components/repl/editor.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package repl
-
-import (
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/llm/agent"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/kujtimiihoxha/vimtea"
- "golang.org/x/net/context"
-)
-
-type EditorCmp interface {
- tea.Model
- layout.Focusable
- layout.Sizeable
- layout.Bordered
- layout.Bindings
-}
-
-type editorCmp struct {
- app *app.App
- editor vimtea.Editor
- editorMode vimtea.EditorMode
- sessionID string
- focused bool
- width int
- height int
- cancelMessage context.CancelFunc
-}
-
-type editorKeyMap struct {
- SendMessage key.Binding
- SendMessageI key.Binding
- CancelMessage key.Binding
- InsertMode key.Binding
- NormaMode key.Binding
- VisualMode key.Binding
- VisualLineMode key.Binding
-}
-
-var editorKeyMapValue = editorKeyMap{
- SendMessage: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "send message normal mode"),
- ),
- SendMessageI: key.NewBinding(
- key.WithKeys("ctrl+s"),
- key.WithHelp("ctrl+s", "send message insert mode"),
- ),
- CancelMessage: key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("ctrl+x", "cancel current message"),
- ),
- InsertMode: key.NewBinding(
- key.WithKeys("i"),
- key.WithHelp("i", "insert mode"),
- ),
- NormaMode: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "normal mode"),
- ),
- VisualMode: key.NewBinding(
- key.WithKeys("v"),
- key.WithHelp("v", "visual mode"),
- ),
- VisualLineMode: key.NewBinding(
- key.WithKeys("V"),
- key.WithHelp("V", "visual line mode"),
- ),
-}
-
-func (m *editorCmp) Init() tea.Cmd {
- return m.editor.Init()
-}
-
-func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case vimtea.EditorModeMsg:
- m.editorMode = msg.Mode
- case SelectedSessionMsg:
- if msg.SessionID != m.sessionID {
- m.sessionID = msg.SessionID
- }
- }
- if m.IsFocused() {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, editorKeyMapValue.SendMessage):
- if m.editorMode == vimtea.ModeNormal {
- return m, m.Send()
- }
- case key.Matches(msg, editorKeyMapValue.SendMessageI):
- if m.editorMode == vimtea.ModeInsert {
- return m, m.Send()
- }
- case key.Matches(msg, editorKeyMapValue.CancelMessage):
- return m, m.Cancel()
- }
- }
- u, cmd := m.editor.Update(msg)
- m.editor = u.(vimtea.Editor)
- return m, cmd
- }
- return m, nil
-}
-
-func (m *editorCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-func (m *editorCmp) BorderText() map[layout.BorderPosition]string {
- title := "New Message"
- if m.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- return map[layout.BorderPosition]string{
- layout.BottomLeftBorder: title,
- }
-}
-
-func (m *editorCmp) Focus() tea.Cmd {
- m.focused = true
- return m.editor.Tick()
-}
-
-func (m *editorCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *editorCmp) IsFocused() bool {
- return m.focused
-}
-
-func (m *editorCmp) SetSize(width int, height int) {
- m.width = width
- m.height = height
- m.editor.SetSize(width, height)
-}
-
-func (m *editorCmp) Cancel() tea.Cmd {
- if m.cancelMessage == nil {
- return util.ReportWarn("No message to cancel")
- }
-
- m.cancelMessage()
- m.cancelMessage = nil
- return util.ReportWarn("Message cancelled")
-}
-
-func (m *editorCmp) Send() tea.Cmd {
- return func() tea.Msg {
- messages, err := m.app.Messages.List(m.sessionID)
- if err != nil {
- return util.ReportError(err)
- }
- if hasUnfinishedMessages(messages) {
- return util.ReportWarn("Assistant is still working on the previous message")
- }
- a, err := agent.NewCoderAgent(m.app)
- if err != nil {
- return util.ReportError(err)
- }
-
- content := strings.Join(m.editor.GetBuffer().Lines(), "\n")
- ctx, cancel := context.WithCancel(m.app.Context)
- m.cancelMessage = cancel
- go func() {
- defer cancel()
- a.Generate(ctx, m.sessionID, content)
- m.cancelMessage = nil
- }()
-
- return m.editor.Reset()
- }
-}
-
-func (m *editorCmp) View() string {
- return m.editor.View()
-}
-
-func (m *editorCmp) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(editorKeyMapValue)
-}
-
-func NewEditorCmp(app *app.App) EditorCmp {
- editor := vimtea.NewEditor(
- vimtea.WithFileName("message.md"),
- )
- return &editorCmp{
- app: app,
- editor: editor,
- }
-}
diff --git a/internal/tui/components/repl/messages.go b/internal/tui/components/repl/messages.go
deleted file mode 100644
index 57a55c579..000000000
--- a/internal/tui/components/repl/messages.go
+++ /dev/null
@@ -1,512 +0,0 @@
-package repl
-
-import (
- "encoding/json"
- "fmt"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/glamour"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/llm/agent"
- "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
- "github.com/kujtimiihoxha/termai/internal/message"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/session"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-type MessagesCmp interface {
- tea.Model
- layout.Focusable
- layout.Bordered
- layout.Sizeable
- layout.Bindings
-}
-
-type messagesCmp struct {
- app *app.App
- messages []message.Message
- selectedMsgIdx int // Index of the selected message
- session session.Session
- viewport viewport.Model
- mdRenderer *glamour.TermRenderer
- width int
- height int
- focused bool
- cachedView string
-}
-
-func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case pubsub.Event[message.Message]:
- if msg.Type == pubsub.CreatedEvent {
- if msg.Payload.SessionID == m.session.ID {
- m.messages = append(m.messages, msg.Payload)
- m.renderView()
- m.viewport.GotoBottom()
- }
- for _, v := range m.messages {
- for _, c := range v.ToolCalls() {
- // the message is being added to the session of a tool called
- if c.ID == msg.Payload.SessionID {
- m.renderView()
- m.viewport.GotoBottom()
- }
- }
- }
- } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
- for i, v := range m.messages {
- if v.ID == msg.Payload.ID {
- m.messages[i] = msg.Payload
- m.renderView()
- if i == len(m.messages)-1 {
- m.viewport.GotoBottom()
- }
- break
- }
- }
- }
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent && m.session.ID == msg.Payload.ID {
- m.session = msg.Payload
- }
- case SelectedSessionMsg:
- m.session, _ = m.app.Sessions.Get(msg.SessionID)
- m.messages, _ = m.app.Messages.List(m.session.ID)
- m.renderView()
- m.viewport.GotoBottom()
- }
- if m.focused {
- u, cmd := m.viewport.Update(msg)
- m.viewport = u
- return m, cmd
- }
- return m, nil
-}
-
-func borderColor(role message.MessageRole) lipgloss.TerminalColor {
- switch role {
- case message.Assistant:
- return styles.Mauve
- case message.User:
- return styles.Rosewater
- }
- return styles.Blue
-}
-
-func borderText(msgRole message.MessageRole, currentMessage int) map[layout.BorderPosition]string {
- role := ""
- icon := ""
- switch msgRole {
- case message.Assistant:
- role = "Assistant"
- icon = styles.BotIcon
- case message.User:
- role = "User"
- icon = styles.UserIcon
- }
- return map[layout.BorderPosition]string{
- layout.TopLeftBorder: lipgloss.NewStyle().
- Padding(0, 1).
- Bold(true).
- Foreground(styles.Crust).
- Background(borderColor(msgRole)).
- Render(fmt.Sprintf("%s %s ", role, icon)),
- layout.TopRightBorder: lipgloss.NewStyle().
- Padding(0, 1).
- Bold(true).
- Foreground(styles.Crust).
- Background(borderColor(msgRole)).
- Render(fmt.Sprintf("#%d ", currentMessage)),
- }
-}
-
-func hasUnfinishedMessages(messages []message.Message) bool {
- if len(messages) == 0 {
- return false
- }
- for _, msg := range messages {
- if !msg.IsFinished() {
- return true
- }
- }
- return false
-}
-
-func (m *messagesCmp) renderMessageWithToolCall(content string, tools []message.ToolCall, futureMessages []message.Message) string {
- allParts := []string{content}
-
- leftPaddingValue := 4
- connectorStyle := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true)
-
- toolCallStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Peach).
- Width(m.width-leftPaddingValue-5).
- Padding(0, 1)
-
- toolResultStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Green).
- Width(m.width-leftPaddingValue-5).
- Padding(0, 1)
-
- leftPadding := lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue)
-
- runningStyle := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true)
-
- renderTool := func(toolCall message.ToolCall) string {
- toolHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Blue).
- Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
-
- var paramLines []string
- var args map[string]interface{}
- var paramOrder []string
-
- json.Unmarshal([]byte(toolCall.Input), &args)
-
- for key := range args {
- paramOrder = append(paramOrder, key)
- }
- sort.Strings(paramOrder)
-
- for _, name := range paramOrder {
- value := args[name]
- paramName := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true).
- Render(name)
-
- truncate := m.width - leftPaddingValue*2 - 10
- if len(fmt.Sprintf("%v", value)) > truncate {
- value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
- paramValue := fmt.Sprintf("%v", value)
- paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
- }
-
- paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
-
- toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
- return toolCallStyle.Render(toolContent)
- }
-
- findToolResult := func(toolCallID string, messages []message.Message) *message.ToolResult {
- for _, msg := range messages {
- if msg.Role == message.Tool {
- for _, result := range msg.ToolResults() {
- if result.ToolCallID == toolCallID {
- return &result
- }
- }
- }
- }
- return nil
- }
-
- renderToolResult := func(result message.ToolResult) string {
- resultHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Green).
- Render(fmt.Sprintf("%s Result", styles.CheckIcon))
-
- // Use the same style for both header and border if it's an error
- borderColor := styles.Green
- if result.IsError {
- resultHeader = lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Red).
- Render(fmt.Sprintf("%s Error", styles.ErrorIcon))
- borderColor = styles.Red
- }
-
- truncate := 200
- content := result.Content
- if len(content) > truncate {
- content = content[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
-
- resultContent := lipgloss.JoinVertical(lipgloss.Left, resultHeader, content)
- return toolResultStyle.BorderForeground(borderColor).Render(resultContent)
- }
-
- connector := connectorStyle.Render("└─> Tool Calls:")
- allParts = append(allParts, connector)
-
- for _, toolCall := range tools {
- toolOutput := renderTool(toolCall)
- allParts = append(allParts, leftPadding.Render(toolOutput))
-
- result := findToolResult(toolCall.ID, futureMessages)
- if result != nil {
-
- resultOutput := renderToolResult(*result)
- allParts = append(allParts, leftPadding.Render(resultOutput))
-
- } else if toolCall.Name == agent.AgentToolName {
-
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, leftPadding.Render(runningIndicator))
- taskSessionMessages, _ := m.app.Messages.List(toolCall.ID)
- for _, msg := range taskSessionMessages {
- if msg.Role == message.Assistant {
- for _, toolCall := range msg.ToolCalls() {
- toolHeader := lipgloss.NewStyle().
- Bold(true).
- Foreground(styles.Blue).
- Render(fmt.Sprintf("%s %s", styles.ToolIcon, toolCall.Name))
-
- var paramLines []string
- var args map[string]interface{}
- var paramOrder []string
-
- json.Unmarshal([]byte(toolCall.Input), &args)
-
- for key := range args {
- paramOrder = append(paramOrder, key)
- }
- sort.Strings(paramOrder)
-
- for _, name := range paramOrder {
- value := args[name]
- paramName := lipgloss.NewStyle().
- Foreground(styles.Peach).
- Bold(true).
- Render(name)
-
- truncate := 50
- if len(fmt.Sprintf("%v", value)) > truncate {
- value = fmt.Sprintf("%v", value)[:truncate] + lipgloss.NewStyle().Foreground(styles.Blue).Render("... (truncated)")
- }
- paramValue := fmt.Sprintf("%v", value)
- paramLines = append(paramLines, fmt.Sprintf(" %s: %s", paramName, paramValue))
- }
-
- paramBlock := lipgloss.JoinVertical(lipgloss.Left, paramLines...)
- toolContent := lipgloss.JoinVertical(lipgloss.Left, toolHeader, paramBlock)
- toolOutput := toolCallStyle.BorderForeground(styles.Teal).MaxWidth(m.width - leftPaddingValue*2 - 2).Render(toolContent)
- allParts = append(allParts, lipgloss.NewStyle().Padding(0, 0, 0, leftPaddingValue*2).Render(toolOutput))
- }
- }
- }
-
- } else {
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, " "+runningIndicator)
- }
- }
-
- for _, msg := range futureMessages {
- if msg.Content().String() != "" || msg.FinishReason() == "canceled" {
- break
- }
-
- for _, toolCall := range msg.ToolCalls() {
- toolOutput := renderTool(toolCall)
- allParts = append(allParts, " "+strings.ReplaceAll(toolOutput, "\n", "\n "))
-
- result := findToolResult(toolCall.ID, futureMessages)
- if result != nil {
- resultOutput := renderToolResult(*result)
- allParts = append(allParts, " "+strings.ReplaceAll(resultOutput, "\n", "\n "))
- } else {
- runningIndicator := runningStyle.Render(fmt.Sprintf("%s Running...", styles.SpinnerIcon))
- allParts = append(allParts, " "+runningIndicator)
- }
- }
- }
-
- return lipgloss.JoinVertical(lipgloss.Left, allParts...)
-}
-
-func (m *messagesCmp) renderView() {
- stringMessages := make([]string, 0)
- r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(styles.CatppuccinMarkdownStyle()),
- glamour.WithWordWrap(m.width-20),
- glamour.WithEmoji(),
- )
- textStyle := lipgloss.NewStyle().Width(m.width - 4)
- currentMessage := 1
- displayedMsgCount := 0 // Track the actual displayed messages count
-
- prevMessageWasUser := false
- for inx, msg := range m.messages {
- content := msg.Content().String()
- if content != "" || prevMessageWasUser || msg.FinishReason() == "canceled" {
- if msg.ReasoningContent().String() != "" && content == "" {
- content = msg.ReasoningContent().String()
- } else if content == "" {
- content = "..."
- }
- if msg.FinishReason() == "canceled" {
- content, _ = r.Render(content)
- content += lipgloss.NewStyle().Padding(1, 0, 0, 1).Foreground(styles.Error).Render(styles.ErrorIcon + " Canceled")
- } else {
- content, _ = r.Render(content)
- }
-
- isSelected := inx == m.selectedMsgIdx
-
- border := lipgloss.DoubleBorder()
- activeColor := borderColor(msg.Role)
-
- if isSelected {
- activeColor = styles.Primary // Use primary color for selected message
- }
-
- content = layout.Borderize(
- textStyle.Render(content),
- layout.BorderOptions{
- InactiveBorder: border,
- ActiveBorder: border,
- ActiveColor: activeColor,
- InactiveColor: borderColor(msg.Role),
- EmbeddedText: borderText(msg.Role, currentMessage),
- },
- )
- if len(msg.ToolCalls()) > 0 {
- content = m.renderMessageWithToolCall(content, msg.ToolCalls(), m.messages[inx+1:])
- }
- stringMessages = append(stringMessages, content)
- currentMessage++
- displayedMsgCount++
- }
- if msg.Role == message.User && msg.Content().String() != "" {
- prevMessageWasUser = true
- } else {
- prevMessageWasUser = false
- }
- }
- m.viewport.SetContent(lipgloss.JoinVertical(lipgloss.Top, stringMessages...))
-}
-
-func (m *messagesCmp) View() string {
- return lipgloss.NewStyle().Padding(1).Render(m.viewport.View())
-}
-
-func (m *messagesCmp) BindingKeys() []key.Binding {
- keys := layout.KeyMapToSlice(m.viewport.KeyMap)
-
- return keys
-}
-
-func (m *messagesCmp) Blur() tea.Cmd {
- m.focused = false
- return nil
-}
-
-func (m *messagesCmp) projectDiagnostics() string {
- errorDiagnostics := []protocol.Diagnostic{}
- warnDiagnostics := []protocol.Diagnostic{}
- hintDiagnostics := []protocol.Diagnostic{}
- infoDiagnostics := []protocol.Diagnostic{}
- for _, client := range m.app.LSPClients {
- for _, d := range client.GetDiagnostics() {
- for _, diag := range d {
- switch diag.Severity {
- case protocol.SeverityError:
- errorDiagnostics = append(errorDiagnostics, diag)
- case protocol.SeverityWarning:
- warnDiagnostics = append(warnDiagnostics, diag)
- case protocol.SeverityHint:
- hintDiagnostics = append(hintDiagnostics, diag)
- case protocol.SeverityInformation:
- infoDiagnostics = append(infoDiagnostics, diag)
- }
- }
- }
- }
-
- if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
- return "No diagnostics"
- }
-
- diagnostics := []string{}
-
- if len(errorDiagnostics) > 0 {
- errStr := lipgloss.NewStyle().Foreground(styles.Error).Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
- diagnostics = append(diagnostics, errStr)
- }
- if len(warnDiagnostics) > 0 {
- warnStr := lipgloss.NewStyle().Foreground(styles.Warning).Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
- diagnostics = append(diagnostics, warnStr)
- }
- if len(hintDiagnostics) > 0 {
- hintStr := lipgloss.NewStyle().Foreground(styles.Text).Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
- diagnostics = append(diagnostics, hintStr)
- }
- if len(infoDiagnostics) > 0 {
- infoStr := lipgloss.NewStyle().Foreground(styles.Peach).Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
- diagnostics = append(diagnostics, infoStr)
- }
-
- return strings.Join(diagnostics, " ")
-}
-
-func (m *messagesCmp) BorderText() map[layout.BorderPosition]string {
- title := m.session.Title
- titleWidth := m.width / 2
- if len(title) > titleWidth {
- title = title[:titleWidth] + "..."
- }
- if m.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- borderTest := map[layout.BorderPosition]string{
- layout.TopLeftBorder: title,
- layout.BottomRightBorder: m.projectDiagnostics(),
- }
- if hasUnfinishedMessages(m.messages) {
- borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Peach).Render("Thinking...")
- } else {
- borderTest[layout.BottomLeftBorder] = lipgloss.NewStyle().Foreground(styles.Text).Render("Sleeping " + styles.SleepIcon + " ")
- }
-
- return borderTest
-}
-
-func (m *messagesCmp) Focus() tea.Cmd {
- m.focused = true
- return nil
-}
-
-func (m *messagesCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func (m *messagesCmp) IsFocused() bool {
- return m.focused
-}
-
-func (m *messagesCmp) SetSize(width int, height int) {
- m.width = width
- m.height = height
- m.viewport.Width = width - 2 // padding
- m.viewport.Height = height - 2 // padding
- m.renderView()
-}
-
-func (m *messagesCmp) Init() tea.Cmd {
- return nil
-}
-
-func NewMessagesCmp(app *app.App) MessagesCmp {
- return &messagesCmp{
- app: app,
- messages: []message.Message{},
- viewport: viewport.New(0, 0),
- }
-}
diff --git a/internal/tui/components/repl/sessions.go b/internal/tui/components/repl/sessions.go
deleted file mode 100644
index 093337b18..000000000
--- a/internal/tui/components/repl/sessions.go
+++ /dev/null
@@ -1,247 +0,0 @@
-package repl
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/list"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/session"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
-)
-
-type SessionsCmp interface {
- tea.Model
- layout.Sizeable
- layout.Focusable
- layout.Bordered
- layout.Bindings
-}
-type sessionsCmp struct {
- app *app.App
- list list.Model
- focused bool
-}
-
-type listItem struct {
- id, title, desc string
-}
-
-func (i listItem) Title() string { return i.title }
-func (i listItem) Description() string { return i.desc }
-func (i listItem) FilterValue() string { return i.title }
-
-type InsertSessionsMsg struct {
- sessions []session.Session
-}
-
-type SelectedSessionMsg struct {
- SessionID string
-}
-
-type sessionsKeyMap struct {
- Select key.Binding
-}
-
-var sessionKeyMapValue = sessionsKeyMap{
- Select: key.NewBinding(
- key.WithKeys("enter", " "),
- key.WithHelp("enter/space", "select session"),
- ),
-}
-
-func (i *sessionsCmp) Init() tea.Cmd {
- existing, err := i.app.Sessions.List()
- if err != nil {
- return util.ReportError(err)
- }
- if len(existing) == 0 || existing[0].MessageCount > 0 {
- newSession, err := i.app.Sessions.Create(
- "New Session",
- )
- if err != nil {
- return util.ReportError(err)
- }
- existing = append([]session.Session{newSession}, existing...)
- }
- return tea.Batch(
- util.CmdHandler(InsertSessionsMsg{existing}),
- util.CmdHandler(SelectedSessionMsg{existing[0].ID}),
- )
-}
-
-func (i *sessionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case InsertSessionsMsg:
- items := make([]list.Item, len(msg.sessions))
- for i, s := range msg.sessions {
- items[i] = listItem{
- id: s.ID,
- title: s.Title,
- desc: formatTokensAndCost(s.PromptTokens+s.CompletionTokens, s.Cost),
- }
- }
- return i, i.list.SetItems(items)
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.CreatedEvent && msg.Payload.ParentSessionID == "" {
- // Check if the session is already in the list
- items := i.list.Items()
- for _, item := range items {
- s := item.(listItem)
- if s.id == msg.Payload.ID {
- return i, nil
- }
- }
- // insert the new session at the top of the list
- items = append([]list.Item{listItem{
- id: msg.Payload.ID,
- title: msg.Payload.Title,
- desc: formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost),
- }}, items...)
- return i, i.list.SetItems(items)
- } else if msg.Type == pubsub.UpdatedEvent {
- // update the session in the list
- items := i.list.Items()
- for idx, item := range items {
- s := item.(listItem)
- if s.id == msg.Payload.ID {
- s.title = msg.Payload.Title
- s.desc = formatTokensAndCost(msg.Payload.PromptTokens+msg.Payload.CompletionTokens, msg.Payload.Cost)
- items[idx] = s
- break
- }
- }
- return i, i.list.SetItems(items)
- }
-
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, sessionKeyMapValue.Select):
- selected := i.list.SelectedItem()
- if selected == nil {
- return i, nil
- }
- return i, util.CmdHandler(SelectedSessionMsg{selected.(listItem).id})
- }
- }
- if i.focused {
- u, cmd := i.list.Update(msg)
- i.list = u
- return i, cmd
- }
- return i, nil
-}
-
-func (i *sessionsCmp) View() string {
- return i.list.View()
-}
-
-func (i *sessionsCmp) Blur() tea.Cmd {
- i.focused = false
- return nil
-}
-
-func (i *sessionsCmp) Focus() tea.Cmd {
- i.focused = true
- return nil
-}
-
-func (i *sessionsCmp) GetSize() (int, int) {
- return i.list.Width(), i.list.Height()
-}
-
-func (i *sessionsCmp) IsFocused() bool {
- return i.focused
-}
-
-func (i *sessionsCmp) SetSize(width int, height int) {
- i.list.SetSize(width, height)
-}
-
-func (i *sessionsCmp) BorderText() map[layout.BorderPosition]string {
- totalCount := len(i.list.Items())
- itemsPerPage := i.list.Paginator.PerPage
- currentPage := i.list.Paginator.Page
-
- current := min(currentPage*itemsPerPage+itemsPerPage, totalCount)
-
- pageInfo := fmt.Sprintf(
- "%d-%d of %d",
- currentPage*itemsPerPage+1,
- current,
- totalCount,
- )
-
- title := "Sessions"
- if i.focused {
- title = lipgloss.NewStyle().Foreground(styles.Primary).Render(title)
- }
- return map[layout.BorderPosition]string{
- layout.TopMiddleBorder: title,
- layout.BottomMiddleBorder: pageInfo,
- }
-}
-
-func (i *sessionsCmp) BindingKeys() []key.Binding {
- return append(layout.KeyMapToSlice(i.list.KeyMap), sessionKeyMapValue.Select)
-}
-
-func formatTokensAndCost(tokens int64, cost float64) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", tokens)
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- // Format cost with $ symbol and 2 decimal places
- formattedCost := fmt.Sprintf("$%.2f", cost)
-
- return fmt.Sprintf("Tokens: %s, Cost: %s", formattedTokens, formattedCost)
-}
-
-func NewSessionsCmp(app *app.App) SessionsCmp {
- listDelegate := list.NewDefaultDelegate()
- defaultItemStyle := list.NewDefaultItemStyles()
- defaultItemStyle.SelectedTitle = defaultItemStyle.SelectedTitle.BorderForeground(styles.Secondary).Foreground(styles.Primary)
- defaultItemStyle.SelectedDesc = defaultItemStyle.SelectedDesc.BorderForeground(styles.Secondary).Foreground(styles.Primary)
-
- defaultStyle := list.DefaultStyles()
- defaultStyle.FilterPrompt = defaultStyle.FilterPrompt.Foreground(styles.Secondary)
- defaultStyle.FilterCursor = defaultStyle.FilterCursor.Foreground(styles.Flamingo)
-
- listDelegate.Styles = defaultItemStyle
-
- listComponent := list.New([]list.Item{}, listDelegate, 0, 0)
- listComponent.FilterInput.PromptStyle = defaultStyle.FilterPrompt
- listComponent.FilterInput.Cursor.Style = defaultStyle.FilterCursor
- listComponent.SetShowTitle(false)
- listComponent.SetShowPagination(false)
- listComponent.SetShowHelp(false)
- listComponent.SetShowStatusBar(false)
- listComponent.DisableQuitKeybindings()
-
- return &sessionsCmp{
- app: app,
- list: listComponent,
- focused: false,
- }
-}
diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go
deleted file mode 100644
index c47c4e090..000000000
--- a/internal/tui/layout/bento.go
+++ /dev/null
@@ -1,392 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type paneID string
-
-const (
- BentoLeftPane paneID = "left"
- BentoRightTopPane paneID = "right-top"
- BentoRightBottomPane paneID = "right-bottom"
-)
-
-type BentoPanes map[paneID]tea.Model
-
-const (
- defaultLeftWidthRatio = 0.2
- defaultRightTopHeightRatio = 0.85
-
- minLeftWidth = 10
- minRightBottomHeight = 10
-)
-
-type BentoLayout interface {
- tea.Model
- Sizeable
- Bindings
-}
-
-type BentoKeyBindings struct {
- SwitchPane key.Binding
- SwitchPaneBack key.Binding
- HideCurrentPane key.Binding
- ShowAllPanes key.Binding
-}
-
-var defaultBentoKeyBindings = BentoKeyBindings{
- SwitchPane: key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "switch pane"),
- ),
- SwitchPaneBack: key.NewBinding(
- key.WithKeys("shift+tab"),
- key.WithHelp("shift+tab", "switch pane back"),
- ),
- HideCurrentPane: key.NewBinding(
- key.WithKeys("X"),
- key.WithHelp("X", "hide current pane"),
- ),
- ShowAllPanes: key.NewBinding(
- key.WithKeys("R"),
- key.WithHelp("R", "show all panes"),
- ),
-}
-
-type bentoLayout struct {
- width int
- height int
-
- leftWidthRatio float64
- rightTopHeightRatio float64
-
- currentPane paneID
- panes map[paneID]SinglePaneLayout
- hiddenPanes map[paneID]bool
-}
-
-func (b *bentoLayout) GetSize() (int, int) {
- return b.width, b.height
-}
-
-func (b *bentoLayout) Init() tea.Cmd {
- var cmds []tea.Cmd
- for _, pane := range b.panes {
- cmd := pane.Init()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return tea.Batch(cmds...)
-}
-
-func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- b.SetSize(msg.Width, msg.Height)
- return b, nil
- case tea.KeyMsg:
- switch {
- case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
- return b, b.SwitchPane(false)
- case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
- return b, b.SwitchPane(true)
- case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
- return b, b.HidePane(b.currentPane)
- case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
- for id := range b.hiddenPanes {
- delete(b.hiddenPanes, id)
- }
- b.SetSize(b.width, b.height)
- return b, nil
- }
- }
-
- var cmds []tea.Cmd
- for id, pane := range b.panes {
- u, cmd := pane.Update(msg)
- b.panes[id] = u.(SinglePaneLayout)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return b, tea.Batch(cmds...)
-}
-
-func (b *bentoLayout) View() string {
- if b.width <= 0 || b.height <= 0 {
- return ""
- }
-
- for id, pane := range b.panes {
- if b.currentPane == id {
- pane.Focus()
- } else {
- pane.Blur()
- }
- }
-
- leftVisible := false
- rightTopVisible := false
- rightBottomVisible := false
-
- var leftPane, rightTopPane, rightBottomPane string
-
- if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- leftPane = pane.View()
- leftVisible = true
- }
-
- if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- rightTopPane = pane.View()
- rightTopVisible = true
- }
-
- if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- rightBottomPane = pane.View()
- rightBottomVisible = true
- }
-
- if leftVisible {
- if rightTopVisible || rightBottomVisible {
- rightSection := ""
- if rightTopVisible && rightBottomVisible {
- rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
- } else if rightTopVisible {
- rightSection = rightTopPane
- } else {
- rightSection = rightBottomPane
- }
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
- lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
- )
- } else {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
- }
- } else if rightTopVisible || rightBottomVisible {
- if rightTopVisible && rightBottomVisible {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
- lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
- )
- } else if rightTopVisible {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
- } else {
- return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
- }
- }
- return ""
-}
-
-func (b *bentoLayout) SetSize(width int, height int) {
- if width < 0 || height < 0 {
- return
- }
- b.width = width
- b.height = height
-
- leftExists := false
- rightTopExists := false
- rightBottomExists := false
-
- if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- leftExists = true
- }
- if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- rightTopExists = true
- }
- if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- rightBottomExists = true
- }
-
- leftWidth := 0
- rightWidth := 0
- rightTopHeight := 0
- rightBottomHeight := 0
-
- if leftExists && (rightTopExists || rightBottomExists) {
- leftWidth = int(float64(width) * b.leftWidthRatio)
- if leftWidth < minLeftWidth && width >= minLeftWidth {
- leftWidth = minLeftWidth
- }
- rightWidth = width - leftWidth
-
- if rightTopExists && rightBottomExists {
- rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
- rightBottomHeight = height - rightTopHeight
-
- if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
- rightBottomHeight = minRightBottomHeight
- rightTopHeight = height - rightBottomHeight
- }
- } else if rightTopExists {
- rightTopHeight = height
- } else if rightBottomExists {
- rightBottomHeight = height
- }
- } else if leftExists {
- leftWidth = width
- } else if rightTopExists || rightBottomExists {
- rightWidth = width
-
- if rightTopExists && rightBottomExists {
- rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
- rightBottomHeight = height - rightTopHeight
-
- if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
- rightBottomHeight = minRightBottomHeight
- rightTopHeight = height - rightBottomHeight
- }
- } else if rightTopExists {
- rightTopHeight = height
- } else if rightBottomExists {
- rightBottomHeight = height
- }
- }
-
- if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
- pane.SetSize(leftWidth, height)
- }
- if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
- pane.SetSize(rightWidth, rightTopHeight)
- }
- if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
- pane.SetSize(rightWidth, rightBottomHeight)
- }
-}
-
-func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
- if len(b.panes)-len(b.hiddenPanes) == 1 {
- return nil
- }
- if _, ok := b.panes[pane]; ok {
- b.hiddenPanes[pane] = true
- }
- b.SetSize(b.width, b.height)
- return b.SwitchPane(false)
-}
-
-func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
- orderForward := []paneID{BentoLeftPane, BentoRightTopPane, BentoRightBottomPane}
- orderBackward := []paneID{BentoLeftPane, BentoRightBottomPane, BentoRightTopPane}
-
- order := orderForward
- if back {
- order = orderBackward
- }
-
- currentIdx := -1
- for i, id := range order {
- if id == b.currentPane {
- currentIdx = i
- break
- }
- }
-
- if currentIdx == -1 {
- for _, id := range order {
- if _, exists := b.panes[id]; exists {
- if _, hidden := b.hiddenPanes[id]; !hidden {
- b.currentPane = id
- break
- }
- }
- }
- } else {
- startIdx := currentIdx
- for {
- currentIdx = (currentIdx + 1) % len(order)
-
- nextID := order[currentIdx]
- if _, exists := b.panes[nextID]; exists {
- if _, hidden := b.hiddenPanes[nextID]; !hidden {
- b.currentPane = nextID
- break
- }
- }
-
- if currentIdx == startIdx {
- break
- }
- }
- }
-
- var cmds []tea.Cmd
- for id, pane := range b.panes {
- if _, ok := b.hiddenPanes[id]; ok {
- continue
- }
- if id == b.currentPane {
- cmds = append(cmds, pane.Focus())
- } else {
- cmds = append(cmds, pane.Blur())
- }
- }
-
- return tea.Batch(cmds...)
-}
-
-func (s *bentoLayout) BindingKeys() []key.Binding {
- bindings := KeyMapToSlice(defaultBentoKeyBindings)
- if b, ok := s.panes[s.currentPane].(Bindings); ok {
- bindings = append(bindings, b.BindingKeys()...)
- }
- return bindings
-}
-
-type BentoLayoutOption func(*bentoLayout)
-
-func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
- p := make(map[paneID]SinglePaneLayout, len(panes))
- for id, pane := range panes {
- if sp, ok := pane.(SinglePaneLayout); !ok {
- p[id] = NewSinglePane(
- pane,
- WithSinglePaneFocusable(true),
- WithSinglePaneBordered(true),
- )
- } else {
- p[id] = sp
- }
- }
- if len(p) == 0 {
- panic("no panes provided for BentoLayout")
- }
- layout := &bentoLayout{
- panes: p,
- hiddenPanes: make(map[paneID]bool),
- currentPane: BentoLeftPane,
- leftWidthRatio: defaultLeftWidthRatio,
- rightTopHeightRatio: defaultRightTopHeightRatio,
- }
-
- for _, opt := range opts {
- opt(layout)
- }
-
- return layout
-}
-
-func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
- return func(b *bentoLayout) {
- if ratio > 0 && ratio < 1 {
- b.leftWidthRatio = ratio
- }
- }
-}
-
-func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
- return func(b *bentoLayout) {
- if ratio > 0 && ratio < 1 {
- b.rightTopHeightRatio = ratio
- }
- }
-}
-
-func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
- return func(b *bentoLayout) {
- b.currentPane = pane
- }
-}
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
deleted file mode 100644
index 8fe5c430c..000000000
--- a/internal/tui/layout/border.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package layout
-
-import (
- "fmt"
- "strings"
-
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
-)
-
-type BorderPosition int
-
-const (
- TopLeftBorder BorderPosition = iota
- TopMiddleBorder
- TopRightBorder
- BottomLeftBorder
- BottomMiddleBorder
- BottomRightBorder
-)
-
-var (
- ActiveBorder = styles.Blue
- InactivePreviewBorder = styles.Grey
-)
-
-type BorderOptions struct {
- Active bool
- EmbeddedText map[BorderPosition]string
- ActiveColor lipgloss.TerminalColor
- InactiveColor lipgloss.TerminalColor
- ActiveBorder lipgloss.Border
- InactiveBorder lipgloss.Border
-}
-
-func Borderize(content string, opts BorderOptions) string {
- if opts.EmbeddedText == nil {
- opts.EmbeddedText = make(map[BorderPosition]string)
- }
- if opts.ActiveColor == nil {
- opts.ActiveColor = ActiveBorder
- }
- if opts.InactiveColor == nil {
- opts.InactiveColor = InactivePreviewBorder
- }
- if opts.ActiveBorder == (lipgloss.Border{}) {
- opts.ActiveBorder = lipgloss.ThickBorder()
- }
- if opts.InactiveBorder == (lipgloss.Border{}) {
- opts.InactiveBorder = lipgloss.NormalBorder()
- }
-
- var (
- thickness = map[bool]lipgloss.Border{
- true: opts.ActiveBorder,
- false: opts.InactiveBorder,
- }
- color = map[bool]lipgloss.TerminalColor{
- true: opts.ActiveColor,
- false: opts.InactiveColor,
- }
- border = thickness[opts.Active]
- style = lipgloss.NewStyle().Foreground(color[opts.Active])
- width = lipgloss.Width(content)
- )
-
- encloseInSquareBrackets := func(text string) string {
- if text != "" {
- return fmt.Sprintf("%s%s%s",
- style.Render(border.TopRight),
- text,
- style.Render(border.TopLeft),
- )
- }
- return text
- }
- buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
- leftText = encloseInSquareBrackets(leftText)
- middleText = encloseInSquareBrackets(middleText)
- rightText = encloseInSquareBrackets(rightText)
- // Calculate length of border between embedded texts
- remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
- leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
- rightBorderLen := max(0, remaining-leftBorderLen)
- // Then construct border string
- s := leftText +
- style.Render(strings.Repeat(inbetween, leftBorderLen)) +
- middleText +
- style.Render(strings.Repeat(inbetween, rightBorderLen)) +
- rightText
- // Make it fit in the space available between the two corners.
- s = lipgloss.NewStyle().
- Inline(true).
- MaxWidth(width).
- Render(s)
- // Add the corners
- return style.Render(leftCorner) + s + style.Render(rightCorner)
- }
- // Stack top border, content and horizontal borders, and bottom border.
- return strings.Join([]string{
- buildHorizontalBorder(
- opts.EmbeddedText[TopLeftBorder],
- opts.EmbeddedText[TopMiddleBorder],
- opts.EmbeddedText[TopRightBorder],
- border.TopLeft,
- border.Top,
- border.TopRight,
- ),
- lipgloss.NewStyle().
- BorderForeground(color[opts.Active]).
- Border(border, false, true, false, true).Render(content),
- buildHorizontalBorder(
- opts.EmbeddedText[BottomLeftBorder],
- opts.EmbeddedText[BottomMiddleBorder],
- opts.EmbeddedText[BottomRightBorder],
- border.BottomLeft,
- border.Bottom,
- border.BottomRight,
- ),
- }, "\n")
-}
diff --git a/internal/tui/layout/container.go b/internal/tui/layout/container.go
new file mode 100644
index 000000000..fdb9ab403
--- /dev/null
+++ b/internal/tui/layout/container.go
@@ -0,0 +1,226 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type Container interface {
+ tea.Model
+ Sizeable
+ Bindings
+}
+type container struct {
+ width int
+ height int
+
+ content tea.Model
+
+ // Style options
+ paddingTop int
+ paddingRight int
+ paddingBottom int
+ paddingLeft int
+
+ borderTop bool
+ borderRight bool
+ borderBottom bool
+ borderLeft bool
+ borderStyle lipgloss.Border
+ borderColor lipgloss.TerminalColor
+
+ backgroundColor lipgloss.TerminalColor
+}
+
+func (c *container) Init() tea.Cmd {
+ return c.content.Init()
+}
+
+func (c *container) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ u, cmd := c.content.Update(msg)
+ c.content = u
+ return c, cmd
+}
+
+func (c *container) View() string {
+ style := lipgloss.NewStyle()
+ width := c.width
+ height := c.height
+ // Apply background color if specified
+ if c.backgroundColor != nil {
+ style = style.Background(c.backgroundColor)
+ }
+
+ // Apply border if any side is enabled
+ if c.borderTop || c.borderRight || c.borderBottom || c.borderLeft {
+ // Adjust width and height for borders
+ if c.borderTop {
+ height--
+ }
+ if c.borderBottom {
+ height--
+ }
+ if c.borderLeft {
+ width--
+ }
+ if c.borderRight {
+ width--
+ }
+ style = style.Border(c.borderStyle, c.borderTop, c.borderRight, c.borderBottom, c.borderLeft)
+
+ // Apply border color if specified
+ if c.borderColor != nil {
+ style = style.BorderBackground(c.backgroundColor).BorderForeground(c.borderColor)
+ }
+ }
+ style = style.
+ Width(width).
+ Height(height).
+ PaddingTop(c.paddingTop).
+ PaddingRight(c.paddingRight).
+ PaddingBottom(c.paddingBottom).
+ PaddingLeft(c.paddingLeft)
+
+ return style.Render(c.content.View())
+}
+
+func (c *container) SetSize(width, height int) tea.Cmd {
+ c.width = width
+ c.height = height
+
+ // If the content implements Sizeable, adjust its size to account for padding and borders
+ if sizeable, ok := c.content.(Sizeable); ok {
+ // Calculate horizontal space taken by padding and borders
+ horizontalSpace := c.paddingLeft + c.paddingRight
+ if c.borderLeft {
+ horizontalSpace++
+ }
+ if c.borderRight {
+ horizontalSpace++
+ }
+
+ // Calculate vertical space taken by padding and borders
+ verticalSpace := c.paddingTop + c.paddingBottom
+ if c.borderTop {
+ verticalSpace++
+ }
+ if c.borderBottom {
+ verticalSpace++
+ }
+
+ // Set content size with adjusted dimensions
+ contentWidth := max(0, width-horizontalSpace)
+ contentHeight := max(0, height-verticalSpace)
+ return sizeable.SetSize(contentWidth, contentHeight)
+ }
+ return nil
+}
+
+func (c *container) GetSize() (int, int) {
+ return c.width, c.height
+}
+
+func (c *container) BindingKeys() []key.Binding {
+ if b, ok := c.content.(Bindings); ok {
+ return b.BindingKeys()
+ }
+ return []key.Binding{}
+}
+
+type ContainerOption func(*container)
+
+func NewContainer(content tea.Model, options ...ContainerOption) Container {
+ c := &container{
+ content: content,
+ borderColor: styles.BorderColor,
+ borderStyle: lipgloss.NormalBorder(),
+ backgroundColor: styles.Background,
+ }
+
+ for _, option := range options {
+ option(c)
+ }
+
+ return c
+}
+
+// Padding options
+func WithPadding(top, right, bottom, left int) ContainerOption {
+ return func(c *container) {
+ c.paddingTop = top
+ c.paddingRight = right
+ c.paddingBottom = bottom
+ c.paddingLeft = left
+ }
+}
+
+func WithPaddingAll(padding int) ContainerOption {
+ return WithPadding(padding, padding, padding, padding)
+}
+
+func WithPaddingHorizontal(padding int) ContainerOption {
+ return func(c *container) {
+ c.paddingLeft = padding
+ c.paddingRight = padding
+ }
+}
+
+func WithPaddingVertical(padding int) ContainerOption {
+ return func(c *container) {
+ c.paddingTop = padding
+ c.paddingBottom = padding
+ }
+}
+
+func WithBorder(top, right, bottom, left bool) ContainerOption {
+ return func(c *container) {
+ c.borderTop = top
+ c.borderRight = right
+ c.borderBottom = bottom
+ c.borderLeft = left
+ }
+}
+
+func WithBorderAll() ContainerOption {
+ return WithBorder(true, true, true, true)
+}
+
+func WithBorderHorizontal() ContainerOption {
+ return WithBorder(true, false, true, false)
+}
+
+func WithBorderVertical() ContainerOption {
+ return WithBorder(false, true, false, true)
+}
+
+func WithBorderStyle(style lipgloss.Border) ContainerOption {
+ return func(c *container) {
+ c.borderStyle = style
+ }
+}
+
+func WithBorderColor(color lipgloss.TerminalColor) ContainerOption {
+ return func(c *container) {
+ c.borderColor = color
+ }
+}
+
+func WithRoundedBorder() ContainerOption {
+ return WithBorderStyle(lipgloss.RoundedBorder())
+}
+
+func WithThickBorder() ContainerOption {
+ return WithBorderStyle(lipgloss.ThickBorder())
+}
+
+func WithDoubleBorder() ContainerOption {
+ return WithBorderStyle(lipgloss.DoubleBorder())
+}
+
+func WithBackgroundColor(color lipgloss.TerminalColor) ContainerOption {
+ return func(c *container) {
+ c.backgroundColor = color
+ }
+}
diff --git a/internal/tui/layout/grid.go b/internal/tui/layout/grid.go
deleted file mode 100644
index 6be493e2c..000000000
--- a/internal/tui/layout/grid.go
+++ /dev/null
@@ -1,254 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type GridLayout interface {
- tea.Model
- Sizeable
- Bindings
- Panes() [][]tea.Model
-}
-
-type gridLayout struct {
- width int
- height int
-
- rows int
- columns int
-
- panes [][]tea.Model
-
- gap int
- bordered bool
- focusable bool
-
- currentRow int
- currentColumn int
-
- activeColor lipgloss.TerminalColor
-}
-
-type GridOption func(*gridLayout)
-
-func (g *gridLayout) Init() tea.Cmd {
- var cmds []tea.Cmd
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- cmds = append(cmds, g.panes[i][j].Init())
- }
- }
- }
- return tea.Batch(cmds...)
-}
-
-func (g *gridLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- g.SetSize(msg.Width, msg.Height)
- return g, nil
- case tea.KeyMsg:
- if key.Matches(msg, g.nextPaneBinding()) {
- return g.focusNextPane()
- }
- }
-
- // Update all panes
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- var cmd tea.Cmd
- g.panes[i][j], cmd = g.panes[i][j].Update(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- }
-
- return g, tea.Batch(cmds...)
-}
-
-func (g *gridLayout) focusNextPane() (tea.Model, tea.Cmd) {
- if !g.focusable {
- return g, nil
- }
-
- var cmds []tea.Cmd
-
- // Blur current pane
- if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
- if currentPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
- cmds = append(cmds, currentPane.Blur())
- }
- }
-
- // Find next valid pane
- g.currentColumn++
- if g.currentColumn >= len(g.panes[g.currentRow]) {
- g.currentColumn = 0
- g.currentRow++
- if g.currentRow >= len(g.panes) {
- g.currentRow = 0
- }
- }
-
- // Focus next pane
- if g.currentRow < len(g.panes) && g.currentColumn < len(g.panes[g.currentRow]) {
- if nextPane, ok := g.panes[g.currentRow][g.currentColumn].(Focusable); ok {
- cmds = append(cmds, nextPane.Focus())
- }
- }
-
- return g, tea.Batch(cmds...)
-}
-
-func (g *gridLayout) nextPaneBinding() key.Binding {
- return key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "next pane"),
- )
-}
-
-func (g *gridLayout) View() string {
- if len(g.panes) == 0 {
- return ""
- }
-
- // Calculate dimensions for each cell
- cellWidth := (g.width - (g.columns-1)*g.gap) / g.columns
- cellHeight := (g.height - (g.rows-1)*g.gap) / g.rows
-
- // Render each row
- rows := make([]string, g.rows)
- for i := range g.rows {
- // Render each column in this row
- cols := make([]string, len(g.panes[i]))
- for j := range g.panes[i] {
- if g.panes[i][j] == nil {
- cols[j] = ""
- continue
- }
-
- // Set size for each pane
- if sizable, ok := g.panes[i][j].(Sizeable); ok {
- effectiveWidth, effectiveHeight := cellWidth, cellHeight
- if g.bordered {
- effectiveWidth -= 2
- effectiveHeight -= 2
- }
- sizable.SetSize(effectiveWidth, effectiveHeight)
- }
-
- // Render the pane
- content := g.panes[i][j].View()
-
- // Apply border if needed
- if g.bordered {
- isFocused := false
- if focusable, ok := g.panes[i][j].(Focusable); ok {
- isFocused = focusable.IsFocused()
- }
-
- borderText := map[BorderPosition]string{}
- if bordered, ok := g.panes[i][j].(Bordered); ok {
- borderText = bordered.BorderText()
- }
-
- content = Borderize(content, BorderOptions{
- Active: isFocused,
- EmbeddedText: borderText,
- })
- }
-
- cols[j] = content
- }
-
- // Join columns with gap
- rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, cols...)
- }
-
- // Join rows with gap
- return lipgloss.JoinVertical(lipgloss.Left, rows...)
-}
-
-func (g *gridLayout) SetSize(width, height int) {
- g.width = width
- g.height = height
-}
-
-func (g *gridLayout) GetSize() (int, int) {
- return g.width, g.height
-}
-
-func (g *gridLayout) BindingKeys() []key.Binding {
- var bindings []key.Binding
- bindings = append(bindings, g.nextPaneBinding())
-
- // Collect bindings from all panes
- for i := range g.panes {
- for j := range g.panes[i] {
- if g.panes[i][j] != nil {
- if bindable, ok := g.panes[i][j].(Bindings); ok {
- bindings = append(bindings, bindable.BindingKeys()...)
- }
- }
- }
- }
-
- return bindings
-}
-
-func (g *gridLayout) Panes() [][]tea.Model {
- return g.panes
-}
-
-// NewGridLayout creates a new grid layout with the given number of rows and columns
-func NewGridLayout(rows, cols int, panes [][]tea.Model, opts ...GridOption) GridLayout {
- grid := &gridLayout{
- rows: rows,
- columns: cols,
- panes: panes,
- gap: 1,
- }
-
- for _, opt := range opts {
- opt(grid)
- }
-
- return grid
-}
-
-// WithGridGap sets the gap between cells
-func WithGridGap(gap int) GridOption {
- return func(g *gridLayout) {
- g.gap = gap
- }
-}
-
-// WithGridBordered sets whether cells should have borders
-func WithGridBordered(bordered bool) GridOption {
- return func(g *gridLayout) {
- g.bordered = bordered
- }
-}
-
-// WithGridFocusable sets whether the grid supports focus navigation
-func WithGridFocusable(focusable bool) GridOption {
- return func(g *gridLayout) {
- g.focusable = focusable
- }
-}
-
-// WithGridActiveColor sets the active border color
-func WithGridActiveColor(color lipgloss.TerminalColor) GridOption {
- return func(g *gridLayout) {
- g.activeColor = color
- }
-}
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
index 2f17c4a0e..495a3fbc5 100644
--- a/internal/tui/layout/layout.go
+++ b/internal/tui/layout/layout.go
@@ -13,12 +13,8 @@ type Focusable interface {
IsFocused() bool
}
-type Bordered interface {
- BorderText() map[BorderPosition]string
-}
-
type Sizeable interface {
- SetSize(width, height int)
+ SetSize(width, height int) tea.Cmd
GetSize() (int, int)
}
diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go
index 22f9e00fe..4c05e8462 100644
--- a/internal/tui/layout/overlay.go
+++ b/internal/tui/layout/overlay.go
@@ -5,7 +5,8 @@ import (
"strings"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
"github.com/mattn/go-runewidth"
"github.com/muesli/ansi"
"github.com/muesli/reflow/truncate"
@@ -45,13 +46,15 @@ func PlaceOverlay(
if shadow {
var shadowbg string = ""
shadowchar := lipgloss.NewStyle().
- Foreground(lipgloss.Color("#333333")).
+ Background(styles.BackgroundDarker).
+ Foreground(styles.Background).
Render("░")
+ bgchar := styles.BaseStyle.Render(" ")
for i := 0; i <= fgHeight; i++ {
if i == 0 {
- shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n"
+ shadowbg += bgchar + strings.Repeat(bgchar, fgWidth) + "\n"
} else {
- shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n"
+ shadowbg += bgchar + strings.Repeat(shadowchar, fgWidth) + "\n"
}
}
@@ -159,8 +162,6 @@ func max(a, b int) int {
return b
}
-
-
type whitespace struct {
style termenv.Style
chars string
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
deleted file mode 100644
index e5c9a61c4..000000000
--- a/internal/tui/layout/single.go
+++ /dev/null
@@ -1,189 +0,0 @@
-package layout
-
-import (
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
-)
-
-type SinglePaneLayout interface {
- tea.Model
- Focusable
- Sizeable
- Bindings
- Pane() tea.Model
-}
-
-type singlePaneLayout struct {
- width int
- height int
-
- focusable bool
- focused bool
-
- bordered bool
- borderText map[BorderPosition]string
-
- content tea.Model
-
- padding []int
-
- activeColor lipgloss.TerminalColor
-}
-
-type SinglePaneOption func(*singlePaneLayout)
-
-func (s *singlePaneLayout) Init() tea.Cmd {
- return s.content.Init()
-}
-
-func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- s.SetSize(msg.Width, msg.Height)
- return s, nil
- }
- u, cmd := s.content.Update(msg)
- s.content = u
- return s, cmd
-}
-
-func (s *singlePaneLayout) View() string {
- style := lipgloss.NewStyle().Width(s.width).Height(s.height)
- if s.bordered {
- style = style.Width(s.width - 2).Height(s.height - 2)
- }
- if s.padding != nil {
- style = style.Padding(s.padding...)
- }
- content := style.Render(s.content.View())
- if s.bordered {
- if s.borderText == nil {
- s.borderText = map[BorderPosition]string{}
- }
- if bordered, ok := s.content.(Bordered); ok {
- s.borderText = bordered.BorderText()
- }
- return Borderize(content, BorderOptions{
- Active: s.focused,
- EmbeddedText: s.borderText,
- })
- }
- return content
-}
-
-func (s *singlePaneLayout) Blur() tea.Cmd {
- if s.focusable {
- s.focused = false
- }
- if blurable, ok := s.content.(Focusable); ok {
- return blurable.Blur()
- }
- return nil
-}
-
-func (s *singlePaneLayout) Focus() tea.Cmd {
- if s.focusable {
- s.focused = true
- }
- if focusable, ok := s.content.(Focusable); ok {
- return focusable.Focus()
- }
- return nil
-}
-
-func (s *singlePaneLayout) SetSize(width, height int) {
- s.width = width
- s.height = height
- childWidth, childHeight := s.width, s.height
- if s.bordered {
- childWidth -= 2
- childHeight -= 2
- }
- if s.padding != nil {
- if len(s.padding) == 1 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[0] * 2
- } else if len(s.padding) == 2 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[1] * 2
- } else if len(s.padding) == 3 {
- childWidth -= s.padding[0] * 2
- childHeight -= s.padding[1] + s.padding[2]
- } else if len(s.padding) == 4 {
- childWidth -= s.padding[0] + s.padding[2]
- childHeight -= s.padding[1] + s.padding[3]
- }
- }
- if s.content != nil {
- if c, ok := s.content.(Sizeable); ok {
- c.SetSize(childWidth, childHeight)
- }
- }
-}
-
-func (s *singlePaneLayout) IsFocused() bool {
- return s.focused
-}
-
-func (s *singlePaneLayout) GetSize() (int, int) {
- return s.width, s.height
-}
-
-func (s *singlePaneLayout) BindingKeys() []key.Binding {
- if b, ok := s.content.(Bindings); ok {
- return b.BindingKeys()
- }
- return []key.Binding{}
-}
-
-func (s *singlePaneLayout) Pane() tea.Model {
- return s.content
-}
-
-func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
- layout := &singlePaneLayout{
- content: content,
- }
- for _, opt := range opts {
- opt(layout)
- }
- return layout
-}
-
-func WithSignlePaneSize(width, height int) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.width = width
- opts.height = height
- }
-}
-
-func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.focusable = focusable
- }
-}
-
-func WithSinglePaneBordered(bordered bool) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.bordered = bordered
- }
-}
-
-func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.borderText = borderText
- }
-}
-
-func WithSinglePanePadding(padding ...int) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.padding = padding
- }
-}
-
-func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption {
- return func(opts *singlePaneLayout) {
- opts.activeColor = color
- }
-}
diff --git a/internal/tui/layout/split.go b/internal/tui/layout/split.go
new file mode 100644
index 000000000..f3ab9247d
--- /dev/null
+++ b/internal/tui/layout/split.go
@@ -0,0 +1,289 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
+)
+
+type SplitPaneLayout interface {
+ tea.Model
+ Sizeable
+ Bindings
+ SetLeftPanel(panel Container) tea.Cmd
+ SetRightPanel(panel Container) tea.Cmd
+ SetBottomPanel(panel Container) tea.Cmd
+
+ ClearLeftPanel() tea.Cmd
+ ClearRightPanel() tea.Cmd
+ ClearBottomPanel() tea.Cmd
+}
+
+type splitPaneLayout struct {
+ width int
+ height int
+ ratio float64
+ verticalRatio float64
+
+ rightPanel Container
+ leftPanel Container
+ bottomPanel Container
+
+ backgroundColor lipgloss.TerminalColor
+}
+
+type SplitPaneOption func(*splitPaneLayout)
+
+func (s *splitPaneLayout) Init() tea.Cmd {
+ var cmds []tea.Cmd
+
+ if s.leftPanel != nil {
+ cmds = append(cmds, s.leftPanel.Init())
+ }
+
+ if s.rightPanel != nil {
+ cmds = append(cmds, s.rightPanel.Init())
+ }
+
+ if s.bottomPanel != nil {
+ cmds = append(cmds, s.bottomPanel.Init())
+ }
+
+ return tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ return s, s.SetSize(msg.Width, msg.Height)
+ }
+
+ if s.rightPanel != nil {
+ u, cmd := s.rightPanel.Update(msg)
+ s.rightPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ if s.leftPanel != nil {
+ u, cmd := s.leftPanel.Update(msg)
+ s.leftPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ if s.bottomPanel != nil {
+ u, cmd := s.bottomPanel.Update(msg)
+ s.bottomPanel = u.(Container)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ return s, tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) View() string {
+ var topSection string
+
+ if s.leftPanel != nil && s.rightPanel != nil {
+ leftView := s.leftPanel.View()
+ rightView := s.rightPanel.View()
+ topSection = lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView)
+ } else if s.leftPanel != nil {
+ topSection = s.leftPanel.View()
+ } else if s.rightPanel != nil {
+ topSection = s.rightPanel.View()
+ } else {
+ topSection = ""
+ }
+
+ var finalView string
+
+ if s.bottomPanel != nil && topSection != "" {
+ bottomView := s.bottomPanel.View()
+ finalView = lipgloss.JoinVertical(lipgloss.Left, topSection, bottomView)
+ } else if s.bottomPanel != nil {
+ finalView = s.bottomPanel.View()
+ } else {
+ finalView = topSection
+ }
+
+ if s.backgroundColor != nil && finalView != "" {
+ style := lipgloss.NewStyle().
+ Width(s.width).
+ Height(s.height).
+ Background(s.backgroundColor)
+
+ return style.Render(finalView)
+ }
+
+ return finalView
+}
+
+func (s *splitPaneLayout) SetSize(width, height int) tea.Cmd {
+ s.width = width
+ s.height = height
+
+ var topHeight, bottomHeight int
+ if s.bottomPanel != nil {
+ topHeight = int(float64(height) * s.verticalRatio)
+ bottomHeight = height - topHeight
+ } else {
+ topHeight = height
+ bottomHeight = 0
+ }
+
+ var leftWidth, rightWidth int
+ if s.leftPanel != nil && s.rightPanel != nil {
+ leftWidth = int(float64(width) * s.ratio)
+ rightWidth = width - leftWidth
+ } else if s.leftPanel != nil {
+ leftWidth = width
+ rightWidth = 0
+ } else if s.rightPanel != nil {
+ leftWidth = 0
+ rightWidth = width
+ }
+
+ var cmds []tea.Cmd
+ if s.leftPanel != nil {
+ cmd := s.leftPanel.SetSize(leftWidth, topHeight)
+ cmds = append(cmds, cmd)
+ }
+
+ if s.rightPanel != nil {
+ cmd := s.rightPanel.SetSize(rightWidth, topHeight)
+ cmds = append(cmds, cmd)
+ }
+
+ if s.bottomPanel != nil {
+ cmd := s.bottomPanel.SetSize(width, bottomHeight)
+ cmds = append(cmds, cmd)
+ }
+ return tea.Batch(cmds...)
+}
+
+func (s *splitPaneLayout) GetSize() (int, int) {
+ return s.width, s.height
+}
+
+func (s *splitPaneLayout) SetLeftPanel(panel Container) tea.Cmd {
+ s.leftPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) SetRightPanel(panel Container) tea.Cmd {
+ s.rightPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) SetBottomPanel(panel Container) tea.Cmd {
+ s.bottomPanel = panel
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearLeftPanel() tea.Cmd {
+ s.leftPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearRightPanel() tea.Cmd {
+ s.rightPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) ClearBottomPanel() tea.Cmd {
+ s.bottomPanel = nil
+ if s.width > 0 && s.height > 0 {
+ return s.SetSize(s.width, s.height)
+ }
+ return nil
+}
+
+func (s *splitPaneLayout) BindingKeys() []key.Binding {
+ keys := []key.Binding{}
+ if s.leftPanel != nil {
+ if b, ok := s.leftPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ if s.rightPanel != nil {
+ if b, ok := s.rightPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ if s.bottomPanel != nil {
+ if b, ok := s.bottomPanel.(Bindings); ok {
+ keys = append(keys, b.BindingKeys()...)
+ }
+ }
+ return keys
+}
+
+func NewSplitPane(options ...SplitPaneOption) SplitPaneLayout {
+ layout := &splitPaneLayout{
+ ratio: 0.7,
+ verticalRatio: 0.9, // Default 80% for top section, 20% for bottom
+ backgroundColor: styles.Background,
+ }
+ for _, option := range options {
+ option(layout)
+ }
+ return layout
+}
+
+func WithLeftPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.leftPanel = panel
+ }
+}
+
+func WithRightPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.rightPanel = panel
+ }
+}
+
+func WithRatio(ratio float64) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.ratio = ratio
+ }
+}
+
+func WithSplitBackgroundColor(color lipgloss.TerminalColor) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.backgroundColor = color
+ }
+}
+
+func WithBottomPanel(panel Container) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.bottomPanel = panel
+ }
+}
+
+func WithVerticalRatio(ratio float64) SplitPaneOption {
+ return func(s *splitPaneLayout) {
+ s.verticalRatio = ratio
+ }
+}
diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go
new file mode 100644
index 000000000..a5a656a22
--- /dev/null
+++ b/internal/tui/page/chat.go
@@ -0,0 +1,167 @@
+package page
+
+import (
+ "context"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/session"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
+)
+
+var ChatPage PageID = "chat"
+
+type chatPage struct {
+ app *app.App
+ editor layout.Container
+ messages layout.Container
+ layout layout.SplitPaneLayout
+ session session.Session
+ editingMode bool
+}
+
+type ChatKeyMap struct {
+ NewSession key.Binding
+ Cancel key.Binding
+}
+
+var keyMap = ChatKeyMap{
+ NewSession: key.NewBinding(
+ key.WithKeys("ctrl+n"),
+ key.WithHelp("ctrl+n", "new session"),
+ ),
+ Cancel: key.NewBinding(
+ key.WithKeys("ctrl+x"),
+ key.WithHelp("ctrl+x", "cancel"),
+ ),
+}
+
+func (p *chatPage) Init() tea.Cmd {
+ cmds := []tea.Cmd{
+ p.layout.Init(),
+ }
+ return tea.Batch(cmds...)
+}
+
+func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ cmd := p.layout.SetSize(msg.Width, msg.Height)
+ cmds = append(cmds, cmd)
+ case chat.SendMsg:
+ cmd := p.sendMessage(msg.Text)
+ if cmd != nil {
+ return p, cmd
+ }
+ case chat.SessionSelectedMsg:
+ if p.session.ID == "" {
+ cmd := p.setSidebar()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ p.session = msg
+ case chat.EditorFocusMsg:
+ p.editingMode = bool(msg)
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, keyMap.NewSession):
+ p.session = session.Session{}
+ return p, tea.Batch(
+ p.clearSidebar(),
+ util.CmdHandler(chat.SessionClearedMsg{}),
+ )
+ case key.Matches(msg, keyMap.Cancel):
+ if p.session.ID != "" {
+ // Cancel the current session's generation process
+ // This allows users to interrupt long-running operations
+ p.app.CoderAgent.Cancel(p.session.ID)
+ return p, nil
+ }
+ }
+ }
+ u, cmd := p.layout.Update(msg)
+ cmds = append(cmds, cmd)
+ p.layout = u.(layout.SplitPaneLayout)
+ return p, tea.Batch(cmds...)
+}
+
+func (p *chatPage) setSidebar() tea.Cmd {
+ sidebarContainer := layout.NewContainer(
+ chat.NewSidebarCmp(p.session, p.app.History),
+ layout.WithPadding(1, 1, 1, 1),
+ )
+ return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
+}
+
+func (p *chatPage) clearSidebar() tea.Cmd {
+ return p.layout.ClearRightPanel()
+}
+
+func (p *chatPage) sendMessage(text string) tea.Cmd {
+ var cmds []tea.Cmd
+ if p.session.ID == "" {
+ session, err := p.app.Sessions.Create(context.Background(), "New Session")
+ if err != nil {
+ return util.ReportError(err)
+ }
+
+ p.session = session
+ cmd := p.setSidebar()
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
+ }
+
+ p.app.CoderAgent.Run(context.Background(), p.session.ID, text)
+ return tea.Batch(cmds...)
+}
+
+func (p *chatPage) SetSize(width, height int) tea.Cmd {
+ return p.layout.SetSize(width, height)
+}
+
+func (p *chatPage) GetSize() (int, int) {
+ return p.layout.GetSize()
+}
+
+func (p *chatPage) View() string {
+ return p.layout.View()
+}
+
+func (p *chatPage) BindingKeys() []key.Binding {
+ bindings := layout.KeyMapToSlice(keyMap)
+ if p.editingMode {
+ bindings = append(bindings, p.editor.BindingKeys()...)
+ } else {
+ bindings = append(bindings, p.messages.BindingKeys()...)
+ }
+ return bindings
+}
+
+func NewChatPage(app *app.App) tea.Model {
+ messagesContainer := layout.NewContainer(
+ chat.NewMessagesCmp(app),
+ layout.WithPadding(1, 1, 0, 1),
+ )
+
+ editorContainer := layout.NewContainer(
+ chat.NewEditorCmp(app),
+ layout.WithBorder(true, false, false, false),
+ )
+ return &chatPage{
+ app: app,
+ editor: editorContainer,
+ messages: messagesContainer,
+ editingMode: true,
+ layout: layout.NewSplitPane(
+ layout.WithLeftPanel(messagesContainer),
+ layout.WithBottomPanel(editorContainer),
+ ),
+ }
+}
diff --git a/internal/tui/page/init.go b/internal/tui/page/init.go
deleted file mode 100644
index 93a5e6fba..000000000
--- a/internal/tui/page/init.go
+++ /dev/null
@@ -1,308 +0,0 @@
-package page
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strconv"
-
- "github.com/charmbracelet/bubbles/key"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/huh"
- "github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/llm/models"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/styles"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/spf13/viper"
-)
-
-var InitPage PageID = "init"
-
-type configSaved struct{}
-
-type initPage struct {
- form *huh.Form
- width int
- height int
- saved bool
- errorMsg string
- statusMsg string
- modelOpts []huh.Option[string]
- bigModel string
- smallModel string
- openAIKey string
- anthropicKey string
- groqKey string
- maxTokens string
- dataDir string
- agent string
-}
-
-func (i *initPage) Init() tea.Cmd {
- return i.form.Init()
-}
-
-func (i *initPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- i.width = msg.Width - 4 // Account for border
- i.height = msg.Height - 4
- i.form = i.form.WithWidth(i.width).WithHeight(i.height)
- return i, nil
-
- case configSaved:
- i.saved = true
- i.statusMsg = "Configuration saved successfully. Press any key to continue."
- return i, nil
- }
-
- if i.saved {
- switch msg.(type) {
- case tea.KeyMsg:
- return i, util.CmdHandler(PageChangeMsg{ID: ReplPage})
- }
- return i, nil
- }
-
- // Process the form
- form, cmd := i.form.Update(msg)
- if f, ok := form.(*huh.Form); ok {
- i.form = f
- cmds = append(cmds, cmd)
- }
-
- if i.form.State == huh.StateCompleted {
- // Save configuration to file
- configPath := filepath.Join(os.Getenv("HOME"), ".termai.yaml")
- maxTokens, _ := strconv.Atoi(i.maxTokens)
- config := map[string]any{
- "models": map[string]string{
- "big": i.bigModel,
- "small": i.smallModel,
- },
- "providers": map[string]any{
- "openai": map[string]string{
- "key": i.openAIKey,
- },
- "anthropic": map[string]string{
- "key": i.anthropicKey,
- },
- "groq": map[string]string{
- "key": i.groqKey,
- },
- "common": map[string]int{
- "max_tokens": maxTokens,
- },
- },
- "data": map[string]string{
- "dir": i.dataDir,
- },
- "agents": map[string]string{
- "default": i.agent,
- },
- "log": map[string]string{
- "level": "info",
- },
- }
-
- // Write config to viper
- for k, v := range config {
- viper.Set(k, v)
- }
-
- // Save configuration
- err := viper.WriteConfigAs(configPath)
- if err != nil {
- i.errorMsg = fmt.Sprintf("Failed to save configuration: %s", err)
- return i, nil
- }
-
- // Return to main page
- return i, util.CmdHandler(configSaved{})
- }
-
- return i, tea.Batch(cmds...)
-}
-
-func (i *initPage) View() string {
- if i.saved {
- return lipgloss.NewStyle().
- Width(i.width).
- Height(i.height).
- Align(lipgloss.Center, lipgloss.Center).
- Render(lipgloss.JoinVertical(
- lipgloss.Center,
- lipgloss.NewStyle().Foreground(styles.Green).Render("✓ Configuration Saved"),
- "",
- lipgloss.NewStyle().Foreground(styles.Blue).Render(i.statusMsg),
- ))
- }
-
- view := i.form.View()
- if i.errorMsg != "" {
- errorBox := lipgloss.NewStyle().
- Padding(1).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(styles.Red).
- Width(i.width - 4).
- Render(i.errorMsg)
- view = lipgloss.JoinVertical(lipgloss.Left, errorBox, view)
- }
- return view
-}
-
-func (i *initPage) GetSize() (int, int) {
- return i.width, i.height
-}
-
-func (i *initPage) SetSize(width int, height int) {
- i.width = width
- i.height = height
- i.form = i.form.WithWidth(width).WithHeight(height)
-}
-
-func (i *initPage) BindingKeys() []key.Binding {
- if i.saved {
- return []key.Binding{
- key.NewBinding(
- key.WithKeys("enter", "space", "esc"),
- key.WithHelp("any key", "continue"),
- ),
- }
- }
- return i.form.KeyBinds()
-}
-
-func NewInitPage() tea.Model {
- // Create model options
- var modelOpts []huh.Option[string]
- for id, model := range models.SupportedModels {
- modelOpts = append(modelOpts, huh.NewOption(model.Name, string(id)))
- }
-
- // Create agent options
- agentOpts := []huh.Option[string]{
- huh.NewOption("Coder", "coder"),
- huh.NewOption("Assistant", "assistant"),
- }
-
- // Init page with form
- initModel := &initPage{
- modelOpts: modelOpts,
- bigModel: string(models.Claude37Sonnet),
- smallModel: string(models.Claude37Sonnet),
- maxTokens: "4000",
- dataDir: ".termai",
- agent: "coder",
- }
-
- // API Keys group
- apiKeysGroup := huh.NewGroup(
- huh.NewNote().
- Title("API Keys").
- Description("You need to provide at least one API key to use termai"),
-
- huh.NewInput().
- Title("OpenAI API Key").
- Placeholder("sk-...").
- Key("openai_key").
- Value(&initModel.openAIKey),
-
- huh.NewInput().
- Title("Anthropic API Key").
- Placeholder("sk-ant-...").
- Key("anthropic_key").
- Value(&initModel.anthropicKey),
-
- huh.NewInput().
- Title("Groq API Key").
- Placeholder("gsk_...").
- Key("groq_key").
- Value(&initModel.groqKey),
- )
-
- // Model configuration group
- modelsGroup := huh.NewGroup(
- huh.NewNote().
- Title("Model Configuration").
- Description("Select which models to use"),
-
- huh.NewSelect[string]().
- Title("Big Model").
- Options(modelOpts...).
- Key("big_model").
- Value(&initModel.bigModel),
-
- huh.NewSelect[string]().
- Title("Small Model").
- Options(modelOpts...).
- Key("small_model").
- Value(&initModel.smallModel),
-
- huh.NewInput().
- Title("Max Tokens").
- Placeholder("4000").
- Key("max_tokens").
- CharLimit(5).
- Validate(func(s string) error {
- var n int
- _, err := fmt.Sscanf(s, "%d", &n)
- if err != nil || n <= 0 {
- return fmt.Errorf("must be a positive number")
- }
- initModel.maxTokens = s
- return nil
- }).
- Value(&initModel.maxTokens),
- )
-
- // General settings group
- generalGroup := huh.NewGroup(
- huh.NewNote().
- Title("General Settings").
- Description("Configure general termai settings"),
-
- huh.NewInput().
- Title("Data Directory").
- Placeholder(".termai").
- Key("data_dir").
- Value(&initModel.dataDir),
-
- huh.NewSelect[string]().
- Title("Default Agent").
- Options(agentOpts...).
- Key("agent").
- Value(&initModel.agent),
-
- huh.NewConfirm().
- Title("Save Configuration").
- Affirmative("Save").
- Negative("Cancel"),
- )
-
- // Create form with theme
- form := huh.NewForm(
- apiKeysGroup,
- modelsGroup,
- generalGroup,
- ).WithTheme(styles.HuhTheme()).
- WithShowHelp(true).
- WithShowErrors(true)
-
- // Set the form in the model
- initModel.form = form
-
- return layout.NewSinglePane(
- initModel,
- layout.WithSinglePaneFocusable(true),
- layout.WithSinglePaneBordered(true),
- layout.WithSignlePaneBorderText(
- map[layout.BorderPosition]string{
- layout.TopMiddleBorder: "Welcome to termai - Initial Setup",
- },
- ),
- )
-}
diff --git a/internal/tui/page/logs.go b/internal/tui/page/logs.go
index 12afaf6aa..f0d35fb7b 100644
--- a/internal/tui/page/logs.go
+++ b/internal/tui/page/logs.go
@@ -1,20 +1,83 @@
package page
import (
+ "github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/tui/components/logs"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/logs"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/styles"
)
var LogsPage PageID = "logs"
-func NewLogsPage() tea.Model {
- return layout.NewBentoLayout(
- layout.BentoPanes{
- layout.BentoRightTopPane: logs.NewLogsTable(),
- layout.BentoRightBottomPane: logs.NewLogsDetails(),
- },
- layout.WithBentoLayoutCurrentPane(layout.BentoRightTopPane),
- layout.WithBentoLayoutRightTopHeightRatio(0.5),
+type LogPage interface {
+ tea.Model
+ layout.Sizeable
+ layout.Bindings
+}
+type logsPage struct {
+ width, height int
+ table layout.Container
+ details layout.Container
+}
+
+func (p *logsPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ p.width = msg.Width
+ p.height = msg.Height
+ return p, p.SetSize(msg.Width, msg.Height)
+ }
+
+ table, cmd := p.table.Update(msg)
+ cmds = append(cmds, cmd)
+ p.table = table.(layout.Container)
+ details, cmd := p.details.Update(msg)
+ cmds = append(cmds, cmd)
+ p.details = details.(layout.Container)
+
+ return p, tea.Batch(cmds...)
+}
+
+func (p *logsPage) View() string {
+ style := styles.BaseStyle.Width(p.width).Height(p.height)
+ return style.Render(lipgloss.JoinVertical(lipgloss.Top,
+ p.table.View(),
+ p.details.View(),
+ ))
+}
+
+func (p *logsPage) BindingKeys() []key.Binding {
+ return p.table.BindingKeys()
+}
+
+// GetSize implements LogPage.
+func (p *logsPage) GetSize() (int, int) {
+ return p.width, p.height
+}
+
+// SetSize implements LogPage.
+func (p *logsPage) SetSize(width int, height int) tea.Cmd {
+ p.width = width
+ p.height = height
+ return tea.Batch(
+ p.table.SetSize(width, height/2),
+ p.details.SetSize(width, height/2),
)
}
+
+func (p *logsPage) Init() tea.Cmd {
+ return tea.Batch(
+ p.table.Init(),
+ p.details.Init(),
+ )
+}
+
+func NewLogsPage() LogPage {
+ return &logsPage{
+ table: layout.NewContainer(logs.NewLogsTable(), layout.WithBorderAll(), layout.WithBorderColor(styles.ForgroundDim)),
+ details: layout.NewContainer(logs.NewLogsDetails(), layout.WithBorderAll(), layout.WithBorderColor(styles.ForgroundDim)),
+ }
+}
diff --git a/internal/tui/page/repl.go b/internal/tui/page/repl.go
deleted file mode 100644
index 47a924b7b..000000000
--- a/internal/tui/page/repl.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package page
-
-import (
- tea "github.com/charmbracelet/bubbletea"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
-)
-
-var ReplPage PageID = "repl"
-
-func NewReplPage(app *app.App) tea.Model {
- return layout.NewBentoLayout(
- layout.BentoPanes{
- layout.BentoLeftPane: repl.NewSessionsCmp(app),
- layout.BentoRightTopPane: repl.NewMessagesCmp(app),
- layout.BentoRightBottomPane: repl.NewEditorCmp(app),
- },
- layout.WithBentoLayoutCurrentPane(layout.BentoRightBottomPane),
- )
-}
diff --git a/internal/tui/styles/background.go b/internal/tui/styles/background.go
new file mode 100644
index 000000000..2fbb34efb
--- /dev/null
+++ b/internal/tui/styles/background.go
@@ -0,0 +1,123 @@
+package styles
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
+
+func getColorRGB(c lipgloss.TerminalColor) (uint8, uint8, uint8) {
+ r, g, b, a := c.RGBA()
+
+ // Un-premultiply alpha if needed
+ if a > 0 && a < 0xffff {
+ r = (r * 0xffff) / a
+ g = (g * 0xffff) / a
+ b = (b * 0xffff) / a
+ }
+
+ // Convert from 16-bit to 8-bit color
+ return uint8(r >> 8), uint8(g >> 8), uint8(b >> 8)
+}
+
+// ForceReplaceBackgroundWithLipgloss replaces any ANSI background color codes
+// in `input` with a single 24‑bit background (48;2;R;G;B).
+func ForceReplaceBackgroundWithLipgloss(input string, newBgColor lipgloss.TerminalColor) string {
+ // Precompute our new-bg sequence once
+ r, g, b := getColorRGB(newBgColor)
+ newBg := fmt.Sprintf("48;2;%d;%d;%d", r, g, b)
+
+ return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string {
+ const (
+ escPrefixLen = 2 // "\x1b["
+ escSuffixLen = 1 // "m"
+ )
+
+ raw := seq
+ start := escPrefixLen
+ end := len(raw) - escSuffixLen
+
+ var sb strings.Builder
+ // reserve enough space: original content minus bg codes + our newBg
+ sb.Grow((end - start) + len(newBg) + 2)
+
+ // scan from start..end, token by token
+ for i := start; i < end; {
+ // find the next ';' or end
+ j := i
+ for j < end && raw[j] != ';' {
+ j++
+ }
+ token := raw[i:j]
+
+ // fast‑path: skip "48;5;N" or "48;2;R;G;B"
+ if len(token) == 2 && token[0] == '4' && token[1] == '8' {
+ k := j + 1
+ if k < end {
+ // find next token
+ l := k
+ for l < end && raw[l] != ';' {
+ l++
+ }
+ next := raw[k:l]
+ if next == "5" {
+ // skip "48;5;N"
+ m := l + 1
+ for m < end && raw[m] != ';' {
+ m++
+ }
+ i = m + 1
+ continue
+ } else if next == "2" {
+ // skip "48;2;R;G;B"
+ m := l + 1
+ for count := 0; count < 3 && m < end; count++ {
+ for m < end && raw[m] != ';' {
+ m++
+ }
+ m++
+ }
+ i = m
+ continue
+ }
+ }
+ }
+
+ // decide whether to keep this token
+ // manually parse ASCII digits to int
+ isNum := true
+ val := 0
+ for p := i; p < j; p++ {
+ c := raw[p]
+ if c < '0' || c > '9' {
+ isNum = false
+ break
+ }
+ val = val*10 + int(c-'0')
+ }
+ keep := !isNum ||
+ ((val < 40 || val > 47) && (val < 100 || val > 107) && val != 49)
+
+ if keep {
+ if sb.Len() > 0 {
+ sb.WriteByte(';')
+ }
+ sb.WriteString(token)
+ }
+ // advance past this token (and the semicolon)
+ i = j + 1
+ }
+
+ // append our new background
+ if sb.Len() > 0 {
+ sb.WriteByte(';')
+ }
+ sb.WriteString(newBg)
+
+ return "\x1b[" + sb.String() + "m"
+ })
+}
diff --git a/internal/tui/styles/icons.go b/internal/tui/styles/icons.go
index f641984e7..96d1b8976 100644
--- a/internal/tui/styles/icons.go
+++ b/internal/tui/styles/icons.go
@@ -1,19 +1,13 @@
package styles
const (
- SessionsIcon string = "󰧑"
- ChatIcon string = "󰭹"
-
- BotIcon string = "󰚩"
- ToolIcon string = ""
- UserIcon string = ""
+ OpenCodeIcon string = "⌬"
CheckIcon string = "✓"
- ErrorIcon string = ""
- WarningIcon string = ""
- InfoIcon string = ""
- HintIcon string = ""
+ ErrorIcon string = "✖"
+ WarningIcon string = "⚠"
+ InfoIcon string = ""
+ HintIcon string = "i"
SpinnerIcon string = "..."
- BugIcon string = ""
- SleepIcon string = "󰒲"
-)
+ LoadingIcon string = "⟳"
+) \ No newline at end of file
diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go
index 77dc314f5..52816eab3 100644
--- a/internal/tui/styles/markdown.go
+++ b/internal/tui/styles/markdown.go
@@ -36,12 +36,13 @@ var catppuccinDark = ansi.StyleConfig{
Italic: boolPtr(true),
Prefix: "┃ ",
},
- Indent: uintPtr(1),
- Margin: uintPtr(defaultMargin),
+ Indent: uintPtr(1),
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
},
List: ansi.StyleList{
LevelIndent: defaultMargin,
StyleBlock: ansi.StyleBlock{
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
StylePrimitive: ansi.StylePrimitive{
Color: stringPtr(dark.Text().Hex),
},
@@ -496,3 +497,445 @@ var catppuccinLight = ansi.StyleConfig{
Color: stringPtr(light.Sapphire().Hex),
},
}
+
+func MarkdownTheme(focused bool) ansi.StyleConfig {
+ if !focused {
+ return ASCIIStyleConfig
+ } else {
+ return DraculaStyleConfig
+ }
+}
+
+const (
+ defaultListIndent = 2
+ defaultListLevelIndent = 4
+)
+
+var ASCIIStyleConfig = ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Color: stringPtr(ForgroundDim.Dark),
+ },
+ Indent: uintPtr(1),
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Indent: uintPtr(1),
+ IndentToken: stringPtr("| "),
+ },
+ Paragraph: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ List: ansi.StyleList{
+ StyleBlock: ansi.StyleBlock{
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ LevelIndent: defaultListLevelIndent,
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ BlockSuffix: "\n",
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "# ",
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "## ",
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "### ",
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "#### ",
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "##### ",
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Prefix: "###### ",
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ BlockPrefix: "~~",
+ BlockSuffix: "~~",
+ },
+ Emph: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ BlockPrefix: "*",
+ BlockSuffix: "*",
+ },
+ Strong: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ BlockPrefix: "**",
+ BlockSuffix: "**",
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Format: "\n--------\n",
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Task: ansi.StyleTask{
+ Ticked: "[x] ",
+ Unticked: "[ ] ",
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ ImageText: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ Format: "Image: {{.text}} →",
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockPrefix: "`",
+ BlockSuffix: "`",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Margin: uintPtr(defaultMargin),
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ },
+ CenterSeparator: stringPtr("|"),
+ ColumnSeparator: stringPtr("|"),
+ RowSeparator: stringPtr("-"),
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ BlockPrefix: "\n* ",
+ },
+}
+
+var DraculaStyleConfig = ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(Forground.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Indent: uintPtr(defaultMargin),
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr("#f1fa8c"),
+ Italic: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Indent: uintPtr(defaultMargin),
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ },
+ Paragraph: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ List: ansi.StyleList{
+ LevelIndent: defaultMargin,
+ StyleBlock: ansi.StyleBlock{
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(Forground.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockSuffix: "\n",
+ Color: stringPtr(PrimaryColor.Dark),
+ Bold: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "# ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "## ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "### ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "#### ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "##### ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "###### ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ CrossedOut: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Emph: ansi.StylePrimitive{
+ Color: stringPtr("#f1fa8c"),
+ Italic: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Strong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ Color: stringPtr(Blue.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ Color: stringPtr("#6272A4"),
+ Format: "\n--------\n",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ Color: stringPtr("#8be9fd"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Task: ansi.StyleTask{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Ticked: "[✓] ",
+ Unticked: "[ ] ",
+ },
+ Link: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ Underline: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ LinkText: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Image: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ Underline: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ ImageText: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ Format: "Image: {{.text}} →",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr("#50fa7b"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ Text: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ DefinitionList: ansi.StyleBlock{},
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: stringPtr(Blue.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Margin: uintPtr(defaultMargin),
+ },
+ Chroma: &ansi.Chroma{
+ NameOther: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Literal: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameException: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ LiteralDate: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Text: ansi.StylePrimitive{
+ Color: stringPtr(Forground.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Error: ansi.StylePrimitive{
+ Color: stringPtr("#f8f8f2"),
+ BackgroundColor: stringPtr("#ff5555"),
+ },
+ Comment: ansi.StylePrimitive{
+ Color: stringPtr("#6272A4"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ CommentPreproc: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Keyword: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ KeywordReserved: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ KeywordNamespace: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ KeywordType: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Operator: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Punctuation: ansi.StylePrimitive{
+ Color: stringPtr(Forground.Dark),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Name: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameBuiltin: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameTag: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameAttribute: ansi.StylePrimitive{
+ Color: stringPtr("#50fa7b"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameClass: ansi.StylePrimitive{
+ Color: stringPtr("#8be9fd"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameConstant: ansi.StylePrimitive{
+ Color: stringPtr("#bd93f9"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameDecorator: ansi.StylePrimitive{
+ Color: stringPtr("#50fa7b"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ NameFunction: ansi.StylePrimitive{
+ Color: stringPtr("#50fa7b"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ LiteralNumber: ansi.StylePrimitive{
+ Color: stringPtr("#6EEFC0"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ LiteralString: ansi.StylePrimitive{
+ Color: stringPtr("#f1fa8c"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ LiteralStringEscape: ansi.StylePrimitive{
+ Color: stringPtr("#ff79c6"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ GenericDeleted: ansi.StylePrimitive{
+ Color: stringPtr("#ff5555"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ GenericEmph: ansi.StylePrimitive{
+ Color: stringPtr("#f1fa8c"),
+ Italic: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ GenericInserted: ansi.StylePrimitive{
+ Color: stringPtr("#50fa7b"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ GenericStrong: ansi.StylePrimitive{
+ Color: stringPtr("#ffb86c"),
+ Bold: boolPtr(true),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ GenericSubheading: ansi.StylePrimitive{
+ Color: stringPtr("#bd93f9"),
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ Background: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+ IndentToken: stringPtr(BaseStyle.Render(" ")),
+ },
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BlockPrefix: "\n* ",
+ BackgroundColor: stringPtr(Background.Dark),
+ },
+}
diff --git a/internal/tui/styles/styles.go b/internal/tui/styles/styles.go
index fe92959e1..476339b57 100644
--- a/internal/tui/styles/styles.go
+++ b/internal/tui/styles/styles.go
@@ -10,6 +10,50 @@ var (
dark = catppuccin.Mocha
)
+// NEW STYLES
+var (
+ Background = lipgloss.AdaptiveColor{
+ Dark: "#212121",
+ Light: "#212121",
+ }
+ BackgroundDim = lipgloss.AdaptiveColor{
+ Dark: "#2c2c2c",
+ Light: "#2c2c2c",
+ }
+ BackgroundDarker = lipgloss.AdaptiveColor{
+ Dark: "#181818",
+ Light: "#181818",
+ }
+ BorderColor = lipgloss.AdaptiveColor{
+ Dark: "#4b4c5c",
+ Light: "#4b4c5c",
+ }
+
+ Forground = lipgloss.AdaptiveColor{
+ Dark: "#d3d3d3",
+ Light: "#d3d3d3",
+ }
+
+ ForgroundMid = lipgloss.AdaptiveColor{
+ Dark: "#a0a0a0",
+ Light: "#a0a0a0",
+ }
+
+ ForgroundDim = lipgloss.AdaptiveColor{
+ Dark: "#737373",
+ Light: "#737373",
+ }
+
+ BaseStyle = lipgloss.NewStyle().
+ Background(Background).
+ Foreground(Forground)
+
+ PrimaryColor = lipgloss.AdaptiveColor{
+ Dark: "#fab283",
+ Light: "#fab283",
+ }
+)
+
var (
Regular = lipgloss.NewStyle()
Bold = Regular.Bold(true)
@@ -120,6 +164,11 @@ var (
Light: light.Peach().Hex,
}
+ Yellow = lipgloss.AdaptiveColor{
+ Dark: dark.Yellow().Hex,
+ Light: light.Yellow().Hex,
+ }
+
Primary = Blue
Secondary = Mauve
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 9e863d2ac..4a723d40d 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -1,56 +1,71 @@
package tui
import (
+ "context"
+
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- "github.com/kujtimiihoxha/termai/internal/app"
- "github.com/kujtimiihoxha/termai/internal/logging"
- "github.com/kujtimiihoxha/termai/internal/permission"
- "github.com/kujtimiihoxha/termai/internal/pubsub"
- "github.com/kujtimiihoxha/termai/internal/tui/components/core"
- "github.com/kujtimiihoxha/termai/internal/tui/components/dialog"
- "github.com/kujtimiihoxha/termai/internal/tui/components/repl"
- "github.com/kujtimiihoxha/termai/internal/tui/layout"
- "github.com/kujtimiihoxha/termai/internal/tui/page"
- "github.com/kujtimiihoxha/termai/internal/tui/util"
- "github.com/kujtimiihoxha/vimtea"
+ "github.com/kujtimiihoxha/opencode/internal/app"
+ "github.com/kujtimiihoxha/opencode/internal/config"
+ "github.com/kujtimiihoxha/opencode/internal/logging"
+ "github.com/kujtimiihoxha/opencode/internal/permission"
+ "github.com/kujtimiihoxha/opencode/internal/pubsub"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/chat"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/core"
+ "github.com/kujtimiihoxha/opencode/internal/tui/components/dialog"
+ "github.com/kujtimiihoxha/opencode/internal/tui/layout"
+ "github.com/kujtimiihoxha/opencode/internal/tui/page"
+ "github.com/kujtimiihoxha/opencode/internal/tui/util"
)
type keyMap struct {
- Logs key.Binding
- Return key.Binding
- Back key.Binding
- Quit key.Binding
- Help key.Binding
+ Logs key.Binding
+ Quit key.Binding
+ Help key.Binding
+ SwitchSession key.Binding
+ Commands key.Binding
}
var keys = keyMap{
Logs: key.NewBinding(
- key.WithKeys("L"),
- key.WithHelp("L", "logs"),
- ),
- Return: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close"),
- ),
- Back: key.NewBinding(
- key.WithKeys("backspace"),
- key.WithHelp("backspace", "back"),
+ key.WithKeys("ctrl+l"),
+ key.WithHelp("ctrl+L", "logs"),
),
+
Quit: key.NewBinding(
- key.WithKeys("ctrl+c", "q"),
- key.WithHelp("ctrl+c/q", "quit"),
+ key.WithKeys("ctrl+c"),
+ key.WithHelp("ctrl+c", "quit"),
),
Help: key.NewBinding(
- key.WithKeys("?"),
- key.WithHelp("?", "toggle help"),
+ key.WithKeys("ctrl+_"),
+ key.WithHelp("ctrl+?", "toggle help"),
+ ),
+
+ SwitchSession: key.NewBinding(
+ key.WithKeys("ctrl+a"),
+ key.WithHelp("ctrl+a", "switch session"),
+ ),
+
+ Commands: key.NewBinding(
+ key.WithKeys("ctrl+k"),
+ key.WithHelp("ctrl+K", "commands"),
),
}
-var replKeyMap = key.NewBinding(
- key.WithKeys("N"),
- key.WithHelp("N", "new session"),
+var helpEsc = key.NewBinding(
+ key.WithKeys("?"),
+ key.WithHelp("?", "toggle help"),
+)
+
+var returnKey = key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+)
+
+var logsKeyReturnKey = key.NewBinding(
+ key.WithKeys("backspace", "q"),
+ key.WithHelp("backspace/q", "go back"),
)
type appModel struct {
@@ -59,19 +74,62 @@ type appModel struct {
previousPage page.PageID
pages map[page.PageID]tea.Model
loadedPages map[page.PageID]bool
- status tea.Model
- help core.HelpCmp
- dialog core.DialogCmp
+ status core.StatusCmp
app *app.App
- dialogVisible bool
- editorMode vimtea.EditorMode
- showHelp bool
+
+ showPermissions bool
+ permissions dialog.PermissionDialogCmp
+
+ showHelp bool
+ help dialog.HelpCmp
+
+ showQuit bool
+ quit dialog.QuitDialog
+
+ showSessionDialog bool
+ sessionDialog dialog.SessionDialog
+
+ showCommandDialog bool
+ commandDialog dialog.CommandDialog
+ commands []dialog.Command
+
+ showInitDialog bool
+ initDialog dialog.InitDialogCmp
+
+ editingMode bool
}
func (a appModel) Init() tea.Cmd {
+ var cmds []tea.Cmd
cmd := a.pages[a.currentPage].Init()
a.loadedPages[a.currentPage] = true
- return cmd
+ cmds = append(cmds, cmd)
+ cmd = a.status.Init()
+ cmds = append(cmds, cmd)
+ cmd = a.quit.Init()
+ cmds = append(cmds, cmd)
+ cmd = a.help.Init()
+ cmds = append(cmds, cmd)
+ cmd = a.sessionDialog.Init()
+ cmds = append(cmds, cmd)
+ cmd = a.commandDialog.Init()
+ cmds = append(cmds, cmd)
+ cmd = a.initDialog.Init()
+ cmds = append(cmds, cmd)
+
+ // Check if we should show the init dialog
+ cmds = append(cmds, func() tea.Msg {
+ shouldShow, err := config.ShouldShowInitDialog()
+ if err != nil {
+ return util.InfoMsg{
+ Type: util.InfoTypeError,
+ Msg: "Failed to check init status: " + err.Error(),
+ }
+ }
+ return dialog.ShowInitDialogMsg{Show: shouldShow}
+ })
+
+ return tea.Batch(cmds...)
}
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -79,68 +137,90 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
- var cmds []tea.Cmd
msg.Height -= 1 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
- a.status, _ = a.status.Update(msg)
+ s, _ := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
+ a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
+ cmds = append(cmds, cmd)
- uh, _ := a.help.Update(msg)
- a.help = uh.(core.HelpCmp)
+ prm, permCmd := a.permissions.Update(msg)
+ a.permissions = prm.(dialog.PermissionDialogCmp)
+ cmds = append(cmds, permCmd)
- p, cmd := a.pages[a.currentPage].Update(msg)
- cmds = append(cmds, cmd)
- a.pages[a.currentPage] = p
+ help, helpCmd := a.help.Update(msg)
+ a.help = help.(dialog.HelpCmp)
+ cmds = append(cmds, helpCmd)
- d, cmd := a.dialog.Update(msg)
- cmds = append(cmds, cmd)
- a.dialog = d.(core.DialogCmp)
+ session, sessionCmd := a.sessionDialog.Update(msg)
+ a.sessionDialog = session.(dialog.SessionDialog)
+ cmds = append(cmds, sessionCmd)
- return a, tea.Batch(cmds...)
+ command, commandCmd := a.commandDialog.Update(msg)
+ a.commandDialog = command.(dialog.CommandDialog)
+ cmds = append(cmds, commandCmd)
+
+ a.initDialog.SetSize(msg.Width, msg.Height)
+ return a, tea.Batch(cmds...)
+ case chat.EditorFocusMsg:
+ a.editingMode = bool(msg)
// Status
case util.InfoMsg:
- a.status, cmd = a.status.Update(msg)
+ s, cmd := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
case pubsub.Event[logging.LogMessage]:
if msg.Payload.Persist {
switch msg.Payload.Level {
case "error":
- a.status, cmd = a.status.Update(util.InfoMsg{
+ s, cmd := a.status.Update(util.InfoMsg{
Type: util.InfoTypeError,
Msg: msg.Payload.Message,
TTL: msg.Payload.PersistTime,
})
+ a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
case "info":
- a.status, cmd = a.status.Update(util.InfoMsg{
+ s, cmd := a.status.Update(util.InfoMsg{
Type: util.InfoTypeInfo,
Msg: msg.Payload.Message,
TTL: msg.Payload.PersistTime,
})
+ a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
+
case "warn":
- a.status, cmd = a.status.Update(util.InfoMsg{
+ s, cmd := a.status.Update(util.InfoMsg{
Type: util.InfoTypeWarn,
Msg: msg.Payload.Message,
TTL: msg.Payload.PersistTime,
})
+ a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
default:
- a.status, cmd = a.status.Update(util.InfoMsg{
+ s, cmd := a.status.Update(util.InfoMsg{
Type: util.InfoTypeInfo,
Msg: msg.Payload.Message,
TTL: msg.Payload.PersistTime,
})
+ a.status = s.(core.StatusCmp)
+ cmds = append(cmds, cmd)
}
- cmds = append(cmds, cmd)
}
case util.ClearStatusMsg:
- a.status, _ = a.status.Update(msg)
+ s, _ := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
// Permission
case pubsub.Event[permission.PermissionRequest]:
- return a, dialog.NewPermissionDialogCmd(msg.Payload)
+ a.showPermissions = true
+ return a, a.permissions.SetPermissions(msg.Payload)
case dialog.PermissionResponseMsg:
+ var cmd tea.Cmd
switch msg.Action {
case dialog.PermissionAllow:
a.app.Permissions.Grant(msg.Permission)
@@ -148,103 +228,229 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Permissions.GrantPersistant(msg.Permission)
case dialog.PermissionDeny:
a.app.Permissions.Deny(msg.Permission)
+ cmd = util.CmdHandler(chat.FocusEditorMsg(true))
}
-
- // Dialog
- case core.DialogMsg:
- d, cmd := a.dialog.Update(msg)
- a.dialog = d.(core.DialogCmp)
- a.dialogVisible = true
+ a.showPermissions = false
return a, cmd
- case core.DialogCloseMsg:
- d, cmd := a.dialog.Update(msg)
- a.dialog = d.(core.DialogCmp)
- a.dialogVisible = false
- return a, cmd
-
- // Editor
- case vimtea.EditorModeMsg:
- a.editorMode = msg.Mode
case page.PageChangeMsg:
return a, a.moveToPage(msg.ID)
+
+ case dialog.CloseQuitMsg:
+ a.showQuit = false
+ return a, nil
+
+ case dialog.CloseSessionDialogMsg:
+ a.showSessionDialog = false
+ return a, nil
+
+ case dialog.CloseCommandDialogMsg:
+ a.showCommandDialog = false
+ return a, nil
+
+ case dialog.ShowInitDialogMsg:
+ a.showInitDialog = msg.Show
+ return a, nil
+
+ case dialog.CloseInitDialogMsg:
+ a.showInitDialog = false
+ if msg.Initialize {
+ // Run the initialization command
+ for _, cmd := range a.commands {
+ if cmd.ID == "init" {
+ // Mark the project as initialized
+ if err := config.MarkProjectInitialized(); err != nil {
+ return a, util.ReportError(err)
+ }
+ return a, cmd.Handler(cmd)
+ }
+ }
+ } else {
+ // Mark the project as initialized without running the command
+ if err := config.MarkProjectInitialized(); err != nil {
+ return a, util.ReportError(err)
+ }
+ }
+ return a, nil
+
+ case chat.SessionSelectedMsg:
+ a.sessionDialog.SetSelectedSession(msg.ID)
+ case dialog.SessionSelectedMsg:
+ a.showSessionDialog = false
+ if a.currentPage == page.ChatPage {
+ return a, util.CmdHandler(chat.SessionSelectedMsg(msg.Session))
+ }
+ return a, nil
+
+ case dialog.CommandSelectedMsg:
+ a.showCommandDialog = false
+ // Execute the command handler if available
+ if msg.Command.Handler != nil {
+ return a, msg.Command.Handler(msg.Command)
+ }
+ return a, util.ReportInfo("Command selected: " + msg.Command.Title)
+
case tea.KeyMsg:
- if a.editorMode == vimtea.ModeNormal {
- switch {
- case key.Matches(msg, keys.Quit):
- return a, dialog.NewQuitDialogCmd()
- case key.Matches(msg, keys.Back):
- if a.previousPage != "" {
- return a, a.moveToPage(a.previousPage)
+ switch {
+ case key.Matches(msg, keys.Quit):
+ a.showQuit = !a.showQuit
+ if a.showHelp {
+ a.showHelp = false
+ }
+ if a.showSessionDialog {
+ a.showSessionDialog = false
+ }
+ if a.showCommandDialog {
+ a.showCommandDialog = false
+ }
+ return a, nil
+ case key.Matches(msg, keys.SwitchSession):
+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog {
+ // Load sessions and show the dialog
+ sessions, err := a.app.Sessions.List(context.Background())
+ if err != nil {
+ return a, util.ReportError(err)
}
- case key.Matches(msg, keys.Return):
- if a.showHelp {
- a.ToggleHelp()
- return a, nil
+ if len(sessions) == 0 {
+ return a, util.ReportWarn("No sessions available")
}
- case key.Matches(msg, replKeyMap):
- if a.currentPage == page.ReplPage {
- sessions, err := a.app.Sessions.List()
- if err != nil {
- return a, util.CmdHandler(util.ReportError(err))
- }
- lastSession := sessions[0]
- if lastSession.MessageCount == 0 {
- return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: lastSession.ID})
- }
- s, err := a.app.Sessions.Create("New Session")
- if err != nil {
- return a, util.CmdHandler(util.ReportError(err))
- }
- return a, util.CmdHandler(repl.SelectedSessionMsg{SessionID: s.ID})
+ a.sessionDialog.SetSessions(sessions)
+ a.showSessionDialog = true
+ return a, nil
+ }
+ return a, nil
+ case key.Matches(msg, keys.Commands):
+ if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog {
+ // Show commands dialog
+ if len(a.commands) == 0 {
+ return a, util.ReportWarn("No commands available")
+ }
+ a.commandDialog.SetCommands(a.commands)
+ a.showCommandDialog = true
+ return a, nil
+ }
+ return a, nil
+ case key.Matches(msg, logsKeyReturnKey):
+ if a.currentPage == page.LogsPage {
+ return a, a.moveToPage(page.ChatPage)
+ }
+ case key.Matches(msg, returnKey):
+ if a.showQuit {
+ a.showQuit = !a.showQuit
+ return a, nil
+ }
+ if a.showHelp {
+ a.showHelp = !a.showHelp
+ return a, nil
+ }
+ if a.showInitDialog {
+ a.showInitDialog = false
+ // Mark the project as initialized without running the command
+ if err := config.MarkProjectInitialized(); err != nil {
+ return a, util.ReportError(err)
+ }
+ return a, nil
+ }
+ case key.Matches(msg, keys.Logs):
+ return a, a.moveToPage(page.LogsPage)
+ case key.Matches(msg, keys.Help):
+ if a.showQuit {
+ return a, nil
+ }
+ a.showHelp = !a.showHelp
+ return a, nil
+ case key.Matches(msg, helpEsc):
+ if !a.editingMode {
+ if a.showQuit {
+ return a, nil
}
- case key.Matches(msg, keys.Logs):
- return a, a.moveToPage(page.LogsPage)
- case key.Matches(msg, keys.Help):
- a.ToggleHelp()
+ a.showHelp = !a.showHelp
return a, nil
}
}
+
}
- if a.dialogVisible {
- d, cmd := a.dialog.Update(msg)
- a.dialog = d.(core.DialogCmp)
- cmds = append(cmds, cmd)
- return a, tea.Batch(cmds...)
+ if a.showQuit {
+ q, quitCmd := a.quit.Update(msg)
+ a.quit = q.(dialog.QuitDialog)
+ cmds = append(cmds, quitCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
+ if a.showPermissions {
+ d, permissionsCmd := a.permissions.Update(msg)
+ a.permissions = d.(dialog.PermissionDialogCmp)
+ cmds = append(cmds, permissionsCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
+
+ if a.showSessionDialog {
+ d, sessionCmd := a.sessionDialog.Update(msg)
+ a.sessionDialog = d.(dialog.SessionDialog)
+ cmds = append(cmds, sessionCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
}
+
+ if a.showCommandDialog {
+ d, commandCmd := a.commandDialog.Update(msg)
+ a.commandDialog = d.(dialog.CommandDialog)
+ cmds = append(cmds, commandCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
+
+ if a.showInitDialog {
+ d, initCmd := a.initDialog.Update(msg)
+ a.initDialog = d.(dialog.InitDialogCmp)
+ cmds = append(cmds, initCmd)
+ // Only block key messages send all other messages down
+ if _, ok := msg.(tea.KeyMsg); ok {
+ return a, tea.Batch(cmds...)
+ }
+ }
+
+ s, _ := a.status.Update(msg)
+ a.status = s.(core.StatusCmp)
a.pages[a.currentPage], cmd = a.pages[a.currentPage].Update(msg)
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
-func (a *appModel) ToggleHelp() {
- if a.showHelp {
- a.showHelp = false
- a.height += a.help.Height()
- } else {
- a.showHelp = true
- a.height -= a.help.Height()
- }
-
- if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
- sizable.SetSize(a.width, a.height)
- }
+// RegisterCommand adds a command to the command dialog
+func (a *appModel) RegisterCommand(cmd dialog.Command) {
+ a.commands = append(a.commands, cmd)
}
func (a *appModel) moveToPage(pageID page.PageID) tea.Cmd {
- var cmd tea.Cmd
+ if a.app.CoderAgent.IsBusy() {
+ // For now we don't move to any page if the agent is busy
+ return util.ReportWarn("Agent is busy, please wait...")
+ }
+ var cmds []tea.Cmd
if _, ok := a.loadedPages[pageID]; !ok {
- cmd = a.pages[pageID].Init()
+ cmd := a.pages[pageID].Init()
+ cmds = append(cmds, cmd)
a.loadedPages[pageID] = true
}
a.previousPage = a.currentPage
a.currentPage = pageID
if sizable, ok := a.pages[a.currentPage].(layout.Sizeable); ok {
- sizable.SetSize(a.width, a.height)
+ cmd := sizable.SetSize(a.width, a.height)
+ cmds = append(cmds, cmd)
}
- return cmd
+ return tea.Batch(cmds...)
}
func (a appModel) View() string {
@@ -252,27 +458,93 @@ func (a appModel) View() string {
a.pages[a.currentPage].View(),
}
+ components = append(components, a.status.View())
+
+ appView := lipgloss.JoinVertical(lipgloss.Top, components...)
+
+ if a.showPermissions {
+ overlay := a.permissions.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
+
+ if a.editingMode {
+ a.status.SetHelpMsg("ctrl+? help")
+ } else {
+ a.status.SetHelpMsg("? help")
+ }
+
if a.showHelp {
bindings := layout.KeyMapToSlice(keys)
if p, ok := a.pages[a.currentPage].(layout.Bindings); ok {
bindings = append(bindings, p.BindingKeys()...)
}
- if a.dialogVisible {
- bindings = append(bindings, a.dialog.BindingKeys()...)
+ if a.showPermissions {
+ bindings = append(bindings, a.permissions.BindingKeys()...)
+ }
+ if a.currentPage == page.LogsPage {
+ bindings = append(bindings, logsKeyReturnKey)
}
- if a.currentPage == page.ReplPage {
- bindings = append(bindings, replKeyMap)
+ if !a.editingMode {
+ bindings = append(bindings, helpEsc)
}
a.help.SetBindings(bindings)
- components = append(components, a.help.View())
+
+ overlay := a.help.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
}
- components = append(components, a.status.View())
+ if a.showQuit {
+ overlay := a.quit.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
- appView := lipgloss.JoinVertical(lipgloss.Top, components...)
+ if a.showSessionDialog {
+ overlay := a.sessionDialog.View()
+ row := lipgloss.Height(appView) / 2
+ row -= lipgloss.Height(overlay) / 2
+ col := lipgloss.Width(appView) / 2
+ col -= lipgloss.Width(overlay) / 2
+ appView = layout.PlaceOverlay(
+ col,
+ row,
+ overlay,
+ appView,
+ true,
+ )
+ }
- if a.dialogVisible {
- overlay := a.dialog.View()
+ if a.showCommandDialog {
+ overlay := a.commandDialog.View()
row := lipgloss.Height(appView) / 2
row -= lipgloss.Height(overlay) / 2
col := lipgloss.Width(appView) / 2
@@ -285,29 +557,60 @@ func (a appModel) View() string {
true,
)
}
+
+ if a.showInitDialog {
+ overlay := a.initDialog.View()
+ appView = layout.PlaceOverlay(
+ a.width/2-lipgloss.Width(overlay)/2,
+ a.height/2-lipgloss.Height(overlay)/2,
+ overlay,
+ appView,
+ true,
+ )
+ }
+
return appView
}
func New(app *app.App) tea.Model {
- // homedir, _ := os.UserHomeDir()
- // configPath := filepath.Join(homedir, ".termai.yaml")
- //
- startPage := page.ReplPage
- // if _, err := os.Stat(configPath); os.IsNotExist(err) {
- // startPage = page.InitPage
- // }
-
- return &appModel{
- currentPage: startPage,
- loadedPages: make(map[page.PageID]bool),
- status: core.NewStatusCmp(),
- help: core.NewHelpCmp(),
- dialog: core.NewDialogCmp(),
- app: app,
+ startPage := page.ChatPage
+ model := &appModel{
+ currentPage: startPage,
+ loadedPages: make(map[page.PageID]bool),
+ status: core.NewStatusCmp(app.LSPClients),
+ help: dialog.NewHelpCmp(),
+ quit: dialog.NewQuitCmp(),
+ sessionDialog: dialog.NewSessionDialogCmp(),
+ commandDialog: dialog.NewCommandDialogCmp(),
+ permissions: dialog.NewPermissionDialogCmp(),
+ initDialog: dialog.NewInitDialogCmp(),
+ app: app,
+ editingMode: true,
+ commands: []dialog.Command{},
pages: map[page.PageID]tea.Model{
+ page.ChatPage: page.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
- page.InitPage: page.NewInitPage(),
- page.ReplPage: page.NewReplPage(app),
},
}
+
+ model.RegisterCommand(dialog.Command{
+ ID: "init",
+ Title: "Initialize Project",
+ Description: "Create/Update the OpenCode.md memory file",
+ Handler: func(cmd dialog.Command) tea.Cmd {
+ prompt := `Please analyze this codebase and create a OpenCode.md file containing:
+1. Build/lint/test commands - especially for running a single test
+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
+
+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
+If there's already a opencode.md, improve it.
+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.`
+ return tea.Batch(
+ util.CmdHandler(chat.SendMsg{
+ Text: prompt,
+ }),
+ )
+ },
+ })
+ return model
}