Merge remote-tracking branch 'upstream/integrate-editor' into exp

Amolith created

# Conflicts:
#	internal/tui/components/chat/editor/editor.go
#	internal/tui/components/dialogs/lazygit/lazygit.go
#	internal/tui/page/chat/chat.go
#	internal/tui/tui.go

Change summary

internal/tui/components/chat/editor/editor.go            |  16 
internal/tui/components/dialogs/commands/commands.go     |  12 
internal/tui/components/dialogs/ghdash/ghdash.go         |  13 
internal/tui/components/dialogs/lazygit/lazygit.go       |   5 
internal/tui/components/dialogs/termdialog/termdialog.go |   7 
internal/tui/components/dialogs/tui_editor/editor.go     | 108 +++
internal/tui/page/chat/chat.go                           | 292 +--------
internal/tui/tui.go                                      |  22 
8 files changed, 206 insertions(+), 269 deletions(-)

Detailed changes

internal/tui/components/chat/editor/editor.go 🔗

@@ -27,6 +27,7 @@ import (
 	"git.secluded.site/crush/internal/tui/components/dialogs/commands"
 	"git.secluded.site/crush/internal/tui/components/dialogs/filepicker"
 	"git.secluded.site/crush/internal/tui/components/dialogs/quit"
+	tuieditor "git.secluded.site/crush/internal/tui/components/dialogs/tui_editor"
 	"git.secluded.site/crush/internal/tui/styles"
 	"git.secluded.site/crush/internal/tui/util"
 )
