@@ -0,0 +1,108 @@
+// Package tuieditor provides a dialog component for embedding terminal-based
+// editors (vim, nvim, nano, etc.) in the TUI.
+package tuieditor
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+
+ "git.secluded.site/crush/internal/terminal"
+ "git.secluded.site/crush/internal/tui/components/dialogs"
+ "git.secluded.site/crush/internal/tui/components/dialogs/termdialog"
+ "git.secluded.site/crush/internal/tui/util"
+)
+
+// DialogID is the unique identifier for the embedded editor dialog.
+const DialogID dialogs.DialogID = "tui_editor"
+
+// EditorResultMsg is sent when the embedded editor closes with the file content.
+type EditorResultMsg struct {
+ Content string
+ Err error
+}
+
+// knownTUIEditors is a list of terminal-based editors that can be embedded.
+var knownTUIEditors = []string{
+ "vim",
+ "nvim",
+ "vi",
+ "nano",
+ "helix",
+ "hx",
+ "micro",
+ "emacs",
+ "joe",
+ "ne",
+ "jed",
+ "kak",
+ "pico",
+ "mcedit",
+ "mg",
+ "zile",
+}
+
+// IsTUIEditor returns true if the given editor command is a known TUI editor.
+func IsTUIEditor(editor string) bool {
+ base := filepath.Base(editor)
+ if idx := strings.Index(base, " "); idx != -1 {
+ base = base[:idx]
+ }
+ return slices.Contains(knownTUIEditors, base)
+}
+
+// Config holds configuration for the embedded editor dialog.
+type Config struct {
+ // FilePath is the path to the file to edit.
+ FilePath string
+ // Editor is the editor command to use.
+ Editor string
+ // WorkingDir is the working directory for the editor.
+ WorkingDir string
+}
+
+// NewDialog creates a new embedded editor dialog. The context controls the
+// lifetime of the editor process - when cancelled, the process will be killed.
+// When the editor exits, an EditorResultMsg is emitted with the file content.
+func NewDialog(ctx context.Context, cfg Config) *termdialog.Dialog {
+ editorCmd := cfg.Editor
+ if editorCmd == "" {
+ editorCmd = "nvim"
+ }
+
+ parts := strings.Fields(editorCmd)
+ cmdName := parts[0]
+ args := append(parts[1:], cfg.FilePath)
+
+ cmd := terminal.PrepareCmd(
+ ctx,
+ cmdName,
+ args,
+ cfg.WorkingDir,
+ nil,
+ )
+
+ filePath := cfg.FilePath
+
+ return termdialog.New(termdialog.Config{
+ ID: DialogID,
+ Title: "Editor",
+ LoadingMsg: "Starting editor...",
+ Term: terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
+ OnClose: func() tea.Cmd {
+ content, err := os.ReadFile(filePath)
+ _ = os.Remove(filePath)
+
+ if err != nil {
+ return util.CmdHandler(EditorResultMsg{Err: err})
+ }
+ return util.CmdHandler(EditorResultMsg{
+ Content: strings.TrimSpace(string(content)),
+ })
+ },
+ })
+}
@@ -34,6 +34,7 @@ import (
"git.secluded.site/crush/internal/tui/components/dialogs/filepicker"
"git.secluded.site/crush/internal/tui/components/dialogs/models"
"git.secluded.site/crush/internal/tui/components/dialogs/reasoning"
+ tuieditor "git.secluded.site/crush/internal/tui/components/dialogs/tui_editor"
"git.secluded.site/crush/internal/tui/page"
"git.secluded.site/crush/internal/tui/styles"
"git.secluded.site/crush/internal/tui/util"
@@ -57,14 +58,6 @@ const (
PanelTypeSplash PanelType = "splash"
)
-// PillSection represents which pill section is focused when in pills panel.
-type PillSection int
-
-const (
- PillSectionTodos PillSection = iota
- PillSectionQueue
-)
-
const (
CompactModeWidthBreakpoint = 120 // Width at which the chat page switches to compact mode
CompactModeHeightBreakpoint = 30 // Height at which the chat page switches to compact mode
@@ -124,18 +117,9 @@ type chatPage struct {
splashFullScreen bool
isOnboarding bool
isProjectInit bool
- promptQueue int
-
- // Pills state
- pillsExpanded bool
- focusedPillSection PillSection
-
- // Todo spinner
- todoSpinner spinner.Model
}
func New(app *app.App) ChatPage {
- t := styles.CurrentTheme()
return &chatPage{
app: app,
keyMap: DefaultKeyMap(),
@@ -145,10 +129,6 @@ func New(app *app.App) ChatPage {
editor: editor.New(app),
splash: splash.New(),
focusedPane: PanelTypeSplash,
- todoSpinner: spinner.New(
- spinner.WithSpinner(spinner.MiniDot),
- spinner.WithStyle(t.S().Base.Foreground(t.GreenDark)),
- ),
}
}
@@ -187,13 +167,6 @@ func (p *chatPage) Init() tea.Cmd {
func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
var cmds []tea.Cmd
- if p.session.ID != "" && p.app.AgentCoordinator != nil {
- queueSize := p.app.AgentCoordinator.QueuedPrompts(p.session.ID)
- if queueSize != p.promptQueue {
- p.promptQueue = queueSize
- cmds = append(cmds, p.SetSize(p.width, p.height))
- }
- }
switch msg := msg.(type) {
case tea.KeyboardEnhancementsMsg:
p.keyboardEnhancements = msg
@@ -295,20 +268,18 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
u, cmd := p.editor.Update(msg)
p.editor = u.(editor.Editor)
return p, cmd
- case pubsub.Event[session.Session]:
- if msg.Payload.ID == p.session.ID {
- prevHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- prevHasInProgress := p.hasInProgressTodo()
- p.session = msg.Payload
- newHasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- newHasInProgress := p.hasInProgressTodo()
- if prevHasIncompleteTodos != newHasIncompleteTodos {
- cmds = append(cmds, p.SetSize(p.width, p.height))
- }
- if !prevHasInProgress && newHasInProgress {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
+ case tuieditor.EditorResultMsg:
+ if msg.Err != nil {
+ return p, util.ReportError(msg.Err)
}
+ if msg.Content == "" {
+ return p, util.ReportWarn("Message is empty")
+ }
+ // Forward the result to the editor as OpenEditorMsg.
+ u, cmd := p.editor.Update(editor.OpenEditorMsg{Text: msg.Content})
+ p.editor = u.(editor.Editor)
+ return p, cmd
+ case pubsub.Event[session.Session]:
u, cmd := p.header.Update(msg)
p.header = u.(header.Header)
cmds = append(cmds, cmd)
@@ -352,17 +323,6 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
case pubsub.Event[message.Message],
anim.StepMsg,
spinner.TickMsg:
- // Update todo spinner if agent is busy and we have in-progress todos
- agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
- if _, ok := msg.(spinner.TickMsg); ok && p.hasInProgressTodo() && agentBusy {
- var cmd tea.Cmd
- p.todoSpinner, cmd = p.todoSpinner.Update(msg)
- cmds = append(cmds, cmd)
- }
- // Start spinner when agent becomes busy and we have in-progress todos
- if _, ok := msg.(pubsub.Event[message.Message]); ok && p.hasInProgressTodo() && agentBusy {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
if p.focusedPane == PanelTypeSplash {
u, cmd := p.splash.Update(msg)
p.splash = u.(splash.Splash)
@@ -453,7 +413,8 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
p.splash = u.(splash.Splash)
return p, cmd
}
- return p, p.changeFocus()
+ p.changeFocus()
+ return p, nil
case key.Matches(msg, p.keyMap.Cancel):
if p.session.ID != "" && p.app.AgentCoordinator.IsBusy() {
return p, p.cancel()
@@ -461,18 +422,6 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
case key.Matches(msg, p.keyMap.Details):
p.toggleDetails()
return p, nil
- case key.Matches(msg, p.keyMap.TogglePills):
- if p.session.ID != "" {
- return p, p.togglePillsExpanded()
- }
- case key.Matches(msg, p.keyMap.PillLeft):
- if p.session.ID != "" && p.pillsExpanded {
- return p, p.switchPillSection(-1)
- }
- case key.Matches(msg, p.keyMap.PillRight):
- if p.session.ID != "" && p.pillsExpanded {
- return p, p.switchPillSection(1)
- }
}
switch p.focusedPane {
@@ -546,78 +495,19 @@ func (p *chatPage) View() string {
} else {
messagesView := p.chat.View()
editorView := p.editor.View()
-
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- todosFocused := p.pillsExpanded && p.focusedPillSection == PillSectionTodos
- queueFocused := p.pillsExpanded && p.focusedPillSection == PillSectionQueue
-
- // Use spinner when agent is busy, otherwise show static icon
- agentBusy := p.app.AgentCoordinator != nil && p.app.AgentCoordinator.IsBusy()
- inProgressIcon := t.S().Base.Foreground(t.GreenDark).Render(styles.CenterSpinnerIcon)
- if agentBusy {
- inProgressIcon = p.todoSpinner.View()
- }
-
- var pills []string
- if hasIncompleteTodos {
- pills = append(pills, todoPill(p.session.Todos, inProgressIcon, todosFocused, p.pillsExpanded, t))
- }
- if hasQueue {
- pills = append(pills, queuePill(p.promptQueue, queueFocused, p.pillsExpanded, t))
- }
-
- var expandedList string
- if p.pillsExpanded {
- if todosFocused && hasIncompleteTodos {
- expandedList = todoList(p.session.Todos, inProgressIcon, t, p.width-SideBarWidth)
- } else if queueFocused && hasQueue {
- queueItems := p.app.AgentCoordinator.QueuedPromptsList(p.session.ID)
- expandedList = queueList(queueItems, t)
- }
- }
-
- var pillsArea string
- if len(pills) > 0 {
- pillsRow := lipgloss.JoinHorizontal(lipgloss.Top, pills...)
-
- if expandedList != "" {
- pillsArea = lipgloss.JoinVertical(
- lipgloss.Left,
- pillsRow,
- expandedList,
- )
- } else {
- pillsArea = pillsRow
- }
-
- style := t.S().Base.MarginTop(1).PaddingLeft(3)
- pillsArea = style.Render(pillsArea)
- }
-
if p.compact {
headerView := p.header.View()
- views := []string{headerView, messagesView}
- if pillsArea != "" {
- views = append(views, pillsArea)
- }
- views = append(views, editorView)
- chatView = lipgloss.JoinVertical(lipgloss.Left, views...)
+ chatView = lipgloss.JoinVertical(
+ lipgloss.Left,
+ headerView,
+ messagesView,
+ editorView,
+ )
} else {
sidebarView := p.sidebar.View()
- var messagesColumn string
- if pillsArea != "" {
- messagesColumn = lipgloss.JoinVertical(
- lipgloss.Left,
- messagesView,
- pillsArea,
- )
- } else {
- messagesColumn = messagesView
- }
messages := lipgloss.JoinHorizontal(
lipgloss.Left,
- messagesColumn,
+ messagesView,
sidebarView,
)
chatView = lipgloss.JoinVertical(
@@ -779,30 +669,14 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd {
cmds = append(cmds, p.editor.SetPosition(0, height-EditorHeight))
}
} else {
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- hasPills := hasIncompleteTodos || hasQueue
-
- pillsAreaHeight := 0
- if hasPills {
- pillsAreaHeight = pillHeightWithBorder + 1 // +1 for padding top
- if p.pillsExpanded {
- if p.focusedPillSection == PillSectionTodos && hasIncompleteTodos {
- pillsAreaHeight += len(p.session.Todos)
- } else if p.focusedPillSection == PillSectionQueue && hasQueue {
- pillsAreaHeight += p.promptQueue
- }
- }
- }
-
if p.compact {
- cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight-pillsAreaHeight))
+ cmds = append(cmds, p.chat.SetSize(width, height-EditorHeight-HeaderHeight))
p.detailsWidth = width - DetailsPositioning
cmds = append(cmds, p.sidebar.SetSize(p.detailsWidth-LeftRightBorders, p.detailsHeight-TopBottomBorders))
cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
cmds = append(cmds, p.header.SetWidth(width-BorderWidth))
} else {
- cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight-pillsAreaHeight))
+ cmds = append(cmds, p.chat.SetSize(width-SideBarWidth, height-EditorHeight))
cmds = append(cmds, p.editor.SetSize(width, EditorHeight))
cmds = append(cmds, p.sidebar.SetSize(SideBarWidth, height-EditorHeight))
}
@@ -827,77 +701,37 @@ func (p *chatPage) newSession() tea.Cmd {
)
}
-func (p *chatPage) setSession(sess session.Session) tea.Cmd {
- if p.session.ID == sess.ID {
+func (p *chatPage) setSession(session session.Session) tea.Cmd {
+ if p.session.ID == session.ID {
return nil
}
var cmds []tea.Cmd
- p.session = sess
-
- if p.hasInProgressTodo() {
- cmds = append(cmds, p.todoSpinner.Tick)
- }
+ p.session = session
cmds = append(cmds, p.SetSize(p.width, p.height))
- cmds = append(cmds, p.chat.SetSession(sess))
- cmds = append(cmds, p.sidebar.SetSession(sess))
- cmds = append(cmds, p.header.SetSession(sess))
- cmds = append(cmds, p.editor.SetSession(sess))
+ cmds = append(cmds, p.chat.SetSession(session))
+ cmds = append(cmds, p.sidebar.SetSession(session))
+ cmds = append(cmds, p.header.SetSession(session))
+ cmds = append(cmds, p.editor.SetSession(session))
return tea.Sequence(cmds...)
}
-func (p *chatPage) changeFocus() tea.Cmd {
+func (p *chatPage) changeFocus() {
if p.session.ID == "" {
- return nil
+ return
}
-
switch p.focusedPane {
- case PanelTypeEditor:
- p.focusedPane = PanelTypeChat
- p.chat.Focus()
- p.editor.Blur()
case PanelTypeChat:
p.focusedPane = PanelTypeEditor
p.editor.Focus()
p.chat.Blur()
+ case PanelTypeEditor:
+ p.focusedPane = PanelTypeChat
+ p.chat.Focus()
+ p.editor.Blur()
}
- return nil
-}
-
-func (p *chatPage) togglePillsExpanded() tea.Cmd {
- hasPills := hasIncompleteTodos(p.session.Todos) || p.promptQueue > 0
- if !hasPills {
- return nil
- }
- p.pillsExpanded = !p.pillsExpanded
- if p.pillsExpanded {
- if hasIncompleteTodos(p.session.Todos) {
- p.focusedPillSection = PillSectionTodos
- } else {
- p.focusedPillSection = PillSectionQueue
- }
- }
- return p.SetSize(p.width, p.height)
-}
-
-func (p *chatPage) switchPillSection(dir int) tea.Cmd {
- if !p.pillsExpanded {
- return nil
- }
- hasIncompleteTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
-
- if dir < 0 && p.focusedPillSection == PillSectionQueue && hasIncompleteTodos {
- p.focusedPillSection = PillSectionTodos
- return p.SetSize(p.width, p.height)
- }
- if dir > 0 && p.focusedPillSection == PillSectionTodos && hasQueue {
- p.focusedPillSection = PillSectionQueue
- return p.SetSize(p.width, p.height)
- }
- return nil
}
func (p *chatPage) cancel() tea.Cmd {
@@ -1183,53 +1017,18 @@ func (p *chatPage) Help() help.KeyMap {
globalBindings := []key.Binding{}
// we are in a session
if p.session.ID != "" {
- var tabKey key.Binding
- switch p.focusedPane {
- case PanelTypeEditor:
- tabKey = key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus chat"),
- )
- case PanelTypeChat:
+ tabKey := key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "focus chat"),
+ )
+ if p.focusedPane == PanelTypeChat {
tabKey = key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "focus editor"),
)
- default:
- tabKey = key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("tab", "focus chat"),
- )
}
shortList = append(shortList, tabKey)
globalBindings = append(globalBindings, tabKey)
-
- // Add toggle pills binding if there are pills
- hasTodos := hasIncompleteTodos(p.session.Todos)
- hasQueue := p.promptQueue > 0
- if hasTodos || hasQueue {
- toggleBinding := p.keyMap.TogglePills
- if p.pillsExpanded {
- if hasTodos {
- toggleBinding.SetHelp("ctrl+space", "hide todos")
- } else {
- toggleBinding.SetHelp("ctrl+space", "hide queued")
- }
- } else {
- if hasTodos {
- toggleBinding.SetHelp("ctrl+space", "show todos")
- } else {
- toggleBinding.SetHelp("ctrl+space", "show queued")
- }
- }
- shortList = append(shortList, toggleBinding)
- globalBindings = append(globalBindings, toggleBinding)
- // Show left/right to switch sections when expanded and both exist
- if p.pillsExpanded && hasTodos && hasQueue {
- shortList = append(shortList, p.keyMap.PillLeft)
- globalBindings = append(globalBindings, p.keyMap.PillLeft)
- }
- }
}
commandsBinding := key.NewBinding(
key.WithKeys("ctrl+p"),
@@ -1420,12 +1219,3 @@ func (p *chatPage) isMouseOverChat(x, y int) bool {
// Check if mouse coordinates are within chat bounds
return x >= chatX && x < chatX+chatWidth && y >= chatY && y < chatY+chatHeight
}
-
-func (p *chatPage) hasInProgressTodo() bool {
- for _, todo := range p.session.Todos {
- if todo.Status == session.TodoStatusInProgress {
- return true
- }
- }
- return false
-}