diff options
| author | Kujtim Hoxha <[email protected]> | 2025-03-23 19:19:08 +0100 |
|---|---|---|
| committer | Kujtim Hoxha <[email protected]> | 2025-03-23 19:19:08 +0100 |
| commit | 8daa6e774a6e02698c90392e7b2008542f789594 (patch) | |
| tree | 8053fc1a4de87fc8dc3fd6e776577b0221c02ac5 /internal/tui/components | |
| parent | 796bbf4d66c0fc70e750c7f582019cb9a7298e59 (diff) | |
| download | opencode-8daa6e774a6e02698c90392e7b2008542f789594.tar.gz opencode-8daa6e774a6e02698c90392e7b2008542f789594.zip | |
add initial stuff
Diffstat (limited to 'internal/tui/components')
| -rw-r--r-- | internal/tui/components/core/dialog.go | 84 | ||||
| -rw-r--r-- | internal/tui/components/core/help.go | 22 | ||||
| -rw-r--r-- | internal/tui/components/dialog/quit.go | 111 | ||||
| -rw-r--r-- | internal/tui/components/repl/editor.go | 127 | ||||
| -rw-r--r-- | internal/tui/components/repl/messages.go | 15 | ||||
| -rw-r--r-- | internal/tui/components/repl/sessions.go | 161 | ||||
| -rw-r--r-- | internal/tui/components/repl/threads.go | 21 |
7 files changed, 496 insertions, 45 deletions
diff --git a/internal/tui/components/core/dialog.go b/internal/tui/components/core/dialog.go new file mode 100644 index 000000000..954e26e87 --- /dev/null +++ b/internal/tui/components/core/dialog.go @@ -0,0 +1,84 @@ +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 +} + +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 +} + +func (d *dialogCmp) Init() tea.Cmd { + return nil +} + +func (d *dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case DialogMsg: + d.content = msg.Content + 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 { + w, h := d.content.GetSize() + return lipgloss.NewStyle().Width(w).Height(h).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 index 9402d9362..4ef857c78 100644 --- a/internal/tui/components/core/help.go +++ b/internal/tui/components/core/help.go @@ -24,23 +24,23 @@ type helpCmp struct { bindings []key.Binding } -func (m *helpCmp) Init() tea.Cmd { +func (h *helpCmp) Init() tea.Cmd { return nil } -func (m *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (h *helpCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.width = msg.Width + h.width = msg.Width } - return m, nil + return h, nil } -func (m *helpCmp) View() string { +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(m.bindings) + bindings := removeDuplicateBindings(h.bindings) // Enumerate through each group of bindings, populating a series of // pairs of columns, one for keys, one for descriptions var ( @@ -72,7 +72,7 @@ func (m *helpCmp) View() string { // check whether it exceeds the maximum width avail (the width of the // terminal, subtracting 2 for the borders). width += lipgloss.Width(pair) - if width > m.width-2 { + if width > h.width-2 { break } pairs = append(pairs, pair) @@ -80,7 +80,7 @@ func (m *helpCmp) View() string { // Join pairs of columns and enclose in a border content := lipgloss.JoinHorizontal(lipgloss.Top, pairs...) - return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(m.width - 2).Render(content) + return styles.DoubleBorder.Height(rows).PaddingLeft(1).Width(h.width - 2).Render(content) } func removeDuplicateBindings(bindings []key.Binding) []key.Binding { @@ -103,11 +103,11 @@ func removeDuplicateBindings(bindings []key.Binding) []key.Binding { return result } -func (m *helpCmp) SetBindings(bindings []key.Binding) { - m.bindings = bindings +func (h *helpCmp) SetBindings(bindings []key.Binding) { + h.bindings = bindings } -func (m helpCmp) Height() int { +func (h helpCmp) Height() int { return helpWidgetHeight } diff --git a/internal/tui/components/dialog/quit.go b/internal/tui/components/dialog/quit.go new file mode 100644 index 000000000..e73550ab5 --- /dev/null +++ b/internal/tui/components/dialog/quit.go @@ -0,0 +1,111 @@ +package dialog + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "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" +) + +const question = "Are you sure you want to quit?" + +var ( + width = lipgloss.Width(question) + 6 + height = 3 +) + +type QuitDialog interface { + tea.Model + layout.Sizeable + layout.Bindings +} + +type quitDialogCmp struct { + form *huh.Form + width int + height int +} + +func (q *quitDialogCmp) Init() tea.Cmd { + return nil +} + +func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + // Process the form + 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 { + return q, tea.Quit + } + cmds = append(cmds, util.CmdHandler(core.DialogCloseMsg{})) + } + + return q, tea.Batch(cmds...) +} + +func (q *quitDialogCmp) View() string { + return q.form.View() +} + +func (q *quitDialogCmp) GetSize() (int, int) { + return q.width, q.height +} + +func (q *quitDialogCmp) SetSize(width int, height int) { + q.width = width + q.height = height +} + +func (q *quitDialogCmp) BindingKeys() []key.Binding { + return q.form.KeyBinds() +} + +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)). + WithWidth(width). + WithHeight(height). + WithShowHelp(false). + WithTheme(theme). + WithShowErrors(false) + confirm.Focus() + return &quitDialogCmp{ + form: form, + width: width, + } +} + +func NewQuitDialogCmd() tea.Cmd { + content := layout.NewSinglePane( + newQuitDialogCmp().(*quitDialogCmp), + layout.WithSignlePaneSize(width+2, height+2), + layout.WithSinglePaneBordered(true), + layout.WithSinglePaneFocusable(true), + layout.WithSinglePaneActiveColor(styles.Warning), + ) + content.Focus() + return util.CmdHandler(core.DialogMsg{ + Content: content, + }) +} diff --git a/internal/tui/components/repl/editor.go b/internal/tui/components/repl/editor.go index 8a04889ab..1de216bd0 100644 --- a/internal/tui/components/repl/editor.go +++ b/internal/tui/components/repl/editor.go @@ -1,21 +1,130 @@ package repl -import tea "github.com/charmbracelet/bubbletea" +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/kujtimiihoxha/termai/internal/app" + "github.com/kujtimiihoxha/termai/internal/tui/layout" + "github.com/kujtimiihoxha/vimtea" +) -type editorCmp struct{} +type EditorCmp interface { + tea.Model + layout.Focusable + layout.Sizeable + layout.Bordered +} + +type editorCmp struct { + app *app.App + editor vimtea.Editor + editorMode vimtea.EditorMode + sessionID string + focused bool + width int + height int +} + +type localKeyMap struct { + SendMessage key.Binding + SendMessageI key.Binding +} + +var keyMap = localKeyMap{ + 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"), + ), +} + +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, keyMap.SendMessage): + if m.editorMode == vimtea.ModeNormal { + return m, m.Send() + } + case key.Matches(msg, keyMap.SendMessageI): + if m.editorMode == vimtea.ModeInsert { + return m, m.Send() + } + } + } + u, cmd := m.editor.Update(msg) + m.editor = u.(vimtea.Editor) + return m, cmd + } + return m, nil +} -func (i *editorCmp) Init() tea.Cmd { +// Blur implements EditorCmp. +func (m *editorCmp) Blur() tea.Cmd { + m.focused = false return nil } -func (i *editorCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return i, nil +// BorderText implements EditorCmp. +func (m *editorCmp) BorderText() map[layout.BorderPosition]string { + return map[layout.BorderPosition]string{ + layout.TopLeftBorder: "New Message", + } +} + +// Focus implements EditorCmp. +func (m *editorCmp) Focus() tea.Cmd { + m.focused = true + return m.editor.Tick() +} + +// GetSize implements EditorCmp. +func (m *editorCmp) GetSize() (int, int) { + return m.width, m.height +} + +// IsFocused implements EditorCmp. +func (m *editorCmp) IsFocused() bool { + return m.focused +} + +// SetSize implements EditorCmp. +func (m *editorCmp) SetSize(width int, height int) { + m.width = width + m.height = height + m.editor.SetSize(width, height) +} + +func (m *editorCmp) Send() tea.Cmd { + return func() tea.Msg { + // TODO: Send message + return nil + } } -func (i *editorCmp) View() string { - return "Editor" +func (m *editorCmp) View() string { + return m.editor.View() } -func NewEditorCmp() tea.Model { - return &editorCmp{} +func NewEditorCmp(app *app.App) EditorCmp { + return &editorCmp{ + app: app, + editor: vimtea.NewEditor(), + } } diff --git a/internal/tui/components/repl/messages.go b/internal/tui/components/repl/messages.go index edef26502..cd3ddac71 100644 --- a/internal/tui/components/repl/messages.go +++ b/internal/tui/components/repl/messages.go @@ -1,8 +1,13 @@ package repl -import tea "github.com/charmbracelet/bubbletea" +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/kujtimiihoxha/termai/internal/app" +) -type messagesCmp struct{} +type messagesCmp struct { + app *app.App +} func (i *messagesCmp) Init() tea.Cmd { return nil @@ -16,6 +21,8 @@ func (i *messagesCmp) View() string { return "Messages" } -func NewMessagesCmp() tea.Model { - return &messagesCmp{} +func NewMessagesCmp(app *app.App) tea.Model { + return &messagesCmp{ + app, + } } diff --git a/internal/tui/components/repl/sessions.go b/internal/tui/components/repl/sessions.go new file mode 100644 index 000000000..aef9b993e --- /dev/null +++ b/internal/tui/components/repl/sessions.go @@ -0,0 +1,161 @@ +package repl + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/kujtimiihoxha/termai/internal/app" + "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 +} + +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 { + session, err := i.app.Sessions.Create( + "New Session", + ) + if err != nil { + return util.ReportError(err) + } + existing = append(existing, session) + } + 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: fmt.Sprintf("Tokens: %d, Cost: %.2f", s.Tokens, s.Cost), + } + } + return i, i.list.SetItems(items) + } + 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, + ) + return map[layout.BorderPosition]string{ + layout.TopMiddleBorder: "Sessions", + layout.BottomMiddleBorder: pageInfo, + } +} + +func (i *sessionsCmp) BindingKeys() []key.Binding { + return layout.KeyMapToSlice(i.list.KeyMap) +} + +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/components/repl/threads.go b/internal/tui/components/repl/threads.go deleted file mode 100644 index aa2bc080b..000000000 --- a/internal/tui/components/repl/threads.go +++ /dev/null @@ -1,21 +0,0 @@ -package repl - -import tea "github.com/charmbracelet/bubbletea" - -type threadsCmp struct{} - -func (i *threadsCmp) Init() tea.Cmd { - return nil -} - -func (i *threadsCmp) Update(_ tea.Msg) (tea.Model, tea.Cmd) { - return i, nil -} - -func (i *threadsCmp) View() string { - return "Threads" -} - -func NewThreadsCmp() tea.Model { - return &threadsCmp{} -} |