@@ -96,7 +97,7 @@ type OpenEditorMsg struct {
 func (m *editorCmp) openEditor(value string) tea.Cmd {
 	editor := os.Getenv("EDITOR")
 	if editor == "" {
-		// Use platform-appropriate default editor
+		// Use platform-appropriate default editor.
 		if runtime.GOOS == "windows" {
 			editor = "notepad"
 		} else {
@@ -112,6 +113,14 @@ func (m *editorCmp) openEditor(value string) tea.Cmd {
 	if _, err := tmpfile.WriteString(value); err != nil {
 		return util.ReportError(err)
 	}
+
+	if tuieditor.IsTUIEditor(editor) {
+		return util.CmdHandler(commands.OpenEmbeddedEditorMsg{
+			FilePath: tmpfile.Name(),
+			Editor:   editor,
+		})
+	}
+
 	cmdStr := editor + " " + tmpfile.Name()
 	return util.ExecShell(context.TODO(), cmdStr, func(err error) tea.Msg {
 		if err != nil {
@@ -550,10 +559,7 @@ func (c *editorCmp) IsEmpty() bool {
 func normalPromptFunc(info textarea.PromptInfo) string {
 	t := styles.CurrentTheme()
 	if info.LineNumber == 0 {
-		if info.Focused {
-			return "  > "
-		}
-		return "::: "
+		return "  > "
 	}
 	if info.Focused {
 		return t.S().Base.Foreground(t.GreenDark).Render("::: ")

internal/tui/components/dialogs/commands/commands.go 🔗

@@ -85,10 +85,14 @@ type (
 	ToggleThinkingMsg      struct{}
 	OpenReasoningDialogMsg struct{}
 	OpenExternalEditorMsg  struct{}
-	ToggleYoloModeMsg      struct{}
-	OpenLazygitMsg         struct{}
-	OpenGhDashMsg          struct{}
-	CompactMsg             struct {
+	OpenEmbeddedEditorMsg  struct {
+		FilePath string
+		Editor   string
+	}
+	ToggleYoloModeMsg struct{}
+	OpenLazygitMsg    struct{}
+	OpenGhDashMsg     struct{}
+	CompactMsg        struct {
 		SessionID string
 	}
 )

internal/tui/components/dialogs/ghdash/ghdash.go 🔗

@@ -7,10 +7,12 @@ import (
 	"image/color"
 	"os"
 
-	"github.com/charmbracelet/crush/internal/terminal"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/termdialog"
-	"github.com/charmbracelet/crush/internal/tui/styles"
+	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/styles"
 )
 
 // DialogID is the unique identifier for the gh-dash dialog.
@@ -34,10 +36,11 @@ func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
 		Title:      "GitHub Dashboard",
 		LoadingMsg: "Starting gh-dash...",
 		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
-		OnClose: func() {
+		OnClose: func() tea.Cmd {
 			if configFile != "" {
 				_ = os.Remove(configFile)
 			}
+			return nil
 		},
 	})
 }

internal/tui/components/dialogs/lazygit/lazygit.go 🔗

@@ -7,6 +7,8 @@ import (
 	"image/color"
 	"os"
 
+	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"
@@ -34,10 +36,11 @@ func NewDialog(ctx context.Context, workingDir string) *termdialog.Dialog {
 		Title:      "Lazygit",
 		LoadingMsg: "Starting lazygit...",
 		Term:       terminal.New(terminal.Config{Context: ctx, Cmd: cmd}),
-		OnClose: func() {
+		OnClose: func() tea.Cmd {
 			if configFile != "" {
 				_ = os.Remove(configFile)
 			}
+			return nil
 		},
 	})
 }

internal/tui/components/dialogs/termdialog/termdialog.go 🔗

@@ -32,7 +32,8 @@ type Config struct {
 	// Term is the terminal to embed.
 	Term *terminal.Terminal
 	// OnClose is called when the dialog is closed (optional).
-	OnClose func()
+	// Can return a tea.Cmd to emit messages after close.
+	OnClose func() tea.Cmd
 }
 
 // Dialog is a dialog that embeds a terminal application.
@@ -41,7 +42,7 @@ type Dialog struct {
 	title      string
 	loadingMsg string
 	term       *terminal.Terminal
-	onClose    func()
+	onClose    func() tea.Cmd
 
 	wWidth     int
 	wHeight    int
@@ -243,7 +244,7 @@ func (d *Dialog) Close() tea.Cmd {
 	_ = d.term.Close()
 
 	if d.onClose != nil {
-		d.onClose()
+		return d.onClose()
 	}
 
 	return nil

internal/tui/components/dialogs/tui_editor/editor.go 🔗

@@ -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)),
+			})
+		},
+	})
+}

internal/tui/page/chat/chat.go 🔗

@@ -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
-}

internal/tui/tui.go 🔗

@@ -34,6 +34,7 @@ import (
 	"git.secluded.site/crush/internal/tui/components/dialogs/permissions"
 	"git.secluded.site/crush/internal/tui/components/dialogs/quit"
 	"git.secluded.site/crush/internal/tui/components/dialogs/sessions"
+	tuieditor "git.secluded.site/crush/internal/tui/components/dialogs/tui_editor"
 	"git.secluded.site/crush/internal/tui/page"
 	"git.secluded.site/crush/internal/tui/page/chat"
 	"git.secluded.site/crush/internal/tui/styles"
@@ -321,6 +322,27 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return a, util.CmdHandler(dialogs.OpenDialogMsg{
 			Model: ghdash.NewDialog(a.app.Context(), a.app.Config().WorkingDir()),
 		})
+	// Embedded TUI Editor
+	case commands.OpenEmbeddedEditorMsg:
+		if a.dialog.ActiveDialogID() == tuieditor.DialogID {
+			return a, util.CmdHandler(dialogs.CloseDialogMsg{})
+		}
+		return a, util.CmdHandler(dialogs.OpenDialogMsg{
+			Model: tuieditor.NewDialog(a.app.Context(), tuieditor.Config{
+				FilePath:   msg.FilePath,
+				Editor:     msg.Editor,
+				WorkingDir: a.app.Config().WorkingDir(),
+			}),
+		})
+	// Editor result - forward to page
+	case tuieditor.EditorResultMsg:
+		item, ok := a.pages[a.currentPage]
+		if !ok {
+			return a, nil
+		}
+		updated, itemCmd := item.Update(msg)
+		a.pages[a.currentPage] = updated
+		return a, itemCmd
 	// Permissions
 	case pubsub.Event[permission.PermissionNotification]:
 		item, ok := a.pages[a.currentPage]