diff --git a/README.md b/README.md
index fa99d41..e8bdace 100644
--- a/README.md
+++ b/README.md
@@ -30,8 +30,8 @@
Welcome — up and running in 10 seconds
-
- Commands palette — every shortcut, one keystroke away (ctrl+p)
+
+ Settings panel — every shortcut, one keystroke away (ctrl+p)
|
@@ -56,7 +56,11 @@
-> **Keyboard shortcuts:** `ctrl+p` commands · `ctrl+m` model · `ctrl+s` sessions · `ctrl+,` provider config · `ctrl+e` select mode · `ctrl+c` cancel/quit
+> **Keyboard shortcuts:** `ctrl+p` settings · `ctrl+m` model · `ctrl+s` sessions · `ctrl+,` provider config · `ctrl+shift+c` copy selection · `ctrl+c` cancel/quit
+
+> **Clipboard note (Linux):** selection copy works best when `wl-clipboard` (Wayland) or `xclip`/`xsel` (X11) is installed. Without a system clipboard backend, Nexus can request terminal clipboard access but cannot guarantee a real system copy.
+
+> **Compaction note:** transcript compaction is automatic today. A dedicated manual compact action is planned for the TUI once the runtime exposes a real manual-compaction hook.
---
diff --git a/cmd/cli/tui.go b/cmd/cli/tui.go
index 76ee7ab..3df6bf0 100644
--- a/cmd/cli/tui.go
+++ b/cmd/cli/tui.go
@@ -6,6 +6,7 @@ import (
"log"
"os"
"path/filepath"
+ "sort"
"strings"
"sync"
"sync/atomic"
@@ -15,9 +16,10 @@ import (
"github.com/EngineerProjects/nexus-engine/internal/monitoring"
"github.com/EngineerProjects/nexus-engine/internal/providers"
"github.com/EngineerProjects/nexus-engine/internal/tui"
- tuimodel "github.com/EngineerProjects/nexus-engine/internal/tui/model"
+ tuiapp "github.com/EngineerProjects/nexus-engine/internal/tui/app"
engineconfig "github.com/EngineerProjects/nexus-engine/pkg/config"
"github.com/EngineerProjects/nexus-engine/pkg/sdk"
+ skillspkg "github.com/EngineerProjects/nexus-engine/pkg/skills"
)
// chunkDebounce batches streaming text chunks at 33ms intervals (crush pattern).
@@ -351,6 +353,78 @@ func (w *nexusWorkspace) DeleteProviderField(ctx context.Context, providerID, fi
return database.DeleteCredential(ctx, providerCredKey(fieldKey, providerID))
}
+func (w *nexusWorkspace) LoadToolCatalog(ctx context.Context) []tui.ToolInfo {
+ surface, err := w.client.BuildToolSurface(ctx)
+ if err != nil || surface == nil {
+ return nil
+ }
+ items := make([]tui.ToolInfo, 0, len(surface.Tools))
+ for _, tool := range surface.Tools {
+ items = append(items, tui.ToolInfo{
+ Name: tool.Name,
+ Description: tool.Description,
+ Category: tool.Category,
+ })
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
+func (w *nexusWorkspace) LoadMCPServers(_ context.Context) []tui.MCPServerInfo {
+ result := w.client.MCPResult()
+ if result == nil {
+ return nil
+ }
+ items := make([]tui.MCPServerInfo, 0, len(result.ServerResults))
+ for _, server := range result.ServerResults {
+ status := "ready"
+ errMsg := ""
+ if server.Error != nil {
+ status = "error"
+ errMsg = server.Error.Error()
+ }
+ items = append(items, tui.MCPServerInfo{
+ Name: server.Name,
+ ToolsRegistered: server.ToolsRegistered,
+ Status: status,
+ Error: errMsg,
+ })
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
+func (w *nexusWorkspace) LoadSkills(_ context.Context) []tui.SkillInfo {
+ skills, err := skillspkg.All(w.workDir)
+ if err != nil {
+ return nil
+ }
+ items := make([]tui.SkillInfo, 0, len(skills))
+ for _, skill := range skills {
+ if !skill.UserInvocable {
+ continue
+ }
+ description := strings.TrimSpace(skill.Description)
+ if description == "" {
+ description = strings.TrimSpace(skill.WhenToUse)
+ }
+ items = append(items, tui.SkillInfo{
+ Name: skill.Name,
+ Description: description,
+ WhenToUse: strings.TrimSpace(skill.WhenToUse),
+ Source: string(skill.Source),
+ })
+ }
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Name < items[j].Name
+ })
+ return items
+}
+
func (w *nexusWorkspace) Close() {
w.client.Close()
}
@@ -384,6 +458,7 @@ func (w *nexusWorkspace) onProgress(progress sdk.ToolProgress) {
ToolName: progress.ToolName,
Status: string(progress.Stage),
Label: label,
+ Metadata: progress.Metadata,
})
}
@@ -433,7 +508,7 @@ func runInteractive(ctx context.Context, options runtimeOptions) error {
}
defer ws.Close()
- return tuimodel.Run(ws, ctx)
+ return tuiapp.Run(ws, ctx)
}
// buildTUIMonitoring creates a monitoring system that writes to a log file
diff --git a/docs/captures/commands_pannel.png b/docs/captures/commands_pannel.png
index 0c30c4f..fa36fc4 100644
Binary files a/docs/captures/commands_pannel.png and b/docs/captures/commands_pannel.png differ
diff --git a/docs/captures/goal_ui/image.png b/docs/captures/goal_ui/image.png
new file mode 100644
index 0000000..256eb92
Binary files /dev/null and b/docs/captures/goal_ui/image.png differ
diff --git a/docs/captures/home.png b/docs/captures/home.png
index 7ac020f..9634b81 100644
Binary files a/docs/captures/home.png and b/docs/captures/home.png differ
diff --git a/docs/captures/model_selction.png b/docs/captures/model_selction.png
index a6263ea..e2197c4 100644
Binary files a/docs/captures/model_selction.png and b/docs/captures/model_selction.png differ
diff --git a/docs/captures/provider_config.png b/docs/captures/provider_config.png
index 6bbdf6a..b2f62ed 100644
Binary files a/docs/captures/provider_config.png and b/docs/captures/provider_config.png differ
diff --git a/docs/captures/working1.png b/docs/captures/working1.png
index 6f4e72b..8b538e8 100644
Binary files a/docs/captures/working1.png and b/docs/captures/working1.png differ
diff --git a/docs/captures/working2.png b/docs/captures/working2.png
index 016ec03..7cc5a37 100644
Binary files a/docs/captures/working2.png and b/docs/captures/working2.png differ
diff --git a/docs/tui-roadmap.md b/docs/tui-roadmap.md
new file mode 100644
index 0000000..10ae810
--- /dev/null
+++ b/docs/tui-roadmap.md
@@ -0,0 +1,128 @@
+# TUI Roadmap
+
+This note tracks the current UX progress of the Nexus CLI TUI and the next interaction work.
+
+## Completed
+
+### 1. Welcome wordmark cleanup
+- Removed the extra leading bullet from the welcome screen wordmark.
+- Status: done
+
+### 2. Footer simplification
+- Removed `ctrl+e` select mode from the visible happy path.
+- Simplified the default footer actions.
+- Tool navigation is no longer advertised as `tab chat/tools` in the primary footer flow.
+- Status: done
+
+### 3. Working status lane above composer
+- Moved the active `working` indicator out of the header.
+- Added a status lane directly above the composer for runtime visibility.
+- The lane now focuses on runtime state only: `working`, `failed`, or `ready`.
+- Status: done
+
+### 4. Primary chat layout polish
+- The app now uses near-full-width layout with small left/right margins instead of a narrow centered column.
+- User messages use an inline blue `● >` marker.
+- Assistant messages use an orange `●` marker.
+- Intermediate assistant segments created around tool calls no longer show false `done` states.
+- Final assistant metadata is attached only to the true end of the turn.
+- Status: done
+
+### 5. Tool rendering baseline
+- Added richer tool summaries and previews for core tools.
+- Kept completed tools visually more neutral so green is reserved for actual turn completion.
+- Added a right-side details pane for selected tools.
+- Status: done
+
+### 6. Shared markdown renderer
+- Switched from raw environment-configured glamour usage to a shared markdown helper in `internal/tui/common/markdown.go`.
+- Added cached renderers by width and per-renderer locking, following the same structural idea as Crush.
+- Kept a Nexus-specific style decision: markdown headings no longer show visible `##` / `###` prefixes in the main chat renderer.
+- Status: done
+
+## Partially Done
+
+### 7. Footer token/context usage
+- The footer now shows cumulative token usage for the current observed session in the TUI.
+- Per-turn token usage is rendered on the final assistant meta line instead of duplicating it in multiple places.
+- Remaining work:
+ - expose reliable model context-window capacity
+ - add a context percentage once that data is available
+- Status: in progress
+
+### 8. Commands / settings reorganization
+- The footer has already been simplified and older noise removed.
+- `ctrl+p` is now a true settings hub with nested sections for commands, providers, models, tools, MCP, and skills.
+- Generic slash commands are no longer advertised there; slash input is now reserved for skills in the chat composer.
+- The `Tools`, `MCP`, and `Skills` sections now load live data from the current workspace/runtime instead of showing only static placeholder copy.
+- Status: done
+
+## Next Priorities
+
+### 9. Mouse-first selection and copy
+- Implemented:
+ - mouse event routing in the main model
+ - drag-to-copy text selection in chat
+ - copy on mouse release
+ - persistent colored selection after mouse release
+ - double-click word selection
+ - triple-click line selection
+ - auto-scroll while dragging at viewport edges
+ - `ctrl+shift+c` copy shortcut
+ - right-click copy attempt when the terminal forwards the event
+ - accurate clipboard-availability notice when Linux clipboard backends are missing
+ - `ctrl+shift+c` copy shortcut
+ - right-click copy attempt when the terminal forwards the event
+ - accurate clipboard-availability notice when Linux clipboard backends are missing
+- Remaining work:
+ - refine copy semantics for visual chat markers versus plain content where needed
+- Status: in progress
+
+### 10. Clickable tool rows and richer interactions
+- Implemented:
+ - tool rows can now be clicked
+ - clicking a tool row selects it
+ - explicit click targets exist for expand and details
+ - thinking blocks can be expanded or collapsed directly with the mouse
+- Remaining work:
+ - smoother IDE-like interactions around the side pane
+- Status: in progress
+
+### 11. Commands / settings panel expansion
+- Completed:
+ - skills
+ - tools
+ - MCP
+ - model/provider settings
+ - session actions
+- Remaining work:
+ - deepen each section into richer management views instead of simple searchable lists
+- Status: in progress
+
+### 11b. Manual compaction trigger
+- A true manual compact action is still missing.
+- The runtime currently auto-compacts, but the TUI/SDK surface does not yet expose a dedicated manual compaction API.
+- Do not fake this with a normal prompt command; it should be a real engine operation once exposed.
+- Candidate UX later:
+ - Settings / Commands entry: `Compact Context`
+ - optional shortcut such as `ctrl+l` once the runtime hook exists
+- Status: planned
+
+### 12. Context percentage and model capacity visibility
+- Once model context capacity is reliably available in TUI state, show clear session usage such as:
+ - `12.4k total`
+ - `31% context`
+- Status: planned
+
+## Recommended Implementation Order
+
+1. Mouse-first selection and copy behavior
+2. Clickable tool rows and detail interactions
+3. Commands/settings reorganization
+4. Context-window percentage and model-capacity display
+
+## Notes
+
+- Crush remains the right reference for markdown renderer structure, mouse selection, and interaction polish.
+- Nexus intentionally diverges from Crush on some visual choices, especially markdown heading presentation and chat chrome.
+- `AGENTS.md` should stay focused on engineering rules; roadmap items belong in docs like this file.
diff --git a/internal/tools/files/edit/edit.go b/internal/tools/files/edit/edit.go
index 01ceb04..022e006 100644
--- a/internal/tools/files/edit/edit.go
+++ b/internal/tools/files/edit/edit.go
@@ -324,7 +324,9 @@ func (e *Tool) Call(
output["git_diff"] = gitDiff
}
- return tool.NewJSONResult(output), nil
+ res := tool.NewJSONResult(output)
+ res.Metadata = &tool.ResultMetadata{Additional: output}
+ return res, nil
}
func normalizedBaseName(filePath string) string {
diff --git a/internal/tools/files/write/write.go b/internal/tools/files/write/write.go
index ffea81f..83b0556 100644
--- a/internal/tools/files/write/write.go
+++ b/internal/tools/files/write/write.go
@@ -308,7 +308,9 @@ func (w *Tool) Call(
output["git_diff"] = gitDiff
}
- return tool.NewJSONResult(output), nil
+ res := tool.NewJSONResult(output)
+ res.Metadata = &tool.ResultMetadata{Additional: output}
+ return res, nil
}
func nullableOriginal(oldContent string, fileExists bool) any {
diff --git a/internal/tui/AGENTS.md b/internal/tui/AGENTS.md
new file mode 100644
index 0000000..7d590f5
--- /dev/null
+++ b/internal/tui/AGENTS.md
@@ -0,0 +1,147 @@
+# TUI Development Guide
+
+This file describes how to work on the `internal/tui` package safely and
+incrementally.
+
+---
+
+## Purpose
+
+The Nexus TUI is the interactive terminal shell for `nexus-engine`.
+It is intentionally thinner than the core runtime:
+
+- `internal/engine` owns agent execution.
+- `internal/tui` owns presentation, input routing, and local interaction state.
+- `internal/tui/workspace.go` is the boundary between the UI and the engine.
+
+Do not move engine logic into the TUI layer.
+
+---
+
+## Current Architecture
+
+The TUI currently follows a **single top-level Bubble Tea model** pattern:
+
+- `app/model.go` owns the main `Model`, the high-level UI state, and the message
+ switch in `Update`.
+- Sub-components in `components/` are stateful structs with imperative methods:
+ `chat`, `session_list`, `permission_dialog`, `model_picker`,
+ `command_palette`, `config_panel`, `file_completions`, and `attachments`.
+- `workspace.go` defines the interface used by the TUI. The CLI provides the
+ concrete implementation and pushes `tea.Msg` events into the model.
+
+This is already close to Crush's architecture. Keep going in that direction.
+
+---
+
+## Rules
+
+- Keep the top-level `Model` as the only Bubble Tea model.
+- Do not create nested Bubble Tea sub-model trees unless there is a very strong
+ reason.
+- Do not do blocking I/O or expensive work inside `Update`.
+- Use `tea.Cmd` for side effects and asynchronous work.
+- Do not mutate model state from inside a command. Return a message and update
+ state in `Update`.
+- Keep TUI state local to the TUI package. Runtime state belongs to the
+ workspace / engine boundary.
+- Preserve the current separation:
+ - `workspace` does engine work
+ - `model` decides how that work is displayed
+ - `common` holds shared rendering helpers and styles
+ - `notification`, `pubsub`, `anim`, `fsext`, `csync` stay as supporting
+ packages
+
+---
+
+## Component Guidance
+
+### `app/model.go`
+
+Own here:
+
+- high-level state machine
+- focus management
+- layout calculations
+- command routing
+- overlay orchestration
+
+Do not let sub-components start calling each other directly in complex ways.
+Route transitions through the main model.
+
+### `components/chat.go`
+
+Keep chat behavior deterministic and testable:
+
+- item creation
+- continuation behavior after tool calls
+- thinking block collapse / expand
+- tool progress updates
+- content extraction helpers
+
+If rendering becomes more complex, prefer splitting renderers into a dedicated
+subpackage later rather than growing one file indefinitely.
+
+### Overlay / dialog-style components
+
+Session browser, permission prompts, model picker, commands palette, and
+provider config should remain independent state holders with:
+
+- `SetSize(...)`
+- small mutation methods
+- one `View()` method
+
+That keeps them easy to test without a running terminal.
+
+---
+
+## Testing Strategy
+
+Manual terminal testing is still useful, but it is not enough once the TUI
+grows. Add automated tests for logic that is stable and deterministic.
+
+Prioritize:
+
+1. state transitions
+2. filtering / selection logic
+3. continuation logic after tool calls
+4. layout sizing calculations
+5. pure rendering helpers with stable text output
+
+Avoid starting with broad snapshot tests of the whole screen. They become
+fragile too early. Start with small unit tests around component behavior.
+
+Good first targets:
+
+- `sessionList` filtering, cursor movement, deletion
+- `chat` thinking blocks, tool progress, assistant continuation rules
+- `Model.relayout()` and other pure layout helpers
+
+When the TUI structure stabilizes, introduce golden-style tests for key views.
+
+---
+
+## Refactor Direction
+
+The next structural step should move the TUI closer to Crush's package
+discipline without copying its full complexity.
+
+Recommended future extraction order:
+
+1. `internal/tui/common`
+2. `internal/tui/components`
+3. `internal/tui/app`
+
+Do this gradually. Do not attempt a one-shot rewrite.
+
+---
+
+## Definition Of Done
+
+For non-trivial TUI changes:
+
+- the feature works in a real terminal
+- at least one automated test covers the new logic when practical
+- `go test ./...` still passes
+- the change does not push engine concerns into the UI layer
+
diff --git a/internal/tui/app/model.go b/internal/tui/app/model.go
new file mode 100644
index 0000000..a4bda0e
--- /dev/null
+++ b/internal/tui/app/model.go
@@ -0,0 +1,1540 @@
+// Package model implements the BubbleTea TUI for nexus-engine, adapted from
+// Charm's crush project architecture (BubbleTea state machine, workspace
+// abstraction, draw cache, permission dialog, session browser).
+package app
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "charm.land/bubbles/v2/spinner"
+ "charm.land/bubbles/v2/textarea"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/components"
+ clipboard "github.com/atotto/clipboard"
+)
+
+type uiState uint8
+
+const (
+ stateWelcome uiState = iota
+ stateChat
+ stateSessions
+ statePermission
+ stateModelSelect
+ stateCommands
+ stateProviderConfig
+)
+
+// uiFocus mirrors crush's focus model: editor has the cursor / main lets
+// the user scroll chat with arrow keys.
+type uiFocus uint8
+
+const (
+ uiFocusEditor uiFocus = iota // textarea is active (default)
+ uiFocusMain // chat list is scrollable with arrow keys
+)
+
+const (
+ headerHeight = 1
+ footerHeight = 1
+ statusHeight = 1
+ inputMinH = 1
+ inputMaxH = 10
+ inputPadding = 1
+)
+
+// Model is the top-level BubbleTea model for nexus-engine's TUI.
+type Model struct {
+ workspace tui.Workspace
+ ctx context.Context
+ cancel context.CancelFunc
+
+ state uiState
+ keys common.KeyMap
+ styles common.Styles
+
+ width int
+ height int
+
+ chat *components.Chat
+ sessions *components.SessionList
+ permission *components.PermissionDialog
+ modelSelect *components.ModelPicker
+ commands *components.CommandPalette
+ configPanel *components.ConfigPanel
+ completions *components.FileCompletions
+ skillCompletions *components.SkillCompletions
+ attachments *components.Attachments
+ input textarea.Model
+ spinner spinner.Model
+
+ focus uiFocus
+ busy bool
+ activeSession string
+ lastErr error
+ permInput string
+ copyNotice string // transient "Copied!" message shown in footer
+ returnState uiState // state to restore when pressing ← from a sub-dialog
+ lastInputTokens int
+ lastOutputTokens int
+ lastStopReason string
+ sessionInputTokens int
+ sessionOutputTokens int
+ lastTurnErr string
+ skillCatalog []tui.SkillInfo
+ skillCatalogLoaded bool
+}
+
+func New(ws tui.Workspace, ctx context.Context) Model {
+ ctx, cancel := context.WithCancel(ctx)
+
+ styles := common.DefaultStyles()
+ keys := common.DefaultKeys()
+
+ ta := textarea.New()
+ ta.SetStyles(styles.Textarea)
+ ta.Placeholder = "Ask Nexus... /skill"
+ ta.ShowLineNumbers = false
+ ta.CharLimit = -1
+ ta.SetVirtualCursor(true)
+ ta.DynamicHeight = true
+ ta.MinHeight = inputMinH
+ ta.MaxHeight = inputMaxH
+ ta.SetPromptFunc(4, editorPrompt(styles))
+ ta.SetWidth(80)
+ ta.SetHeight(inputMinH)
+ // Don't call Focus() here — do it in Init() so the Cmd runs properly.
+
+ sp := spinner.New()
+ sp.Spinner = spinner.Dot
+ sp.Style = lipgloss.NewStyle().Foreground(common.ColorYellow)
+
+ return Model{
+ workspace: ws,
+ ctx: ctx,
+ cancel: cancel,
+ state: stateWelcome,
+ focus: uiFocusEditor,
+ keys: keys,
+ styles: styles,
+ chat: components.NewChat(styles, 80, 20),
+ sessions: components.NewSessionList(styles),
+ permission: components.NewPermissionDialog(styles),
+ modelSelect: components.NewModelPicker(styles),
+ commands: components.NewCommandPalette(styles),
+ configPanel: components.NewConfigPanel(styles),
+ completions: components.NewFileCompletions(styles, ws.WorkingDir()),
+ skillCompletions: components.NewSkillCompletions(styles),
+ attachments: components.NewAttachments(styles),
+ input: ta,
+ spinner: sp,
+ }
+}
+
+// ─── BubbleTea v2 interface ───────────────────────────────────────────────────
+
+func (m Model) Init() tea.Cmd {
+ // Focus() in bubbles/v2 returns a Cmd that sets up the cursor — must run.
+ return tea.Batch(
+ m.input.Focus(),
+ m.spinner.Tick,
+ m.loadSessions(),
+ )
+}
+
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m = m.relayout()
+
+ case spinner.TickMsg:
+ if m.busy {
+ newSp, cmd := m.spinner.Update(msg)
+ m.spinner = newSp
+ cmds = append(cmds, cmd)
+ }
+
+ case tui.ChunkMsg:
+ if m.state == stateChat || m.state == statePermission {
+ m.chat.AppendChunk(msg.Text, msg.IsThinking)
+ }
+
+ case tui.ToolProgressMsg:
+ label := msg.Label
+ if label == "" {
+ label = msg.Status
+ }
+ m.chat.AddToolProgress(msg.ToolUseID, msg.ToolName, msg.Status, label, msg.Metadata)
+
+ case tui.TurnStartMsg:
+ m.busy = true
+ m.lastTurnErr = ""
+ m.chat.StartAssistantMessage()
+ cmds = append(cmds, m.spinner.Tick)
+
+ case tui.TurnDoneMsg:
+ m.busy = false
+ m.lastInputTokens = msg.InputTokens
+ m.lastOutputTokens = msg.OutputTokens
+ m.lastStopReason = msg.StopReason
+ m.sessionInputTokens += msg.InputTokens
+ m.sessionOutputTokens += msg.OutputTokens
+ m.lastTurnErr = ""
+ m.chat.FinishAssistantMessage(msg.InputTokens, msg.OutputTokens, msg.StopReason)
+ if msg.Err != nil {
+ m.lastTurnErr = msg.Err.Error()
+ m.chat.AddError(msg.Err)
+ }
+
+ case tui.PromptRequestMsg:
+ m.permission.SetPending(&msg)
+ m.permInput = ""
+ m.state = statePermission
+
+ case tui.SessionListMsg:
+ if msg.Err == nil {
+ m.sessions.SetSessions(msg.Sessions)
+ }
+
+ case tui.SessionCreatedMsg:
+ if msg.Err != nil {
+ m.lastErr = msg.Err
+ } else {
+ m.activeSession = msg.ID
+ m.state = stateChat
+ m.focus = uiFocusEditor
+ m.sessionInputTokens = 0
+ m.sessionOutputTokens = 0
+ m.chat.Clear()
+ m.sessionInputTokens = 0
+ m.sessionOutputTokens = 0
+ m.chat.AddSystem("New session · " + common.ShortID(msg.ID))
+ cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd
+ }
+
+ case tui.SessionLoadedMsg:
+ if msg.Err != nil {
+ m.lastErr = msg.Err
+ } else {
+ m.activeSession = msg.ID
+ m.state = stateChat
+ m.focus = uiFocusEditor
+ m.chat.Clear()
+ m.chat.AddSystem("Resumed session · " + common.ShortID(msg.ID))
+ cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd
+ }
+
+ case tui.ModelListMsg:
+ if msg.Err == nil {
+ m.modelSelect.SetModels(msg.Models)
+ }
+
+ case tui.ModelChangedMsg:
+ // Header will pick up new model string from workspace.ModelString()
+
+ case tui.ErrMsg:
+ m.lastErr = msg.Err
+
+ case clearCopyNoticeMsg:
+ m.copyNotice = ""
+
+ case providerConfigLoadedMsg:
+ m.configPanel.SetProviders(msg.providers)
+
+ case cfgSaveResultMsg:
+ if msg.err != nil {
+ m.configPanel.SetError(msg.err.Error())
+ } else {
+ m.configPanel.SetSaved()
+ }
+
+ // v2 uses KeyPressMsg instead of KeyMsg
+ case tea.KeyPressMsg:
+ consumed, cmd := m.handleKey(msg)
+ if cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ // Non-consumed keys flow to the textarea so regular characters,
+ // backspace, and cursor movement work normally.
+ if !consumed && (m.state == stateChat || m.state == stateWelcome) {
+ newInput, inputCmd := m.input.Update(msg)
+ m.input = newInput
+ cmds = append(cmds, inputCmd)
+ m = m.resizeInput()
+ m.syncComposerAssist()
+ }
+ return m, tea.Batch(cmds...)
+
+ case tea.MouseClickMsg:
+ if cmd := m.handleMouseClick(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ return m, tea.Batch(cmds...)
+
+ case tea.MouseMotionMsg:
+ if m.handleMouseMotion(msg) {
+ return m, tea.Batch(cmds...)
+ }
+
+ case tea.MouseReleaseMsg:
+ if cmd := m.handleMouseRelease(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+ }
+ case tea.MouseWheelMsg:
+ if (m.state == stateChat || m.state == stateWelcome) && m.skillCompletions.IsOpen() {
+ layout := m.currentChatLayout()
+ if pointInRect(msg.X, msg.Y, layout.popupX, layout.popupY, layout.popupW, layout.popupH) {
+ switch msg.Button {
+ case tea.MouseWheelUp:
+ m.skillCompletions.Scroll(-1)
+ case tea.MouseWheelDown:
+ m.skillCompletions.Scroll(1)
+ }
+ return m, tea.Batch(cmds...)
+ }
+ }
+ // Mouse wheel scrolls chat regardless of focus state (no Tab required).
+ if m.state == stateChat || m.state == stateWelcome {
+ switch msg.Button {
+ case tea.MouseWheelUp:
+ m.chat.ScrollUp(3)
+ case tea.MouseWheelDown:
+ m.chat.ScrollDown(3)
+ }
+ }
+ return m, tea.Batch(cmds...)
+ }
+
+ // Non-key messages (spinner, window resize, etc.) are also forwarded
+ // to the textarea so blinking and focus work correctly.
+ if m.state == stateChat || m.state == stateWelcome {
+ newInput, cmd := m.input.Update(msg)
+ m.input = newInput
+ cmds = append(cmds, cmd)
+ m = m.resizeInput()
+ m.syncComposerAssist()
+ }
+
+ return m, tea.Batch(cmds...)
+}
+
+// View returns a tea.View (v2 API — not a string).
+func (m Model) View() tea.View {
+ if m.width == 0 {
+ return tea.NewView("")
+ }
+
+ var content string
+ switch m.state {
+ case stateWelcome:
+ content = m.viewWelcome()
+ case stateSessions:
+ content = m.viewSessions()
+ case stateModelSelect:
+ content = m.viewModelSelect()
+ case stateCommands:
+ content = m.viewCommands()
+ case stateProviderConfig:
+ content = m.viewProviderConfig()
+ case stateChat, statePermission:
+ content = m.viewChat()
+ default:
+ content = m.viewChat()
+ }
+
+ v := tea.NewView(content)
+ v.AltScreen = true
+ v.MouseMode = tea.MouseModeCellMotion
+ return v
+}
+
+type chatLayout struct {
+ contentW int
+ contentX int
+ chatX int
+ chatY int
+ chatW int
+ chatH int
+ inputX int
+ inputY int
+ inputW int
+ inputH int
+ popupX int
+ popupY int
+ popupW int
+ popupH int
+}
+
+func (m Model) currentChatLayout() chatLayout {
+ inputView := m.inputView()
+ statusView := m.statusLine()
+ contentW := m.contentWidth()
+ chatH := m.height - headerHeight - footerHeight - lipgloss.Height(statusView) - lipgloss.Height(inputView)
+ chatW := contentW
+ inputW := max(12, contentW-2)
+ inputX := max(0, (m.width-inputW)/2)
+ popupW := 0
+ popupH := 0
+ popupX := inputX
+ if m.chat.DetailsOpen() && contentW >= 110 {
+ paneW := max(36, contentW/3)
+ chatW = max(40, contentW-paneW-1)
+ }
+ contentX := max(0, (m.width-contentW)/2)
+ chatY := headerHeight
+ inputY := chatY + max(1, chatH) + lipgloss.Height(statusView)
+ if m.skillCompletions.IsOpen() {
+ popupW = m.skillCompletions.Width(max(24, contentW-4))
+ popupH = m.skillCompletions.Height(max(24, contentW-4))
+ }
+ return chatLayout{
+ contentW: contentW,
+ contentX: contentX,
+ chatX: contentX,
+ chatY: chatY,
+ chatW: chatW,
+ chatH: max(1, chatH),
+ inputX: inputX,
+ inputY: inputY,
+ inputW: inputW,
+ inputH: lipgloss.Height(inputView),
+ popupX: popupX,
+ popupY: inputY,
+ popupW: popupW,
+ popupH: popupH,
+ }
+}
+
+func pointInRect(x, y, rx, ry, rw, rh int) bool {
+ return x >= rx && x < rx+rw && y >= ry && y < ry+rh
+}
+
+func clampMouse(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func (m *Model) handleMouseClick(msg tea.MouseClickMsg) tea.Cmd {
+ if m.state != stateChat {
+ return nil
+ }
+ layout := m.currentChatLayout()
+ if m.skillCompletions.IsOpen() && pointInRect(msg.X, msg.Y, layout.popupX, layout.popupY, layout.popupW, layout.popupH) {
+ if msg.Button == tea.MouseLeft {
+ row := msg.Y - layout.popupY - 1
+ if sel := m.skillCompletions.ClickRow(row); sel != "" {
+ m.input.SetValue(sel + " ")
+ m.input.CursorEnd()
+ m.skillCompletions.Close()
+ m.focus = uiFocusEditor
+ *m = m.resizeInput()
+ return m.input.Focus()
+ }
+ }
+ return nil
+ }
+ if pointInRect(msg.X, msg.Y, layout.inputX, layout.inputY+layout.popupH, layout.inputW, layout.inputH-layout.popupH) {
+ m.focus = uiFocusEditor
+ return m.input.Focus()
+ }
+ if !pointInRect(msg.X, msg.Y, layout.chatX, layout.chatY, layout.chatW, layout.chatH) {
+ return nil
+ }
+ if msg.Button == tea.MouseRight {
+ if text := m.chat.SelectedText(); text != "" {
+ return m.copyToClipboard(text, "Selection copied")
+ }
+ return nil
+ }
+ if msg.Button != tea.MouseLeft {
+ return nil
+ }
+ m.focus = uiFocusMain
+ m.input.Blur()
+ m.chat.HandleMouseDown(msg.X-layout.chatX, msg.Y-layout.chatY)
+ return nil
+}
+
+func (m *Model) handleMouseMotion(msg tea.MouseMotionMsg) bool {
+ if m.state != stateChat || !m.chat.HasMouseCapture() {
+ return false
+ }
+ layout := m.currentChatLayout()
+ relX := msg.X - layout.chatX
+ relY := msg.Y - layout.chatY
+ return m.chat.HandleMouseDrag(relX, relY)
+}
+
+func (m *Model) handleMouseRelease(msg tea.MouseReleaseMsg) tea.Cmd {
+ if m.state != stateChat || !m.chat.HasMouseCapture() {
+ return nil
+ }
+ layout := m.currentChatLayout()
+ relX := msg.X - layout.chatX
+ relY := msg.Y - layout.chatY
+ if text := m.chat.HandleMouseUp(relX, relY); text != "" {
+ return m.copyToClipboard(text, "Selection copied")
+ }
+ return nil
+}
+
+// ─── Key handling ─────────────────────────────────────────────────────────────
+
+// handleKey processes a keypress. Returns (consumed, cmd):
+// - consumed=true → key was handled; do NOT forward to textarea
+// - consumed=false → key was not handled; forward to textarea for normal input
+func (m *Model) handleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ k := msg.String()
+ stroke := msg.Keystroke()
+
+ if stroke == "ctrl+shift+c" {
+ if text := m.chat.SelectedText(); text != "" {
+ return true, m.copyToClipboard(text, "Selection copied")
+ }
+ return true, nil
+ }
+
+ // ── Permission dialog (all keys consumed) ────────────────────────────
+ if m.state == statePermission && m.permission.HasPending() {
+ switch {
+ case k == "y" || k == "Y":
+ m.permission.Resolve(true, false)
+ m.state = stateChat
+ case k == "n" || k == "N" || k == "esc":
+ m.permission.Resolve(false, true)
+ m.state = stateChat
+ case k == "a" || k == "A":
+ m.permission.Resolve("always", false)
+ m.state = stateChat
+ default:
+ m.permInput += k
+ }
+ return true, nil
+ }
+
+ // ── Model selection (all keys consumed) ─────────────────────────────
+ if m.state == stateModelSelect {
+ switch k {
+ case "esc", "ctrl+m":
+ if m.returnState == stateCommands {
+ m.refreshSettingsHubData()
+ m.state = stateCommands
+ m.commands.Open("")
+ } else {
+ m.state = m.prevChatState()
+ }
+ case "left":
+ // Navigate back to the settings hub if that's where we came from,
+ // otherwise close to the chat/welcome state.
+ if m.returnState == stateCommands {
+ m.refreshSettingsHubData()
+ m.state = stateCommands
+ m.commands.Open("")
+ } else {
+ m.state = m.prevChatState()
+ }
+ case "up":
+ m.modelSelect.Up()
+ case "down":
+ m.modelSelect.Down()
+ case "enter":
+ if sel := m.modelSelect.Selected(); sel != nil {
+ m.workspace.SetModel(sel.Provider, sel.Identifier)
+ m.state = m.prevChatState()
+ }
+ case "backspace":
+ m.modelSelect.DeleteFilter()
+ default:
+ if len(k) == 1 {
+ m.modelSelect.TypeFilter(k)
+ }
+ }
+ return true, nil
+ }
+
+ // ── Settings hub (all keys consumed) ────────────────────────────────
+ if m.state == stateCommands {
+ switch k {
+ case "esc", "ctrl+p":
+ if !m.commands.Back() {
+ m.state = m.prevChatState()
+ }
+ case "left":
+ if !m.commands.Back() {
+ m.state = m.prevChatState()
+ }
+ case "up":
+ m.commands.Up()
+ case "down":
+ m.commands.Down()
+ case "enter":
+ return true, m.activateSettingsSelection()
+ case "backspace":
+ m.commands.DeleteFilter()
+ default:
+ if len(k) == 1 {
+ m.commands.TypeFilter(k)
+ }
+ }
+ return true, nil
+ }
+
+ // ── Provider config panel (all keys consumed) ───────────────────────
+ if m.state == stateProviderConfig {
+ cp := m.configPanel
+ if cp.IsEditing() {
+ switch k {
+ case "esc":
+ m.state = m.prevChatState()
+ case "left":
+ cp.ExitEdit()
+ // Reload provider status after editing.
+ return true, m.loadProviderConfig()
+ case "up":
+ cp.Up()
+ case "down":
+ cp.Down()
+ case "tab":
+ cp.Down()
+ case "enter":
+ draft, _, fieldKey := cp.CurrentFieldDraft()
+ if strings.TrimSpace(draft) == "" {
+ return true, nil
+ }
+ providerID := cp.EditedProviderID()
+ return true, func() tea.Msg {
+ err := m.workspace.SaveProviderField(m.ctx, providerID, fieldKey, strings.TrimSpace(draft))
+ if err != nil {
+ return cfgSaveResultMsg{err: err}
+ }
+ return cfgSaveResultMsg{}
+ }
+ case "backspace":
+ cp.DeleteChar()
+ case "ctrl+v":
+ cp.ToggleReveal()
+ default:
+ if len(k) == 1 {
+ cp.TypeChar(k)
+ }
+ }
+ } else {
+ switch k {
+ case "esc", "ctrl+,":
+ if m.returnState == stateCommands {
+ m.refreshSettingsHubData()
+ m.state = stateCommands
+ m.commands.Open("")
+ } else {
+ m.state = m.prevChatState()
+ }
+ case "up":
+ cp.Up()
+ case "down":
+ cp.Down()
+ case "enter":
+ cp.EnterEdit()
+ case "backspace":
+ cp.DeleteFilter()
+ default:
+ if len(k) == 1 {
+ cp.TypeFilter(k)
+ }
+ }
+ }
+ return true, nil
+ }
+
+ // ── Session browser (all keys consumed) ─────────────────────────────
+ if m.state == stateSessions {
+ switch k {
+ case "esc", "ctrl+s":
+ m.state = m.prevChatState()
+ case "up":
+ m.sessions.Up()
+ case "down":
+ m.sessions.Down()
+ case "enter":
+ id := m.sessions.Selected()
+ if id != "" {
+ m.state = stateChat
+ return true, m.loadSession(id)
+ }
+ case "d", "delete":
+ id := m.sessions.DeleteSelected()
+ if id != "" {
+ return true, m.deleteSession(id)
+ }
+ case "backspace":
+ m.sessions.DeleteFilter()
+ default:
+ if len(k) == 1 {
+ m.sessions.TypeFilter(k)
+ }
+ }
+ return true, nil
+ }
+
+ // ── Global shortcuts (always consumed) ──────────────────────────────
+ switch k {
+ case "ctrl+c":
+ if m.busy {
+ m.workspace.Cancel()
+ return true, nil
+ }
+ m.cancel()
+ return true, tea.Quit
+ case "ctrl+q":
+ m.cancel()
+ return true, tea.Quit
+ case "ctrl+p":
+ if m.state != stateCommands {
+ m.refreshSettingsHubData()
+ m.commands.Open("")
+ m.state = stateCommands
+ }
+ return true, nil
+ case "ctrl+,":
+ if m.state != stateProviderConfig {
+ m.state = stateProviderConfig
+ return true, m.loadProviderConfig()
+ }
+ return true, nil
+ case "ctrl+s":
+ if m.state == stateChat || m.state == stateWelcome {
+ m.state = stateSessions
+ return true, m.loadSessions()
+ }
+ case "ctrl+n":
+ return true, m.createSession()
+ case "ctrl+m":
+ if m.state != stateModelSelect {
+ m.returnState = m.prevChatState()
+ m.state = stateModelSelect
+ m.modelSelect.ClearFilter()
+ return true, m.listModels()
+ }
+ case "tab":
+ // Tab toggles between editor focus (typing) and main focus (scrolling).
+ if m.state == stateChat {
+ if m.focus == uiFocusEditor {
+ m.focus = uiFocusMain
+ m.input.Blur()
+ } else {
+ m.focus = uiFocusEditor
+ return true, m.input.Focus()
+ }
+ return true, nil
+ }
+ }
+
+ // ── Chat / welcome: dispatch by focus state (crush pattern) ──────────
+ if m.state == stateChat || m.state == stateWelcome {
+
+ // When focus is on the chat list, arrow keys scroll rather than move cursor.
+ if m.focus == uiFocusMain {
+ switch k {
+ case "up":
+ m.chat.ScrollUp(3)
+ return true, nil
+ case "down":
+ m.chat.ScrollDown(3)
+ return true, nil
+ case "pgup":
+ m.chat.PageUp()
+ return true, nil
+ case "pgdown":
+ m.chat.PageDown()
+ return true, nil
+ case "home":
+ m.chat.GotoTop()
+ return true, nil
+ case "end":
+ m.chat.GotoBottom()
+ return true, nil
+ case "n":
+ return true, boolCmd(m.chat.SelectNextTool())
+ case "p":
+ return true, boolCmd(m.chat.SelectPrevTool())
+ case "space":
+ return true, boolCmd(m.chat.ToggleSelectedToolExpanded())
+ case "o", "enter", "right":
+ return true, boolCmd(m.chat.ToggleDetails())
+ case "left", "esc":
+ m.chat.CloseDetails()
+ return true, nil
+ }
+ m.focus = uiFocusEditor
+ return true, m.input.Focus()
+ }
+ // ── Editor focus (default) ────────────────────────────────────────
+
+ // Slash-skill suggestions intercept keys while open.
+ if m.skillCompletions.IsOpen() {
+ switch k {
+ case "esc":
+ m.skillCompletions.Close()
+ return true, nil
+ case "up":
+ m.skillCompletions.Up()
+ return true, nil
+ case "down":
+ m.skillCompletions.Down()
+ return true, nil
+ case "enter", "tab":
+ if sel := m.skillCompletions.Selected(); sel != "" {
+ m.input.SetValue(sel + " ")
+ m.input.CursorEnd()
+ m.skillCompletions.Close()
+ *m = m.resizeInput()
+ return true, nil
+ }
+ m.skillCompletions.Close()
+ return false, nil
+ default:
+ return false, nil
+ }
+ }
+
+ // File completions popup intercepts keys while open.
+ if m.completions.IsOpen() {
+ switch k {
+ case "esc":
+ m.completions.Close()
+ case "up":
+ m.completions.Up()
+ case "down":
+ m.completions.Down()
+ case "enter", "tab":
+ if sel := m.completions.Selected(); sel != "" {
+ query := m.completions.Query()
+ val := m.input.Value()
+ atIdx := strings.LastIndex(val, "@"+query)
+ if atIdx >= 0 {
+ m.input.SetValue(val[:atIdx] + sel + val[atIdx+len("@"+query):])
+ }
+ m.completions.Close()
+ }
+ case "backspace":
+ m.completions.Backspace()
+ default:
+ if len(k) == 1 && k != "@" {
+ m.completions.TypeChar(k)
+ } else {
+ m.completions.Close()
+ return false, nil
+ }
+ }
+ return true, nil
+ }
+
+ switch k {
+ case "/":
+ // Slash is reserved for skills. Let the textarea receive it directly.
+ return false, nil
+
+ case "@":
+ // Open completions AND let textarea receive @ to show it in the input.
+ m.completions.Open(m.workspace.WorkingDir())
+ // Fall through to textarea (consumed=false) so @ appears in input.
+ return false, nil
+
+ case "enter":
+ text := strings.TrimSpace(m.input.Value())
+ if text == "" || m.busy {
+ return true, nil
+ }
+ if m.activeSession == "" {
+ return true, tea.Batch(m.createSession(), func() tea.Msg {
+ return pendingSubmitMsg{prompt: text}
+ })
+ }
+ atts := m.attachments.List()
+ _ = atts
+ m.attachments.Reset()
+ m.input.Reset()
+ *m = m.resizeInput()
+ m.chat.AddUserMessage(text)
+ m.workspace.Submit(m.ctx, text)
+ m.syncComposerAssist()
+ return true, nil
+
+ case "shift+enter", "alt+enter":
+ // crush uses InsertRune('\n') directly — more reliable than Update(msg).
+ m.input.InsertRune('\n')
+ return true, nil
+
+ case "ctrl+t":
+ // Toggle thinking block collapse on the most recent assistant message.
+ m.chat.ToggleThinking()
+ return true, nil
+
+ case "ctrl+u":
+ // Copy last user message to clipboard.
+ text := m.chat.GetLastUserText()
+ if text != "" {
+ return true, m.copyToClipboard(text, "Message copied")
+ }
+ return true, nil
+
+ case "ctrl+a":
+ return true, nil
+
+ case "pgup":
+ m.chat.PageUp()
+ return true, nil
+ case "pgdown":
+ m.chat.PageDown()
+ return true, nil
+ case "home":
+ m.chat.GotoTop()
+ return true, nil
+ case "end":
+ m.chat.GotoBottom()
+ return true, nil
+ }
+ }
+
+ // Key was not handled — forward to the textarea.
+ return false, nil
+}
+
+func (m *Model) activateSettingsSelection() tea.Cmd {
+ sel := m.commands.Selected()
+ if sel == nil {
+ return nil
+ }
+ switch sel.Kind {
+ case components.PaletteSectionKind:
+ m.commands.OpenSection(sel.ID)
+ return nil
+ case components.PaletteRouteKind:
+ switch sel.ID {
+ case "providers":
+ m.returnState = stateCommands
+ m.state = stateProviderConfig
+ return m.loadProviderConfig()
+ case "models":
+ m.returnState = stateCommands
+ m.state = stateModelSelect
+ m.modelSelect.ClearFilter()
+ return m.listModels()
+ }
+ case components.PaletteActionKind:
+ cmd := m.executeCommand(sel.ID)
+ if m.state == stateCommands {
+ m.state = m.prevChatState()
+ }
+ return cmd
+ case components.PaletteInfoKind:
+ if strings.HasPrefix(sel.Name, "/") {
+ return m.insertSkillIntoComposer(sel.Name)
+ }
+ return nil
+ }
+ return nil
+}
+
+func (m *Model) insertSkillIntoComposer(skill string) tea.Cmd {
+ m.state = m.prevChatState()
+ m.focus = uiFocusEditor
+ m.input.SetValue(skill + " ")
+ m.input.CursorEnd()
+ *m = m.resizeInput()
+ return m.input.Focus()
+}
+
+func (m *Model) executeCommand(id string) tea.Cmd {
+ switch id {
+ case "new-session":
+ return m.createSession()
+ case "sessions":
+ m.state = stateSessions
+ return m.loadSessions()
+ case "model":
+ m.returnState = stateCommands
+ m.state = stateModelSelect
+ m.modelSelect.ClearFilter()
+ return m.listModels()
+ case "thinking":
+ m.chat.ToggleThinking()
+ return nil
+ case "copy-msg":
+ text := m.chat.GetLastUserText()
+ if text != "" {
+ return m.copyToClipboard(text, "Message copied")
+ }
+ return nil
+ case "provider-config":
+ m.state = stateProviderConfig
+ return m.loadProviderConfig()
+ case "quit":
+ m.cancel()
+ return tea.Quit
+ default:
+ return nil
+ }
+}
+
+// pendingSubmitMsg is used to queue a prompt while session creation is pending.
+type pendingSubmitMsg struct{ prompt string }
+
+// clearCopyNoticeMsg clears the transient "Copied!" footer message.
+type clearCopyNoticeMsg struct{}
+
+// cfgSaveResultMsg is sent after attempting to save a provider credential.
+type cfgSaveResultMsg struct{ err error }
+
+// providerConfigLoadedMsg carries a refreshed provider list.
+type providerConfigLoadedMsg struct{ providers []tui.ProviderStatus }
+
+func editorPrompt(styles common.Styles) func(textarea.PromptInfo) string {
+ return func(info textarea.PromptInfo) string {
+ if info.LineNumber == 0 {
+ if info.Focused {
+ return styles.InputPrompt.Render("> ")
+ }
+ return styles.InputHint.Render("> ")
+ }
+ return " "
+ }
+}
+
+// ─── Views ────────────────────────────────────────────────────────────────────
+
+func (m Model) viewWelcome() string {
+ // Braille logo rendered in orange primary colour.
+ logoArt := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render(common.NexusLogo)
+
+ wordmark := m.styles.Logo.Render("NEXUS")
+ tagline := m.styles.HeaderModel.Render("One runtime. Any LLM. Any language.")
+
+ hint := strings.Join([]string{
+ m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new session"),
+ m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"),
+ m.styles.Key.Render("ctrl+q") + " " + m.styles.Desc.Render("quit"),
+ }, " ")
+
+ contentW := m.contentWidth()
+ body := lipgloss.NewStyle().
+ Width(contentW).
+ Height(m.height-2).
+ Align(lipgloss.Center, lipgloss.Center).
+ Render(logoArt + "\n" + wordmark + "\n\n" + tagline + "\n\n" + hint)
+
+ return m.header() + "\n" + common.CenterHorizontally(body, m.width)
+}
+
+func (m Model) viewChat() string {
+ inputView := m.inputView()
+ statusView := m.statusLine()
+ contentW := m.contentWidth()
+ chatH := m.height - headerHeight - footerHeight - lipgloss.Height(statusView) - lipgloss.Height(inputView)
+ chatW := contentW
+ var detailView string
+ if m.chat.DetailsOpen() && contentW >= 110 {
+ paneW := max(36, contentW/3)
+ chatW = max(40, contentW-paneW-1)
+ m.chat.SetSize(chatW, max(1, chatH))
+ detailView = m.chat.DetailView(contentW-chatW-1, max(1, chatH))
+ } else {
+ m.chat.SetSize(chatW, max(1, chatH))
+ }
+ chatView := m.chat.View()
+ body := chatView
+ if detailView != "" {
+ body = lipgloss.JoinHorizontal(lipgloss.Top, chatView, " ", detailView)
+ }
+ body = common.CenterHorizontally(lipgloss.NewStyle().Width(contentW).Render(body), m.width)
+
+ base := strings.Join([]string{
+ m.header(),
+ body,
+ statusView,
+ inputView,
+ m.footer(),
+ }, "\n")
+
+ if m.state == statePermission && m.permission.HasPending() {
+ overlay := m.permission.View()
+ return common.OverlayOn(base, overlay, m.width, m.height)
+ }
+ return base
+}
+func (m Model) viewSessions() string {
+ m.sessions.SetSize(m.width, m.height)
+ overlay := m.sessions.Centered()
+ var backdrop string
+ if m.activeSession != "" {
+ backdrop = m.viewChat()
+ } else {
+ backdrop = m.viewWelcome()
+ }
+ return common.OverlayOn(backdrop, overlay, m.width, m.height)
+}
+
+func (m Model) viewModelSelect() string {
+ m.modelSelect.SetSize(m.width, m.height)
+ overlay := m.modelSelect.Centered()
+ var backdrop string
+ if m.activeSession != "" {
+ backdrop = m.viewChat()
+ } else {
+ backdrop = m.viewWelcome()
+ }
+ return common.OverlayOn(backdrop, overlay, m.width, m.height)
+}
+
+func (m Model) viewCommands() string {
+ m.commands.SetSize(m.width, m.height)
+ overlay := m.commands.Centered()
+ var backdrop string
+ if m.activeSession != "" {
+ backdrop = m.viewChat()
+ } else {
+ backdrop = m.viewWelcome()
+ }
+ return common.OverlayOn(backdrop, overlay, m.width, m.height)
+}
+
+func (m Model) viewProviderConfig() string {
+ m.configPanel.SetSize(m.width, m.height)
+ overlay := m.configPanel.Centered()
+ var backdrop string
+ if m.activeSession != "" {
+ backdrop = m.viewChat()
+ } else {
+ backdrop = m.viewWelcome()
+ }
+ return common.OverlayOn(backdrop, overlay, m.width, m.height)
+}
+
+func (m Model) header() string {
+ contentW := m.contentWidth()
+ logo := m.styles.Logo.Render("NEXUS")
+ model := m.styles.HeaderPill.Render(m.workspace.ModelString())
+ left := lipgloss.JoinHorizontal(lipgloss.Center, logo, " ", model)
+
+ var right string
+ if m.focus == uiFocusMain && m.state == stateChat {
+ right = lipgloss.JoinHorizontal(
+ lipgloss.Center,
+ m.styles.HeaderPillActive.Render("tools"),
+ " ",
+ m.styles.HeaderID.Render("n/p navigate · space expand · o details"),
+ )
+ } else if m.activeSession != "" {
+ right = lipgloss.JoinHorizontal(
+ lipgloss.Center,
+ m.styles.HeaderPillReady.Render("● live"),
+ " ",
+ m.styles.HeaderPill.Render(common.ShortID(m.activeSession)),
+ )
+ } else {
+ right = m.styles.HeaderPillReady.Render("ready")
+ }
+
+ gap := contentW - lipgloss.Width(left) - lipgloss.Width(right) - m.styles.HeaderBar.GetHorizontalFrameSize()
+ if gap < 1 {
+ gap = 1
+ }
+ content := m.styles.HeaderBar.Width(contentW).Render(left + strings.Repeat(" ", gap) + right)
+ return common.CenterHorizontally(content, m.width)
+}
+
+func (m Model) statusLine() string {
+ contentW := m.contentWidth()
+ var line string
+ switch {
+ case m.busy:
+ line = m.styles.Footer.Width(contentW).Render(m.styles.HeaderPillBusy.Render(m.spinner.View() + " working"))
+ case m.lastTurnErr != "":
+ line = m.styles.Footer.Width(contentW).Render(m.styles.ToolError.Render("failed") + " " + m.styles.Desc.Render(truncateStatus(m.lastTurnErr, max(12, contentW/2))))
+ default:
+ line = m.styles.Footer.Width(contentW).Render(m.styles.Desc.Render("ready"))
+ }
+ return common.CenterHorizontally(line, m.width)
+}
+
+func (m Model) tokenSummary() string {
+ total := m.sessionInputTokens + m.sessionOutputTokens
+ if total <= 0 {
+ return ""
+ }
+ parts := []string{formatTokenCount(total) + " total"}
+ if m.sessionInputTokens > 0 {
+ parts = append(parts, "in "+formatTokenCount(m.sessionInputTokens))
+ }
+ if m.sessionOutputTokens > 0 {
+ parts = append(parts, "out "+formatTokenCount(m.sessionOutputTokens))
+ }
+ return m.styles.Desc.Render(strings.Join(parts, " · "))
+}
+
+func truncateStatus(s string, maxLen int) string {
+ r := []rune(strings.TrimSpace(s))
+ if len(r) <= maxLen {
+ return string(r)
+ }
+ if maxLen <= 1 {
+ return string(r[:1])
+ }
+ return string(r[:maxLen-1]) + "…"
+}
+
+func formatTokenCount(n int) string {
+ switch {
+ case n >= 1_000_000:
+ return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M"
+ case n >= 1_000:
+ return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k"
+ default:
+ return strconv.Itoa(n)
+ }
+}
+
+func (m Model) contentWidth() int {
+ if m.width <= 4 {
+ return m.width
+ }
+ return m.width - 4
+}
+
+func (m Model) footer() string {
+ contentW := m.contentWidth()
+ if m.copyNotice != "" {
+ return common.CenterHorizontally(m.styles.ToolDone.Width(contentW).Render("✓ "+m.copyNotice), m.width)
+ }
+
+ var leftItems []string
+ if m.focus == uiFocusMain && m.state == stateChat {
+ leftItems = []string{
+ m.styles.Key.Render("↑↓") + " " + m.styles.Desc.Render("scroll"),
+ m.styles.Key.Render("n/p") + " " + m.styles.Desc.Render("tools"),
+ m.styles.Key.Render("space") + " " + m.styles.Desc.Render("preview"),
+ m.styles.Key.Render("o") + " " + m.styles.Desc.Render("details"),
+ m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"),
+ }
+ } else {
+ leftItems = []string{
+ m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("settings"),
+ m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new"),
+ m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"),
+ m.styles.Key.Render("ctrl+c") + " " + m.styles.Desc.Render("cancel/quit"),
+ }
+ }
+ left := strings.Join(leftItems, " ")
+ right := m.tokenSummary()
+ var line string
+ if right == "" {
+ line = m.styles.Footer.Width(contentW).Render(left)
+ } else {
+ gap := contentW - lipgloss.Width(left) - lipgloss.Width(right)
+ if gap < 2 {
+ gap = 2
+ }
+ line = m.styles.Footer.Width(contentW).Render(left + strings.Repeat(" ", gap) + right)
+ }
+ return common.CenterHorizontally(line, m.width)
+}
+
+// Select mode banner takes priority.
+func (m Model) inputView() string {
+ contentW := m.contentWidth()
+ inner := m.input.View()
+
+ if attView := m.attachments.View(max(20, contentW-4)); attView != "" {
+ inner = attView + "\n" + inner
+ }
+
+ box := m.styles.InputBorder.Width(max(12, contentW-2)).Render(inner)
+ stackW := lipgloss.Width(box)
+ if m.skillCompletions.IsOpen() {
+ popup := m.skillCompletions.View(max(24, contentW-4))
+ stack := lipgloss.NewStyle().Width(stackW).Render(popup) + "\n" + box
+ return common.CenterHorizontally(stack, m.width)
+ }
+ if m.completions.IsOpen() {
+ popup := m.completions.View(max(20, contentW-4))
+ stack := lipgloss.NewStyle().Width(stackW).Render(popup) + "\n" + box
+ return common.CenterHorizontally(stack, m.width)
+ }
+ return common.CenterHorizontally(box, m.width)
+}
+
+// ─── Layout ───────────────────────────────────────────────────────────────────
+
+func (m Model) relayout() Model {
+ contentW := m.contentWidth()
+ inputW := contentW - 4
+ if inputW < 10 {
+ inputW = 10
+ }
+ m.input.SetWidth(inputW)
+ m.sessions.SetSize(m.width, m.height)
+ m.permission.SetSize(m.width, m.height)
+ m.modelSelect.SetSize(m.width, m.height)
+ m.commands.SetSize(m.width, m.height)
+ m.configPanel.SetSize(m.width, m.height)
+ m.chat.SetSize(contentW, max(1, m.height-headerHeight-footerHeight-statusHeight-inputMinH-inputPadding))
+ return m
+}
+
+func (m Model) resizeInput() Model {
+ lines := strings.Count(m.input.Value(), "\n") + 1
+ h := common.Clamp(lines, inputMinH, inputMaxH)
+ m.input.SetHeight(h)
+ return m
+}
+
+func (m Model) prevChatState() uiState {
+ if m.activeSession != "" {
+ return stateChat
+ }
+ return stateWelcome
+}
+
+// ─── Clipboard ───────────────────────────────────────────────────────────────
+
+// copyToClipboard copies text using OSC 52 (tea.SetClipboard) and the native
+// clipboard (atotto/clipboard), then shows a transient notice in the footer.
+// This mirrors crush's CopyToClipboard approach, but avoids claiming success
+// when the local session has no actual clipboard backend.
+func (m *Model) copyToClipboard(text, notice string) tea.Cmd {
+ m.copyNotice = copyNoticeForCapability(notice)
+ return tea.Sequence(
+ tea.SetClipboard(text),
+ func() tea.Msg {
+ _ = clipboard.WriteAll(text)
+ return nil
+ },
+ tea.Tick(2*time.Second, func(time.Time) tea.Msg {
+ return clearCopyNoticeMsg{}
+ }),
+ )
+}
+
+func copyNoticeForCapability(success string) string {
+ return clipboardNotice(success, nativeClipboardLikelyAvailable(), terminalClipboardLikelyAvailable())
+}
+
+func clipboardNotice(success string, nativeAvailable, terminalAvailable bool) string {
+ switch {
+ case nativeAvailable:
+ return success
+ case terminalAvailable:
+ return success + " (terminal clipboard requested)"
+ default:
+ return "Clipboard unavailable: install wl-clipboard or xclip"
+ }
+}
+
+func nativeClipboardLikelyAvailable() bool {
+ switch runtime.GOOS {
+ case "windows", "darwin":
+ return true
+ }
+ for _, name := range []string{"wl-copy", "xclip", "xsel", "pbcopy", "clip.exe", "powershell.exe"} {
+ if _, err := exec.LookPath(name); err == nil {
+ return true
+ }
+ }
+ return false
+}
+
+func terminalClipboardLikelyAvailable() bool {
+ term := strings.TrimSpace(os.Getenv("TERM"))
+ return term != "" && term != "dumb"
+}
+
+// ─── Workspace commands ───────────────────────────────────────────────────────
+
+func boolCmd(ok bool) tea.Cmd {
+ if ok {
+ return func() tea.Msg { return nil }
+ }
+ return nil
+}
+
+func (m *Model) syncComposerAssist() {
+ if m.completions.IsOpen() {
+ m.skillCompletions.Close()
+ return
+ }
+ skills := m.loadSkillCatalog()
+ m.skillCompletions.Sync(skills, m.input.Value())
+}
+
+func (m *Model) loadSkillCatalog() []tui.SkillInfo {
+ if !m.skillCatalogLoaded {
+ m.skillCatalog = m.workspace.LoadSkills(m.ctx)
+ m.skillCatalogLoaded = true
+ }
+ return m.skillCatalog
+}
+
+func (m Model) loadSessions() tea.Cmd {
+ return func() tea.Msg { m.workspace.ListSessions(m.ctx); return nil }
+}
+
+func (m Model) listModels() tea.Cmd {
+ return func() tea.Msg { m.workspace.ListModels(m.ctx); return nil }
+}
+
+func (m Model) createSession() tea.Cmd {
+ return func() tea.Msg { m.workspace.CreateSession(m.ctx); return nil }
+}
+
+func (m Model) loadSession(id string) tea.Cmd {
+ return func() tea.Msg { m.workspace.LoadSession(m.ctx, id); return nil }
+}
+
+func (m Model) loadProviderConfig() tea.Cmd {
+ return func() tea.Msg {
+ providers := m.workspace.LoadProviderConfig(m.ctx)
+ return providerConfigLoadedMsg{providers: providers}
+ }
+}
+
+func (m *Model) refreshSettingsHubData() {
+ m.commands.SetSectionItems("tools", buildToolSettingsItems(m.workspace.LoadToolCatalog(m.ctx)))
+ m.commands.SetSectionItems("mcp", buildMCPSettingsItems(m.workspace.LoadMCPServers(m.ctx)))
+ m.skillCatalog = m.workspace.LoadSkills(m.ctx)
+ m.skillCatalogLoaded = true
+ m.commands.SetSectionItems("skills", buildSkillSettingsItems(m.skillCatalog))
+}
+
+func buildToolSettingsItems(items []tui.ToolInfo) []components.PaletteItem {
+ if len(items) == 0 {
+ return []components.PaletteItem{{
+ Kind: components.PaletteInfoKind,
+ ID: "tools-empty",
+ Name: "No tools found",
+ Desc: "The current runtime did not expose any tools",
+ }}
+ }
+ result := make([]components.PaletteItem, 0, len(items))
+ for _, item := range items {
+ desc := strings.TrimSpace(item.Description)
+ if category := strings.TrimSpace(item.Category); category != "" {
+ if desc != "" {
+ desc = category + " · " + desc
+ } else {
+ desc = category
+ }
+ }
+ result = append(result, components.PaletteItem{
+ Kind: components.PaletteInfoKind,
+ ID: "tool-" + item.Name,
+ Name: item.Name,
+ Desc: desc,
+ })
+ }
+ return result
+}
+
+func buildMCPSettingsItems(items []tui.MCPServerInfo) []components.PaletteItem {
+ if len(items) == 0 {
+ return []components.PaletteItem{{
+ Kind: components.PaletteInfoKind,
+ ID: "mcp-empty",
+ Name: "No MCP servers configured",
+ Desc: "Add MCP servers in config to expose them here",
+ }}
+ }
+ result := make([]components.PaletteItem, 0, len(items))
+ for _, item := range items {
+ desc := item.Status + " · " + strconv.Itoa(item.ToolsRegistered) + " tools"
+ if item.Error != "" {
+ desc += " · " + item.Error
+ }
+ result = append(result, components.PaletteItem{
+ Kind: components.PaletteInfoKind,
+ ID: "mcp-" + item.Name,
+ Name: item.Name,
+ Desc: desc,
+ })
+ }
+ return result
+}
+
+func buildSkillSettingsItems(items []tui.SkillInfo) []components.PaletteItem {
+ if len(items) == 0 {
+ return []components.PaletteItem{{
+ Kind: components.PaletteInfoKind,
+ ID: "skills-empty",
+ Name: "No skills found",
+ Desc: "Add bundled, repo, or user skills to invoke them with /skill",
+ }}
+ }
+ result := make([]components.PaletteItem, 0, len(items))
+ for _, item := range items {
+ desc := strings.TrimSpace(item.Description)
+ if desc == "" {
+ desc = strings.TrimSpace(item.WhenToUse)
+ }
+ if source := strings.TrimSpace(item.Source); source != "" {
+ if desc != "" {
+ desc = source + " · " + desc
+ } else {
+ desc = source
+ }
+ }
+ result = append(result, components.PaletteItem{
+ Kind: components.PaletteInfoKind,
+ ID: "skill-" + item.Name,
+ Name: "/" + item.Name,
+ Desc: desc,
+ })
+ }
+ return result
+}
+
+func (m Model) deleteSession(id string) tea.Cmd {
+ return func() tea.Msg {
+ _ = m.workspace.DeleteSession(m.ctx, id)
+ m.workspace.ListSessions(m.ctx)
+ return nil
+ }
+}
+
+// ─── Utilities ────────────────────────────────────────────────────────────────
+
+func clamp(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+// Run starts the BubbleTea program and blocks until it exits.
+func Run(ws tui.Workspace, ctx context.Context) error {
+ m := New(ws, ctx)
+ p := tea.NewProgram(m, tea.WithContext(ctx))
+ ws.Subscribe(p)
+ _, err := p.Run()
+ return err
+}
diff --git a/internal/tui/app/model_test.go b/internal/tui/app/model_test.go
new file mode 100644
index 0000000..47afe9c
--- /dev/null
+++ b/internal/tui/app/model_test.go
@@ -0,0 +1,396 @@
+package app
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ tea "charm.land/bubbletea/v2"
+ "github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+type mockWorkspace struct{}
+
+func (mockWorkspace) ListSessions(context.Context) {}
+func (mockWorkspace) CreateSession(context.Context) {}
+func (mockWorkspace) LoadSession(context.Context, string) {}
+func (mockWorkspace) DeleteSession(context.Context, string) error { return nil }
+func (mockWorkspace) Submit(context.Context, string) {}
+func (mockWorkspace) Cancel() {}
+func (mockWorkspace) ActiveSessionID() string { return "" }
+func (mockWorkspace) IsBusy() bool { return false }
+func (mockWorkspace) ModelString() string { return "test/model" }
+func (mockWorkspace) WorkingDir() string { return "/tmp" }
+func (mockWorkspace) PermissionMode() string { return "default" }
+func (mockWorkspace) ListModels(context.Context) {}
+func (mockWorkspace) SetModel(string, string) {}
+func (mockWorkspace) Subscribe(*tea.Program) {}
+func (mockWorkspace) Close() {}
+func (mockWorkspace) LoadProviderConfig(context.Context) []tui.ProviderStatus {
+ return nil
+}
+func (mockWorkspace) LoadToolCatalog(context.Context) []tui.ToolInfo {
+ return []tui.ToolInfo{{Name: "bash", Description: "Run shell commands", Category: "system"}}
+}
+func (mockWorkspace) LoadMCPServers(context.Context) []tui.MCPServerInfo {
+ return []tui.MCPServerInfo{{Name: "github", ToolsRegistered: 3, Status: "ready"}}
+}
+func (mockWorkspace) LoadSkills(context.Context) []tui.SkillInfo {
+ return []tui.SkillInfo{{Name: "summarise-pr", Description: "Summarise a pull request", Source: "bundled"}}
+}
+func (mockWorkspace) SaveProviderField(context.Context, string, string, string) error {
+ return nil
+}
+func (mockWorkspace) DeleteProviderField(context.Context, string, string) error {
+ return nil
+}
+
+func TestModelRelayoutPropagatesChildSizes(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 80
+ m.height = 24
+
+ m = m.relayout()
+
+ cw, ch := m.chat.Size()
+ if cw != 76 {
+ t.Fatalf("expected chat width 76, got %d", cw)
+ }
+ if ch != 19 {
+ t.Fatalf("expected chat height 19, got %d", ch)
+ }
+ sw, sh := m.sessions.Size()
+ if sw != 80 {
+ t.Fatalf("expected session width 80, got %d", sw)
+ }
+ if sh != 24 {
+ t.Fatalf("expected session height 24, got %d", sh)
+ }
+}
+
+func TestModelPrevChatStateDependsOnActiveSession(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+
+ if got := m.prevChatState(); got != stateWelcome {
+ t.Fatalf("expected welcome state without active session, got %v", got)
+ }
+
+ m.activeSession = "sess-1"
+ if got := m.prevChatState(); got != stateChat {
+ t.Fatalf("expected chat state with active session, got %v", got)
+ }
+}
+
+func TestModelInputViewUsesPromptStyleAndHint(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 100
+ m.height = 30
+ m = m.relayout()
+ view := m.inputView()
+ if strings.Contains(view, "enter send") || strings.Contains(view, "chat") {
+ t.Fatalf("expected input view to avoid inline helper chrome, got %q", view)
+ }
+ if !strings.Contains(view, "╭") || !strings.Contains(view, "╯") {
+ t.Fatalf("expected input view to render a compact bordered composer, got %q", view)
+ }
+ if !strings.Contains(view, ">") {
+ t.Fatalf("expected input view to include prompt marker, got %q", view)
+ }
+}
+
+func TestModelResizeInputUsesTextareaLineCount(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.input.SetValue(strings.Repeat("line\n", 20))
+ m = m.resizeInput()
+ if got := m.input.Height(); got != inputMaxH {
+ t.Fatalf("expected input height %d, got %d", inputMaxH, got)
+ }
+}
+
+func TestModelHeaderRendersModelAndStatusPills(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 100
+ m.activeSession = "session-1234567890abcdef"
+ header := m.header()
+ if !strings.Contains(header, "NEXUS") {
+ t.Fatalf("expected header to include wordmark, got %q", header)
+ }
+ if !strings.Contains(header, "test/model") {
+ t.Fatalf("expected header to include model pill, got %q", header)
+ }
+ if !strings.Contains(header, "live") {
+ t.Fatalf("expected header to include session status pill, got %q", header)
+ }
+ if !strings.Contains(header, common.ShortID(m.activeSession)) {
+ t.Fatalf("expected header to include short session id, got %q", header)
+ }
+}
+
+func TestModelStatusLineShowsBusyAndUsage(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 100
+ m.busy = true
+ busy := m.statusLine()
+ if !strings.Contains(busy, "working") {
+ t.Fatalf("expected busy status line to mention working, got %q", busy)
+ }
+
+ m.busy = false
+ m.busy = false
+ m.lastTurnErr = "boom"
+ errLine := m.statusLine()
+ if !strings.Contains(errLine, "failed") {
+ t.Fatalf("expected error status line to mention failed, got %q", errLine)
+ }
+}
+
+func TestModelFooterSimplifiesPrimaryActions(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 100
+ m.sessionInputTokens = 10
+ m.sessionOutputTokens = 5
+ footer := m.footer()
+ if strings.Contains(footer, "ctrl+e") || strings.Contains(footer, "chat/tools") {
+ t.Fatalf("expected footer to remove old select/tools hints, got %q", footer)
+ }
+ if !strings.Contains(footer, "ctrl+p") || !strings.Contains(footer, "settings") || !strings.Contains(footer, "15 total") {
+ t.Fatalf("expected footer to include settings and total token usage, got %q", footer)
+ }
+}
+
+func TestModelViewChatIncludesStatusLine(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 120
+ m.height = 30
+ m.activeSession = "session-123"
+ m.state = stateChat
+ m.busy = true
+ m = m.relayout()
+ m.chat.AddUserMessage("hello")
+ view := m.viewChat()
+ if !strings.Contains(view, "working") {
+ t.Fatalf("expected chat view to include busy status line, got %q", view)
+ }
+}
+
+func TestModelSlashKeyFallsThroughToInput(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateChat
+ consumed, cmd := m.handleKey(tea.KeyPressMsg{Text: "/"})
+ if consumed {
+ t.Fatalf("expected slash to fall through to the textarea for skill input")
+ }
+ if cmd != nil {
+ t.Fatalf("expected slash key handling not to emit a command")
+ }
+ if got := m.state; got != stateChat {
+ t.Fatalf("expected slash to keep chat state, got %v", got)
+ }
+}
+
+func TestModelCtrlShiftCCopiesActiveSelection(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 120
+ m.height = 30
+ m.state = stateChat
+ m.activeSession = "session-123"
+ m = m.relayout()
+ m.chat.AddUserMessage("hello world")
+ m.chat.HandleMouseDown(0, 0)
+ m.chat.HandleMouseDrag(8, 0)
+ m.chat.HandleMouseUp(8, 0)
+
+ consumed, cmd := m.handleKey(tea.KeyPressMsg(tea.Key{Text: "c", Code: 'c', ShiftedCode: 'C', Mod: tea.ModCtrl | tea.ModShift}))
+ if !consumed {
+ t.Fatalf("expected ctrl+shift+c to be handled")
+ }
+ if cmd == nil {
+ t.Fatalf("expected ctrl+shift+c to return a clipboard command")
+ }
+ if m.copyNotice == "" {
+ t.Fatalf("expected a copy notice")
+ }
+}
+
+func TestModelRightClickCopiesActiveSelection(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.width = 120
+ m.height = 30
+ m.state = stateChat
+ m.activeSession = "session-123"
+ m = m.relayout()
+ m.chat.AddUserMessage("hello world")
+ m.chat.HandleMouseDown(0, 0)
+ m.chat.HandleMouseDrag(8, 0)
+ m.chat.HandleMouseUp(8, 0)
+
+ layout := m.currentChatLayout()
+ cmd := m.handleMouseClick(tea.MouseClickMsg(tea.Mouse{X: layout.chatX + 2, Y: layout.chatY, Button: tea.MouseRight}))
+ if cmd == nil {
+ t.Fatalf("expected right click to return a clipboard command")
+ }
+ if m.copyNotice == "" {
+ t.Fatalf("expected a copy notice")
+ }
+}
+
+func TestClipboardNoticeReflectsCapabilities(t *testing.T) {
+ if got := clipboardNotice("Selection copied", true, false); got != "Selection copied" {
+ t.Fatalf("expected native clipboard success notice, got %q", got)
+ }
+ if got := clipboardNotice("Selection copied", false, true); got != "Selection copied (terminal clipboard requested)" {
+ t.Fatalf("expected terminal clipboard fallback notice, got %q", got)
+ }
+ if got := clipboardNotice("Selection copied", false, false); got != "Clipboard unavailable: install wl-clipboard or xclip" {
+ t.Fatalf("expected unavailable notice, got %q", got)
+ }
+}
+
+func TestModelCtrlPOpensSettingsRoot(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateChat
+ consumed, cmd := m.handleKey(tea.KeyPressMsg(tea.Key{Code: 'p', Mod: tea.ModCtrl}))
+ if !consumed {
+ t.Fatalf("expected ctrl+p to be handled")
+ }
+ if cmd != nil {
+ t.Fatalf("expected ctrl+p not to emit an async command")
+ }
+ if got := m.state; got != stateCommands {
+ t.Fatalf("expected settings hub state, got %v", got)
+ }
+ if sel := m.commands.Selected(); sel == nil || sel.Name != "Commands" {
+ t.Fatalf("expected settings root to start on Commands, got %+v", sel)
+ }
+}
+
+func TestModelSettingsEnterCommandsStaysInHub(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateCommands
+ m.commands.Open("")
+ cmd := m.activateSettingsSelection()
+ if cmd != nil {
+ t.Fatalf("expected opening commands section not to emit a command")
+ }
+ if sel := m.commands.Selected(); sel == nil || sel.Name != "New Session" {
+ t.Fatalf("expected commands section to open on first command, got %+v", sel)
+ }
+}
+
+func TestModelSettingsProvidersRouteToConfig(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateCommands
+ m.commands.Open("")
+ m.commands.Down()
+ cmd := m.activateSettingsSelection()
+ if cmd == nil {
+ t.Fatalf("expected providers route to emit a load command")
+ }
+ if got := m.state; got != stateProviderConfig {
+ t.Fatalf("expected provider config state, got %v", got)
+ }
+ if got := m.returnState; got != stateCommands {
+ t.Fatalf("expected return state to point back to settings, got %v", got)
+ }
+}
+
+func TestModelCtrlPLoadsLiveSettingsSections(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateChat
+ consumed, cmd := m.handleKey(tea.KeyPressMsg(tea.Key{Code: 'p', Mod: tea.ModCtrl}))
+ if !consumed {
+ t.Fatalf("expected ctrl+p to be handled")
+ }
+ if cmd != nil {
+ t.Fatalf("expected ctrl+p not to emit an async command")
+ }
+ if !m.commands.OpenSection("tools") {
+ t.Fatalf("expected tools section to open")
+ }
+ if sel := m.commands.Selected(); sel == nil || sel.Name != "bash" {
+ t.Fatalf("expected live tools section to include bash, got %+v", sel)
+ }
+ if !m.commands.Back() {
+ t.Fatalf("expected to return to settings root from tools")
+ }
+ m.commands.Down()
+ m.commands.Down()
+ m.commands.Down()
+ m.commands.OpenSection("mcp")
+ if sel := m.commands.Selected(); sel == nil || sel.Name != "github" {
+ t.Fatalf("expected live mcp section to include github, got %+v", sel)
+ }
+ if !m.commands.Back() {
+ t.Fatalf("expected to return to settings root from mcp")
+ }
+ m.commands.Down()
+ m.commands.Down()
+ m.commands.OpenSection("skills")
+ if sel := m.commands.Selected(); sel == nil || sel.Name != "/summarise-pr" {
+ t.Fatalf("expected live skills section to include /summarise-pr, got %+v", sel)
+ }
+}
+
+func TestModelSettingsSkillSelectionPrimesComposer(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateCommands
+ m.activeSession = "session-123"
+ m.refreshSettingsHubData()
+ if !m.commands.OpenSection("skills") {
+ t.Fatalf("expected skills section to open")
+ }
+ cmd := m.activateSettingsSelection()
+ if cmd == nil {
+ t.Fatalf("expected skill selection to focus the composer")
+ }
+ if got := m.state; got != stateChat {
+ t.Fatalf("expected state to return to chat, got %v", got)
+ }
+ if got := m.input.Value(); got != "/summarise-pr " {
+ t.Fatalf("expected skill to be inserted into composer, got %q", got)
+ }
+ if got := m.focus; got != uiFocusEditor {
+ t.Fatalf("expected editor focus after inserting skill, got %v", got)
+ }
+}
+
+func TestModelSlashSkillPopupOpensWhileTyping(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateChat
+ m.width = 100
+ m.height = 30
+ m = m.relayout()
+ m.input.SetValue("/sum")
+ m.syncComposerAssist()
+ if !m.skillCompletions.IsOpen() {
+ t.Fatalf("expected slash skill popup to open for /sum")
+ }
+ view := m.inputView()
+ if !strings.Contains(view, "/summarise-pr") {
+ t.Fatalf("expected skill popup to render matching skill, got %q", view)
+ }
+}
+
+func TestModelSlashSkillPopupSelectionPrimesComposer(t *testing.T) {
+ m := New(mockWorkspace{}, context.Background())
+ m.state = stateChat
+ m.width = 100
+ m.height = 30
+ m = m.relayout()
+ m.input.SetValue("/sum")
+ m.syncComposerAssist()
+ consumed, cmd := m.handleKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter, Text: "enter"}))
+ if !consumed {
+ t.Fatalf("expected enter to be handled by slash skill popup")
+ }
+ if cmd != nil {
+ t.Fatalf("expected skill popup selection not to emit a command")
+ }
+ if got := m.input.Value(); got != "/summarise-pr " {
+ t.Fatalf("expected selected skill to replace typed query, got %q", got)
+ }
+ if m.skillCompletions.IsOpen() {
+ t.Fatalf("expected slash skill popup to close after selection")
+ }
+}
diff --git a/internal/tui/ansiext/ansi.go b/internal/tui/common/ansi.go
similarity index 97%
rename from internal/tui/ansiext/ansi.go
rename to internal/tui/common/ansi.go
index 4ec76a7..d7da1c7 100644
--- a/internal/tui/ansiext/ansi.go
+++ b/internal/tui/common/ansi.go
@@ -1,4 +1,4 @@
-package ansiext
+package common
import (
"strings"
diff --git a/internal/tui/common/helpers.go b/internal/tui/common/helpers.go
new file mode 100644
index 0000000..8d79d84
--- /dev/null
+++ b/internal/tui/common/helpers.go
@@ -0,0 +1,22 @@
+package common
+
+func Clamp(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func ClampInt(v, lo, hi int) int {
+ return Clamp(v, lo, hi)
+}
+
+func ShortID(id string) string {
+ if len(id) > 8 {
+ return id[:8]
+ }
+ return id
+}
diff --git a/internal/tui/model/keys.go b/internal/tui/common/keys.go
similarity index 99%
rename from internal/tui/model/keys.go
rename to internal/tui/common/keys.go
index 2317da2..81ba455 100644
--- a/internal/tui/model/keys.go
+++ b/internal/tui/common/keys.go
@@ -1,4 +1,4 @@
-package model
+package common
import "charm.land/bubbles/v2/key"
diff --git a/internal/tui/common/layout.go b/internal/tui/common/layout.go
new file mode 100644
index 0000000..bfe579e
--- /dev/null
+++ b/internal/tui/common/layout.go
@@ -0,0 +1,64 @@
+package common
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+// CenterHorizontally pads a rendered box so it is centered within the viewport.
+func CenterHorizontally(box string, width int) string {
+ if box == "" {
+ return ""
+ }
+
+ lines := strings.Split(box, "\n")
+ boxW := lipgloss.Width(lines[0])
+ leftPad := max(0, (width-boxW)/2)
+ pad := strings.Repeat(" ", leftPad)
+
+ var sb strings.Builder
+ for i, line := range lines {
+ if i > 0 {
+ sb.WriteString("\n")
+ }
+ sb.WriteString(pad)
+ sb.WriteString(line)
+ }
+ return sb.String()
+}
+
+// OverlayOn dims the backdrop and places the overlay vertically centered on it.
+// Empty overlay lines are treated as transparent rows.
+func OverlayOn(base, overlay string, width, height int) string {
+ if overlay == "" {
+ return base
+ }
+
+ baseLines := strings.Split(base, "\n")
+ overlayLines := strings.Split(overlay, "\n")
+ overlayH := len(overlayLines)
+
+ for len(baseLines) < height {
+ baseLines = append(baseLines, strings.Repeat(" ", width))
+ }
+
+ topOffset := max(0, (height-overlayH)/2)
+ dim := lipgloss.NewStyle().Faint(true)
+
+ for i, line := range baseLines {
+ overlayRow := i - topOffset
+ if overlayRow >= 0 && overlayRow < overlayH {
+ overlayLine := overlayLines[overlayRow]
+ if overlayLine == "" {
+ baseLines[i] = dim.Render(line)
+ continue
+ }
+ baseLines[i] = overlayLine
+ continue
+ }
+ baseLines[i] = dim.Render(line)
+ }
+
+ return strings.Join(baseLines, "\n")
+}
diff --git a/internal/tui/common/layout_test.go b/internal/tui/common/layout_test.go
new file mode 100644
index 0000000..f3e14fa
--- /dev/null
+++ b/internal/tui/common/layout_test.go
@@ -0,0 +1,61 @@
+package common
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCenterHorizontallyPadsRenderedBox(t *testing.T) {
+ got := CenterHorizontally("abc\ndef", 9)
+ lines := strings.Split(got, "\n")
+
+ if len(lines) != 2 {
+ t.Fatalf("expected 2 lines, got %d", len(lines))
+ }
+ if lines[0] != " abc" || lines[1] != " def" {
+ t.Fatalf("expected centered lines with left padding, got %q / %q", lines[0], lines[1])
+ }
+}
+
+func TestOverlayOnCentersOverlay(t *testing.T) {
+ base := strings.Join([]string{
+ "base-0",
+ "base-1",
+ "base-2",
+ "base-3",
+ "base-4",
+ }, "\n")
+ overlay := "OVR-1\nOVR-2"
+
+ got := OverlayOn(base, overlay, 10, 5)
+ lines := strings.Split(got, "\n")
+
+ if len(lines) != 5 {
+ t.Fatalf("expected 5 lines, got %d", len(lines))
+ }
+ if lines[1] != "OVR-1" || lines[2] != "OVR-2" {
+ t.Fatalf("expected overlay to be vertically centered, got %q / %q", lines[1], lines[2])
+ }
+}
+
+func TestOverlayOnKeepsTransparentRowsFromOverlay(t *testing.T) {
+ base := strings.Join([]string{
+ "line-0",
+ "line-1",
+ "line-2",
+ }, "\n")
+ overlay := "TOP\n\nBOT"
+
+ got := OverlayOn(base, overlay, 8, 3)
+ lines := strings.Split(got, "\n")
+
+ if lines[0] != "TOP" {
+ t.Fatalf("expected first overlay row, got %q", lines[0])
+ }
+ if !strings.Contains(lines[1], "line-1") {
+ t.Fatalf("expected transparent overlay row to keep base content, got %q", lines[1])
+ }
+ if lines[2] != "BOT" {
+ t.Fatalf("expected last overlay row, got %q", lines[2])
+ }
+}
diff --git a/internal/tui/common/list_state.go b/internal/tui/common/list_state.go
new file mode 100644
index 0000000..e02e7b6
--- /dev/null
+++ b/internal/tui/common/list_state.go
@@ -0,0 +1,129 @@
+package common
+
+import "strings"
+
+// State manages filter text, cursor movement, and filtered items for overlay lists.
+type ListState[T any] struct {
+ items []T
+ filtered []T
+ filter string
+ cursor int
+ match func(item T, needle string) bool
+}
+
+// NewListState constructs a filterable list state with the supplied matcher.
+func NewListState[T any](match func(item T, needle string) bool) ListState[T] {
+ return ListState[T]{match: match}
+}
+
+// SetItems replaces the backing items and resets the cursor to the first row.
+func (s *ListState[T]) SetItems(items []T) {
+ s.ResetItems(items, false)
+}
+
+// ResetItems replaces the backing items and optionally keeps the current cursor.
+func (s *ListState[T]) ResetItems(items []T, preserveCursor bool) {
+ s.items = clone(items)
+ if !preserveCursor {
+ s.cursor = 0
+ }
+ s.apply()
+}
+
+// SetFilter replaces the filter text and resets the cursor.
+func (s *ListState[T]) SetFilter(filter string) {
+ s.filter = filter
+ s.cursor = 0
+ s.apply()
+}
+
+// TypeFilter appends one character to the current filter.
+func (s *ListState[T]) TypeFilter(ch string) {
+ s.SetFilter(s.filter + ch)
+}
+
+// DeleteFilter removes the last character from the current filter.
+func (s *ListState[T]) DeleteFilter() {
+ if len(s.filter) == 0 {
+ return
+ }
+ s.SetFilter(s.filter[:len(s.filter)-1])
+}
+
+// ClearFilter clears the current filter.
+func (s *ListState[T]) ClearFilter() {
+ s.SetFilter("")
+}
+
+// Refilter reapplies the current filter against the current items.
+func (s *ListState[T]) Refilter() {
+ s.apply()
+}
+
+// Up moves the cursor one row up.
+func (s *ListState[T]) Up() {
+ if s.cursor > 0 {
+ s.cursor--
+ }
+}
+
+// Down moves the cursor one row down.
+func (s *ListState[T]) Down() {
+ if s.cursor < len(s.filtered)-1 {
+ s.cursor++
+ }
+}
+
+// Selected returns the currently selected filtered item.
+func (s *ListState[T]) Selected() (T, bool) {
+ var zero T
+ if s.cursor < 0 || s.cursor >= len(s.filtered) {
+ return zero, false
+ }
+ return s.filtered[s.cursor], true
+}
+
+// FilteredItems returns the filtered items in display order.
+func (s *ListState[T]) FilteredItems() []T {
+ return s.filtered
+}
+
+// Filter returns the current filter text.
+func (s *ListState[T]) Filter() string {
+ return s.filter
+}
+
+// Cursor returns the current filtered cursor position.
+func (s *ListState[T]) Cursor() int {
+ return s.cursor
+}
+
+func (s *ListState[T]) apply() {
+ if s.filter == "" {
+ s.filtered = clone(s.items)
+ } else {
+ needle := strings.ToLower(s.filter)
+ s.filtered = s.filtered[:0]
+ for _, item := range s.items {
+ if s.match == nil || s.match(item, needle) {
+ s.filtered = append(s.filtered, item)
+ }
+ }
+ }
+ if s.cursor >= len(s.filtered) {
+ s.cursor = max(0, len(s.filtered)-1)
+ }
+}
+
+func clone[T any](items []T) []T {
+ out := make([]T, len(items))
+ copy(out, items)
+ return out
+}
+
+func max(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/internal/tui/common/list_state_test.go b/internal/tui/common/list_state_test.go
new file mode 100644
index 0000000..3dc50ef
--- /dev/null
+++ b/internal/tui/common/list_state_test.go
@@ -0,0 +1,62 @@
+package common
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestStateFiltersAndNavigates(t *testing.T) {
+ state := NewListState(func(item string, needle string) bool {
+ return strings.Contains(strings.ToLower(item), needle)
+ })
+ state.SetItems([]string{"alpha", "beta", "betamax"})
+
+ state.TypeFilter("b")
+ state.TypeFilter("e")
+ state.TypeFilter("t")
+
+ if got := len(state.FilteredItems()); got != 2 {
+ t.Fatalf("expected 2 filtered items, got %d", got)
+ }
+ if got := state.Cursor(); got != 0 {
+ t.Fatalf("expected cursor to reset to 0, got %d", got)
+ }
+
+ state.Down()
+ selected, ok := state.Selected()
+ if !ok || selected != "betamax" {
+ t.Fatalf("expected betamax to be selected, got %q ok=%v", selected, ok)
+ }
+}
+
+func TestStateResetItemsPreservesCursorWhenPossible(t *testing.T) {
+ state := NewListState(func(item string, needle string) bool {
+ return strings.Contains(strings.ToLower(item), needle)
+ })
+ state.SetItems([]string{"alpha", "beta", "betamax"})
+ state.SetFilter("beta")
+ state.Down()
+
+ state.ResetItems([]string{"alpha", "beta"}, true)
+
+ selected, ok := state.Selected()
+ if !ok || selected != "beta" {
+ t.Fatalf("expected cursor to clamp to remaining item beta, got %q ok=%v", selected, ok)
+ }
+}
+
+func TestStateClearFilterRestoresAllItems(t *testing.T) {
+ state := NewListState(func(item string, needle string) bool {
+ return strings.Contains(strings.ToLower(item), needle)
+ })
+ state.SetItems([]string{"alpha", "beta"})
+ state.SetFilter("z")
+ if got := len(state.FilteredItems()); got != 0 {
+ t.Fatalf("expected 0 filtered items, got %d", got)
+ }
+
+ state.ClearFilter()
+ if got := len(state.FilteredItems()); got != 2 {
+ t.Fatalf("expected all items after clearing filter, got %d", got)
+ }
+}
diff --git a/internal/tui/model/logo.go b/internal/tui/common/logo.go
similarity index 96%
rename from internal/tui/model/logo.go
rename to internal/tui/common/logo.go
index 574bcd7..143c738 100644
--- a/internal/tui/model/logo.go
+++ b/internal/tui/common/logo.go
@@ -1,8 +1,8 @@
-package model
+package common
-// nexusLogo is a braille-art rendering of the Nexus logo (atom + circular arrows).
+// NexusLogo is a braille-art rendering of the Nexus logo (atom + circular arrows).
// Generated from docs/images/nexus.png at 48×20 braille chars (2×4 dots per char).
-const nexusLogo = `` +
+const NexusLogo = `` +
`⠀⠀⠀⠀⠀⠀⠀⢀⢠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢸⣸⡜⡜⡜⣜⢸⠠⠀⠀⠀⠀⠀` + "\n" +
`⠀⠀⠀⢀⣸⡞⡇⡇⡇⡇⡇⡗⡜⢼⢸⢠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣐⢼⢨⢠⣸⡞⠇⠁⠀⠀⠀⠀⠀⠂⣟⠨⠀⠀⠀⠀` + "\n" +
`⠀⠀⢀⣿⠁⠀⠀⠀⠀⢰⣸⡜⡜⢼⢺⣣⣷⣼⢸⡼⡜⡟⡇⡇⡇⡇⡟⡜⣜⣿⠫⡇⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⡒⣿⠀⠀⠀⠀` + "\n" +
diff --git a/internal/tui/common/markdown.go b/internal/tui/common/markdown.go
new file mode 100644
index 0000000..a2e0199
--- /dev/null
+++ b/internal/tui/common/markdown.go
@@ -0,0 +1,69 @@
+package common
+
+import (
+ "sync"
+
+ "charm.land/glamour/v2"
+ glamouransi "charm.land/glamour/v2/ansi"
+ glamourstyles "charm.land/glamour/v2/styles"
+)
+
+var (
+ markdownCacheMu sync.Mutex
+ markdownCache = map[int]*glamour.TermRenderer{}
+ rendererLocksMu sync.Mutex
+ rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
+)
+
+// MarkdownRenderer returns a memoized glamour renderer for the given width.
+// The renderer uses a custom dark style without visible ATX heading prefixes.
+func MarkdownRenderer(width int) *glamour.TermRenderer {
+ markdownCacheMu.Lock()
+ defer markdownCacheMu.Unlock()
+ wrappedWidth := ClampInt(width-4, 20, width)
+ if r, ok := markdownCache[wrappedWidth]; ok {
+ return r
+ }
+ r, err := glamour.NewTermRenderer(
+ glamour.WithStyles(markdownStyleConfig()),
+ glamour.WithWordWrap(wrappedWidth),
+ )
+ if err != nil {
+ return nil
+ }
+ markdownCache[wrappedWidth] = r
+ return r
+}
+
+// LockMarkdownRenderer returns the mutex guarding a shared glamour renderer.
+func LockMarkdownRenderer(r *glamour.TermRenderer) *sync.Mutex {
+ rendererLocksMu.Lock()
+ defer rendererLocksMu.Unlock()
+ if mu, ok := rendererLocks[r]; ok {
+ return mu
+ }
+ mu := &sync.Mutex{}
+ rendererLocks[r] = mu
+ return mu
+}
+
+// InvalidateMarkdownRendererCache drops all cached renderers and locks.
+func InvalidateMarkdownRendererCache() {
+ markdownCacheMu.Lock()
+ defer markdownCacheMu.Unlock()
+ rendererLocksMu.Lock()
+ defer rendererLocksMu.Unlock()
+ markdownCache = map[int]*glamour.TermRenderer{}
+ rendererLocks = map[*glamour.TermRenderer]*sync.Mutex{}
+}
+
+func markdownStyleConfig() glamouransi.StyleConfig {
+ style := glamourstyles.DarkStyleConfig
+ empty := ""
+ style.H2.Prefix = empty
+ style.H3.Prefix = empty
+ style.H4.Prefix = empty
+ style.H5.Prefix = empty
+ style.H6.Prefix = empty
+ return style
+}
diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go
new file mode 100644
index 0000000..34dc534
--- /dev/null
+++ b/internal/tui/common/styles.go
@@ -0,0 +1,253 @@
+package common
+
+import (
+ "time"
+
+ "charm.land/bubbles/v2/textarea"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+)
+
+// Palette — orange / grey accent matching the Nexus logo.
+// lipgloss/v2 Color returns an interface, so these must be var not const.
+var (
+ ColorPrimary = lipgloss.Color("#E8630A") // orange
+ ColorSecondary = lipgloss.Color("#FF8C42") // lighter orange
+ ColorMuted = lipgloss.Color("#6B7280") // grey
+ ColorBorder = lipgloss.Color("#374151") // dark border
+ ColorText = lipgloss.Color("#F9FAFB") // near-white text
+ ColorGreen = lipgloss.Color("#10B981")
+ ColorRed = lipgloss.Color("#EF4444")
+ ColorYellow = lipgloss.Color("#F59E0B")
+ ColorBlue = lipgloss.Color("#3B82F6")
+ ColorUserMsg = lipgloss.Color("#A5B4FC") // lavender for user messages
+)
+
+// Styles groups all lipgloss styles used by the TUI.
+type Styles struct {
+ // Header
+ Logo lipgloss.Style
+ HeaderBar lipgloss.Style
+ HeaderModel lipgloss.Style
+ HeaderSep lipgloss.Style
+ HeaderID lipgloss.Style
+ HeaderBusy lipgloss.Style
+ HeaderReady lipgloss.Style
+ HeaderPill lipgloss.Style
+ HeaderPillActive lipgloss.Style
+ HeaderPillBusy lipgloss.Style
+ HeaderPillReady lipgloss.Style
+
+ // Chat
+ UserLabel lipgloss.Style
+ AssistantLabel lipgloss.Style
+ UserMarker lipgloss.Style
+ AssistantMarker lipgloss.Style
+ TurnMeta lipgloss.Style
+ UserMsg lipgloss.Style
+ MsgTimestamp lipgloss.Style
+ ToolProgress lipgloss.Style
+ ToolDone lipgloss.Style
+ ToolError lipgloss.Style
+ ErrorMsg lipgloss.Style
+ Selection lipgloss.Style
+
+ // Input
+ InputBorder lipgloss.Style
+ InputPrompt lipgloss.Style
+ InputPlaceholder lipgloss.Style
+ InputHint lipgloss.Style
+ InputBadge lipgloss.Style
+ Textarea textarea.Styles
+
+ // Session browser
+ BrowserBorder lipgloss.Style
+ BrowserTitle lipgloss.Style
+ BrowserItem lipgloss.Style
+ BrowserSelected lipgloss.Style
+ BrowserFilter lipgloss.Style
+
+ // Permission dialog
+ PermBorder lipgloss.Style
+ PermTitle lipgloss.Style
+ PermBody lipgloss.Style
+ PermYes lipgloss.Style
+ PermNo lipgloss.Style
+ PermAlways lipgloss.Style
+
+ // Footer
+ Footer lipgloss.Style
+ Key lipgloss.Style
+ Desc lipgloss.Style
+}
+
+// DefaultStyles returns the theme used by the TUI.
+func DefaultStyles() Styles {
+ s := Styles{}
+
+ // Header
+ s.Logo = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorPrimary)
+ s.HeaderBar = lipgloss.NewStyle().
+ Padding(0, 1)
+ s.HeaderModel = lipgloss.NewStyle().
+ Foreground(ColorMuted)
+ s.HeaderSep = lipgloss.NewStyle().
+ Foreground(ColorBorder)
+ s.HeaderID = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ Faint(true)
+ s.HeaderBusy = lipgloss.NewStyle().
+ Foreground(ColorYellow)
+ s.HeaderReady = lipgloss.NewStyle().
+ Foreground(ColorGreen)
+ s.HeaderPill = lipgloss.NewStyle().
+ Foreground(ColorText).
+ Background(lipgloss.Color("#151A21")).
+ Padding(0, 1)
+ s.HeaderPillActive = lipgloss.NewStyle().
+ Foreground(ColorPrimary).
+ Background(lipgloss.Color("#151A21")).
+ Bold(true).
+ Padding(0, 1)
+ s.HeaderPillBusy = lipgloss.NewStyle().
+ Foreground(ColorYellow).
+ Background(lipgloss.Color("#151A21")).
+ Bold(true).
+ Padding(0, 1)
+ s.HeaderPillReady = lipgloss.NewStyle().
+ Foreground(ColorGreen).
+ Background(lipgloss.Color("#151A21")).
+ Bold(true).
+ Padding(0, 1)
+
+ // Chat
+ s.UserLabel = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorUserMsg)
+ s.AssistantLabel = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorSecondary)
+ s.UserMarker = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorBlue)
+ s.AssistantMarker = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorPrimary)
+ s.TurnMeta = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ Faint(true)
+ s.UserMsg = lipgloss.NewStyle().
+ Foreground(ColorText)
+ s.MsgTimestamp = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ Faint(true)
+ s.ToolProgress = lipgloss.NewStyle().
+ Foreground(ColorYellow)
+ s.ToolDone = lipgloss.NewStyle().
+ Foreground(ColorGreen)
+ s.ToolError = lipgloss.NewStyle().
+ Foreground(ColorRed)
+ s.ErrorMsg = lipgloss.NewStyle().
+ Foreground(ColorRed).
+ Bold(true)
+ s.Selection = lipgloss.NewStyle().
+ Background(lipgloss.Color("#5A3418"))
+
+ // Input
+ s.InputBorder = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(ColorBorder).
+ Padding(0, 1)
+ s.InputPrompt = lipgloss.NewStyle().
+ Foreground(ColorPrimary).
+ Bold(true)
+ s.InputPlaceholder = lipgloss.NewStyle().
+ Foreground(ColorMuted).
+ Faint(true)
+ s.InputHint = lipgloss.NewStyle().
+ Foreground(ColorMuted)
+ s.InputBadge = lipgloss.NewStyle().
+ Foreground(ColorPrimary).
+ Bold(true)
+ s.Textarea = textarea.Styles{
+ Focused: textarea.StyleState{
+ Base: lipgloss.NewStyle().Foreground(ColorText),
+ Text: lipgloss.NewStyle().Foreground(ColorText),
+ LineNumber: lipgloss.NewStyle().Foreground(ColorMuted),
+ CursorLine: lipgloss.NewStyle().Foreground(ColorText),
+ CursorLineNumber: lipgloss.NewStyle().Foreground(ColorMuted),
+ Placeholder: lipgloss.NewStyle().Foreground(ColorMuted).Faint(true),
+ Prompt: lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true),
+ },
+ Blurred: textarea.StyleState{
+ Base: lipgloss.NewStyle().Foreground(ColorMuted),
+ Text: lipgloss.NewStyle().Foreground(ColorMuted),
+ LineNumber: lipgloss.NewStyle().Foreground(ColorMuted),
+ CursorLine: lipgloss.NewStyle().Foreground(ColorMuted),
+ CursorLineNumber: lipgloss.NewStyle().Foreground(ColorMuted),
+ Placeholder: lipgloss.NewStyle().Foreground(ColorMuted).Faint(true),
+ Prompt: lipgloss.NewStyle().Foreground(ColorMuted),
+ },
+ Cursor: textarea.CursorStyle{
+ Color: ColorSecondary,
+ Shape: tea.CursorBar,
+ Blink: true,
+ BlinkSpeed: 420 * time.Millisecond,
+ },
+ }
+
+ // Session browser
+ s.BrowserBorder = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(ColorPrimary).
+ PaddingLeft(1).PaddingRight(1)
+ s.BrowserTitle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorPrimary).
+ Padding(0, 1)
+ s.BrowserItem = lipgloss.NewStyle().
+ Foreground(ColorText).
+ Padding(0, 1)
+ s.BrowserSelected = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorPrimary).
+ Background(lipgloss.Color("#1F2937")).
+ Padding(0, 1)
+ s.BrowserFilter = lipgloss.NewStyle().
+ Foreground(ColorText).
+ Padding(0, 1)
+
+ // Permission dialog
+ s.PermBorder = lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(ColorYellow).
+ PaddingLeft(2).PaddingRight(2).
+ PaddingTop(1).PaddingBottom(1)
+ s.PermTitle = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorYellow)
+ s.PermBody = lipgloss.NewStyle().
+ Foreground(ColorText)
+ s.PermYes = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorGreen)
+ s.PermNo = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorRed)
+ s.PermAlways = lipgloss.NewStyle().
+ Bold(true).
+ Foreground(ColorBlue)
+
+ // Footer
+ s.Footer = lipgloss.NewStyle().
+ Foreground(ColorMuted)
+ s.Key = lipgloss.NewStyle().
+ Foreground(ColorPrimary).
+ Bold(true)
+ s.Desc = lipgloss.NewStyle().
+ Foreground(ColorMuted)
+
+ return s
+}
diff --git a/internal/tui/model/attachments.go b/internal/tui/components/attachments.go
similarity index 70%
rename from internal/tui/model/attachments.go
rename to internal/tui/components/attachments.go
index 3a9bc2d..8924330 100644
--- a/internal/tui/model/attachments.go
+++ b/internal/tui/components/attachments.go
@@ -1,7 +1,8 @@
-package model
+package components
import (
"fmt"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
"path/filepath"
"strings"
)
@@ -15,46 +16,46 @@ type Attachment struct {
// attachments manages the file attachment strip above the input.
// Adapted from crush's ui/attachments/attachments.go.
-type attachments struct {
- styles Styles
+type Attachments struct {
+ styles common.Styles
list []Attachment
deleting bool // delete mode — next key removes an item
}
-func newAttachments(styles Styles) *attachments {
- return &attachments{styles: styles}
+func NewAttachments(styles common.Styles) *Attachments {
+ return &Attachments{styles: styles}
}
-func (a *attachments) Add(att Attachment) {
+func (a *Attachments) Add(att Attachment) {
a.list = append(a.list, att)
}
-func (a *attachments) AddPath(path string) {
+func (a *Attachments) AddPath(path string) {
a.list = append(a.list, Attachment{Path: path})
}
-func (a *attachments) List() []Attachment { return a.list }
-func (a *attachments) Count() int { return len(a.list) }
-func (a *attachments) Reset() { a.list = nil; a.deleting = false }
+func (a *Attachments) List() []Attachment { return a.list }
+func (a *Attachments) Count() int { return len(a.list) }
+func (a *Attachments) Reset() { a.list = nil; a.deleting = false }
// EnterDeleteMode activates attachment-delete mode.
-func (a *attachments) EnterDeleteMode() {
+func (a *Attachments) EnterDeleteMode() {
if len(a.list) > 0 {
a.deleting = true
}
}
// ExitDeleteMode cancels delete mode without removing anything.
-func (a *attachments) ExitDeleteMode() { a.deleting = false }
+func (a *Attachments) ExitDeleteMode() { a.deleting = false }
// DeleteAll removes all attachments and exits delete mode.
-func (a *attachments) DeleteAll() {
+func (a *Attachments) DeleteAll() {
a.list = nil
a.deleting = false
}
// DeleteLast removes the last attachment.
-func (a *attachments) DeleteLast() {
+func (a *Attachments) DeleteLast() {
if len(a.list) > 0 {
a.list = a.list[:len(a.list)-1]
}
@@ -64,7 +65,7 @@ func (a *attachments) DeleteLast() {
}
// View renders the attachment pills strip. Returns "" when there are no attachments.
-func (a *attachments) View(width int) string {
+func (a *Attachments) View(width int) string {
if len(a.list) == 0 {
return ""
}
diff --git a/internal/tui/components/chat.go b/internal/tui/components/chat.go
new file mode 100644
index 0000000..ca54053
--- /dev/null
+++ b/internal/tui/components/chat.go
@@ -0,0 +1,1636 @@
+package components
+
+import (
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+ "github.com/muesli/reflow/wrap"
+
+ "charm.land/bubbles/v2/viewport"
+ "charm.land/glamour/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/x/ansi"
+)
+
+var toolIcons = map[string]string{
+ "bash": "❯",
+ "write_file": "✏",
+ "edit_file": "✎",
+ "apply_patch": "⟠",
+ "read_file": "◻",
+ "list_directory": "◫",
+ "glob": "◈",
+ "grep": "◈",
+ "web_fetch": "◉",
+ "web_search": "◉",
+ "job_output": "◆",
+ "job_kill": "⊗",
+ "write_stdin": "❯",
+ "create_directory": "◫",
+ "spawn_agent": "◎",
+ "wait_agent": "◎",
+ "send_agent_message": "◎",
+ "close_agent": "◎",
+}
+
+func toolIconFor(name string) string {
+ if icon, ok := toolIcons[name]; ok {
+ return icon
+ }
+ return "◆"
+}
+
+type msgItem interface {
+ render(c *Chat, width int) string
+ isFinished() bool
+ invalidate()
+}
+
+const thinkTailLines = 4
+
+type thinkingBlock struct {
+ content string
+ streaming bool
+ startedAt time.Time
+ finishedAt time.Time
+ collapsed bool
+
+ cacheWidth int
+ cacheRender string
+}
+
+func newThinkingBlock() *thinkingBlock {
+ return &thinkingBlock{
+ streaming: true,
+ collapsed: true,
+ startedAt: time.Now(),
+ }
+}
+
+func (tb *thinkingBlock) append(text string) {
+ tb.content += text
+ tb.cacheWidth = 0
+}
+
+func (tb *thinkingBlock) finish() {
+ tb.streaming = false
+ tb.finishedAt = time.Now()
+ tb.cacheWidth = 0
+}
+
+func (tb *thinkingBlock) toggle() {
+ tb.collapsed = !tb.collapsed
+ tb.cacheWidth = 0
+}
+
+func (tb *thinkingBlock) render(styles common.Styles, width int) string {
+ if !tb.streaming && tb.cacheWidth == width && tb.cacheRender != "" {
+ return tb.cacheRender
+ }
+
+ innerW := width - 6
+ if innerW < 10 {
+ innerW = 10
+ }
+
+ lines := strings.Split(strings.TrimRight(tb.content, "\n"), "\n")
+ var shownLines []string
+ var hiddenCount int
+
+ if tb.collapsed && len(lines) > thinkTailLines {
+ hiddenCount = len(lines) - thinkTailLines
+ shownLines = lines[len(lines)-thinkTailLines:]
+ } else {
+ shownLines = lines
+ }
+
+ var inner strings.Builder
+ if hiddenCount > 0 {
+ inner.WriteString(styles.MsgTimestamp.Render(fmt.Sprintf("… %d lines hidden", hiddenCount)))
+ inner.WriteString("\n")
+ }
+ for i, line := range shownLines {
+ inner.WriteString(styles.MsgTimestamp.Render(wrap.String(line, innerW)))
+ if i < len(shownLines)-1 {
+ inner.WriteString("\n")
+ }
+ }
+
+ boxStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(common.ColorBorder).
+ Padding(0, 1).
+ Width(width - 2)
+
+ box := boxStyle.Render(inner.String())
+
+ var footParts []string
+ if tb.streaming {
+ footParts = append(footParts, styles.MsgTimestamp.Render("thinking…"))
+ } else {
+ dur := tb.finishedAt.Sub(tb.startedAt).Round(100 * time.Millisecond)
+ footParts = append(footParts,
+ styles.MsgTimestamp.Render(fmt.Sprintf("Thought for %.1fs", dur.Seconds())))
+ if tb.collapsed {
+ footParts = append(footParts, styles.Desc.Render("click to expand"))
+ } else {
+ footParts = append(footParts, styles.Desc.Render("click to collapse"))
+ }
+ }
+ foot := " " + strings.Join(footParts, " ")
+
+ result := box + "\n" + foot
+ if !tb.streaming {
+ tb.cacheWidth = width
+ tb.cacheRender = result
+ }
+ return result
+}
+
+type assistantItem struct {
+ thinking *thinkingBlock
+ content string
+ streaming bool
+ startedAt time.Time
+ finishedAt time.Time
+ showLabel bool
+ showMeta bool
+ inputTokens int
+ outputTokens int
+ stopReason string
+
+ contentCacheWidth int
+ contentCacheRender string
+}
+
+func newAssistantItem() *assistantItem {
+ return &assistantItem{streaming: true, showLabel: true, startedAt: time.Now()}
+}
+
+func newContinuationItem(startedAt time.Time) *assistantItem {
+ if startedAt.IsZero() {
+ startedAt = time.Now()
+ }
+ return &assistantItem{streaming: true, showLabel: false, startedAt: startedAt}
+}
+
+func (a *assistantItem) appendThinking(text string) {
+ if text == "" {
+ return
+ }
+ if a.thinking == nil {
+ a.thinking = newThinkingBlock()
+ }
+ a.thinking.append(text)
+ a.contentCacheWidth = 0
+}
+
+func (a *assistantItem) appendContent(text string) {
+ if a.thinking != nil && a.thinking.streaming {
+ a.thinking.finish()
+ }
+ a.content += text
+ a.contentCacheWidth = 0
+}
+
+func (a *assistantItem) finish(inputTokens, outputTokens int, stopReason string, showMeta bool) {
+ a.streaming = false
+ a.finishedAt = time.Now()
+ a.showMeta = showMeta
+ a.inputTokens = inputTokens
+ a.outputTokens = outputTokens
+ a.stopReason = stopReason
+ if a.thinking != nil && a.thinking.streaming {
+ a.thinking.finish()
+ }
+ a.contentCacheWidth = 0
+}
+
+func (a *assistantItem) isFinished() bool { return !a.streaming }
+func (a *assistantItem) invalidate() { a.contentCacheWidth = 0 }
+
+func (a *assistantItem) render(c *Chat, width int) string {
+ var sb strings.Builder
+ if a.showLabel {
+ sb.WriteString(c.styles.AssistantMarker.Render("●"))
+ sb.WriteString("\n")
+ }
+ if a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" {
+ sb.WriteString(a.thinking.render(c.styles, width))
+ sb.WriteString("\n")
+ }
+ if a.content != "" {
+ var rendered string
+ if !a.streaming && a.contentCacheWidth == width && a.contentCacheRender != "" {
+ rendered = a.contentCacheRender
+ } else {
+ var err error
+ mu := common.LockMarkdownRenderer(c.renderer)
+ mu.Lock()
+ rendered, err = c.renderer.Render(a.content)
+ mu.Unlock()
+ if err != nil {
+ rendered = a.content
+ }
+ rendered = strings.TrimRight(rendered, "\n")
+ if !a.streaming {
+ a.contentCacheWidth = width
+ a.contentCacheRender = rendered
+ }
+ }
+ sb.WriteString(rendered)
+ } else if a.streaming {
+ sb.WriteString(c.styles.MsgTimestamp.Render("…"))
+ }
+ if meta := a.metaLine(c.styles, width); meta != "" {
+ if sb.Len() > 0 {
+ sb.WriteString("\n\n")
+ }
+ sb.WriteString(meta)
+ }
+ return sb.String()
+}
+
+func (a *assistantItem) metaLine(styles common.Styles, width int) string {
+ if a.streaming || a.finishedAt.IsZero() || !a.showMeta {
+ return ""
+ }
+ left := styles.ToolDone.Render("done")
+ if !a.startedAt.IsZero() {
+ left += styles.TurnMeta.Render(" · " + formatDuration(a.finishedAt.Sub(a.startedAt)))
+ }
+ turnTokens := a.inputTokens + a.outputTokens
+ if turnTokens <= 0 {
+ return left
+ }
+ right := styles.TurnMeta.Render(compactTokenCount(turnTokens) + " tok")
+ sepLen := width - lipgloss.Width(left) - lipgloss.Width(right) - 2
+ if sepLen < 3 {
+ sepLen = 3
+ }
+ sep := styles.TurnMeta.Render(strings.Repeat("·", sepLen))
+ return left + " " + sep + " " + right
+}
+
+type userItem struct {
+ content string
+ timestamp time.Time
+ cacheW int
+ cacheR string
+}
+
+func (u *userItem) isFinished() bool { return true }
+func (u *userItem) invalidate() { u.cacheW = 0 }
+
+func (u *userItem) render(c *Chat, width int) string {
+ if u.cacheW == width && u.cacheR != "" {
+ return u.cacheR
+ }
+ _ = u.timestamp
+ prefix := "● > "
+ bodyWidth := max(12, width-lipgloss.Width(prefix))
+ wrapped := strings.Split(wrap.String(u.content, bodyWidth), "\n")
+ if len(wrapped) == 0 {
+ wrapped = []string{""}
+ }
+ wrapped[0] = c.styles.UserMarker.Render(prefix) + c.styles.UserMsg.Render(wrapped[0])
+ for i := 1; i < len(wrapped); i++ {
+ wrapped[i] = strings.Repeat(" ", lipgloss.Width(prefix)) + c.styles.UserMsg.Render(wrapped[i])
+ }
+ r := strings.Join(wrapped, "\n")
+ u.cacheW = width
+ u.cacheR = r
+ return r
+}
+
+type toolItem struct {
+ id string
+ name string
+ status string
+ label string
+ metadata map[string]any
+ expanded bool
+ startedAt time.Time
+ finishedAt time.Time
+
+ cacheW int
+ cacheR string
+}
+
+func newToolItem(id, name, status, label string, metadata map[string]any) *toolItem {
+ return &toolItem{
+ id: id,
+ name: name,
+ status: status,
+ label: label,
+ metadata: cloneMap(metadata),
+ startedAt: time.Now(),
+ }
+}
+
+func (t *toolItem) isDone() bool {
+ return t.status == "completed" || t.status == "failed" || t.status == "done" || t.status == "error"
+}
+
+func (t *toolItem) isFinished() bool { return t.isDone() }
+func (t *toolItem) invalidate() { t.cacheW = 0; t.cacheR = "" }
+
+func (t *toolItem) render(c *Chat, width int) string {
+ return t.renderSelected(c, width, false)
+}
+
+func (t *toolItem) expanderSymbol() string {
+ if !t.supportsPreview() {
+ return " "
+ }
+ if t.expanded {
+ return "▾"
+ }
+ return "▸"
+}
+
+func (t *toolItem) detailsSymbol(selected, detailsOpen bool) string {
+ if selected && detailsOpen {
+ return "⊟"
+ }
+ return "⊞"
+}
+
+func (t *toolItem) renderSelected(c *Chat, width int, selected bool) string {
+ if t.isDone() && !selected && !t.expanded && t.cacheW == width && t.cacheR != "" {
+ return t.cacheR
+ }
+
+ icon := t.renderIcon(c.styles)
+ nameStyle := t.renderNameStyle(c.styles)
+ summary := truncate(t.summaryText(), max(12, width-34))
+ expander := c.styles.MsgTimestamp.Render(t.expanderSymbol())
+ details := c.styles.MsgTimestamp.Render(t.detailsSymbol(selected, c.detailOpen && selected))
+ status := c.styles.MsgTimestamp.Render(t.statusLabel())
+
+ parts := []string{expander, details, icon, nameStyle.Render(toolDisplayName(t.name)), status}
+ if summary != "" {
+ parts = append(parts, c.styles.MsgTimestamp.Render(summary))
+ }
+ if dur := t.durationText(); dur != "" {
+ parts = append(parts, c.styles.MsgTimestamp.Render("("+dur+")"))
+ }
+
+ line := strings.Join(parts, " ")
+ if selected {
+ line = lipgloss.NewStyle().Foreground(common.ColorText).Background(lipgloss.Color("#1F2937")).Render(line)
+ }
+
+ if !t.expanded {
+ if t.isDone() && !selected {
+ t.cacheW = width
+ t.cacheR = line
+ }
+ return line
+ }
+
+ preview := t.inlinePreview(c, width)
+ if preview == "" {
+ preview = c.styles.MsgTimestamp.Render("No preview available.")
+ }
+ result := line + "\n" + indentBlock(preview, " ")
+ if t.isDone() && !selected {
+ t.cacheW = width
+ t.cacheR = result
+ }
+ return result
+}
+
+func (t *toolItem) renderIcon(styles common.Styles) string {
+ switch {
+ case t.status == "completed" || t.status == "done":
+ return styles.MsgTimestamp.Render("✓")
+ case t.status == "failed" || t.status == "error":
+ return styles.ToolError.Render("✗")
+ default:
+ return styles.ToolProgress.Render(toolIconFor(t.name))
+ }
+}
+
+func (t *toolItem) renderNameStyle(styles common.Styles) lipgloss.Style {
+ switch {
+ case t.status == "completed" || t.status == "done":
+ return styles.UserMsg
+ case t.status == "failed" || t.status == "error":
+ return styles.ToolError
+ default:
+ return styles.ToolProgress
+ }
+}
+
+func (t *toolItem) durationText() string {
+ if !t.isDone() || t.finishedAt.IsZero() {
+ if ms, ok := intFromMap(t.metadata, "execution_duration_ms"); ok && ms > 0 {
+ return formatDuration(time.Duration(ms) * time.Millisecond)
+ }
+ return ""
+ }
+ return formatDuration(t.finishedAt.Sub(t.startedAt))
+}
+
+func (t *toolItem) toolInput() map[string]any {
+ return normalizeMap(t.metadata["tool_input"])
+}
+
+func (t *toolItem) supportsPreview() bool {
+ switch t.name {
+ case "read_file", "write_file", "edit_file", "apply_patch", "bash", "spawn_agent", "wait_agent", "close_agent", "send_agent_message":
+ return true
+ default:
+ return strings.TrimSpace(t.resultContent()) != ""
+ }
+}
+
+func (t *toolItem) statusLabel() string {
+ switch t.status {
+ case "completed", "done":
+ return "done"
+ case "failed", "error":
+ return "failed"
+ case "running", "started":
+ return "running"
+ default:
+ return t.status
+ }
+}
+
+func (t *toolItem) summaryText() string {
+ input := t.toolInput()
+ switch t.name {
+ case "read_file":
+ path := compactPath(stringFromMap(input, "file_path"))
+ if path == "" {
+ path = t.label
+ }
+ return path
+ case "write_file", "edit_file":
+ path := compactPath(stringFromMap(input, "file_path"))
+ kind := stringFromMap(t.metadata, "type")
+ if kind != "" {
+ return path + " · " + kind
+ }
+ return path
+ case "apply_patch":
+ if content := stringFromMap(t.metadata, "content"); content != "" {
+ return firstLine(content)
+ }
+ if patch := stringFromMap(input, "patch"); patch != "" {
+ return firstLine(strings.TrimSpace(patch))
+ }
+ case "bash":
+ cmd := strings.TrimSpace(stringFromMap(input, "command"))
+ if cmd == "" {
+ cmd = strings.TrimSpace(stringFromMap(t.metadata, "description"))
+ }
+ return cmd
+ case "spawn_agent":
+ prompt := strings.TrimSpace(stringFromMap(input, "prompt"))
+ nickname := strings.TrimSpace(stringFromMap(input, "nickname"))
+ if nickname != "" {
+ return nickname + " · " + prompt
+ }
+ return prompt
+ case "wait_agent", "close_agent", "send_agent_message":
+ agentID := strings.TrimSpace(stringFromMap(input, "agent_id"))
+ if agentID != "" {
+ return agentID
+ }
+ }
+ if t.label != "" && t.label != t.status {
+ return strings.TrimSpace(t.label)
+ }
+ if msg := strings.TrimSpace(stringFromMap(t.metadata, "content")); msg != "" {
+ return firstLine(msg)
+ }
+ return ""
+}
+
+func (t *toolItem) inlinePreview(c *Chat, width int) string {
+ bodyWidth := max(20, width-8)
+ switch t.name {
+ case "read_file":
+ input := t.toolInput()
+ path := prettyPath(stringFromMap(input, "file_path"))
+ content := t.resultContent()
+ return renderContentPanel(c.styles, path, content, bodyWidth, 8)
+ case "write_file", "edit_file":
+ path := prettyPath(stringFromMap(t.toolInput(), "file_path"))
+ if diff := t.diffPreview(); diff != "" {
+ return renderContentPanel(c.styles, path, diff, bodyWidth, 10)
+ }
+ return renderContentPanel(c.styles, path, t.resultContent(), bodyWidth, 8)
+ case "apply_patch":
+ if diff := stringFromMap(t.toolInput(), "patch"); diff != "" {
+ return renderContentPanel(c.styles, "patch", diff, bodyWidth, 10)
+ }
+ return renderContentPanel(c.styles, "patch", t.resultContent(), bodyWidth, 8)
+ case "bash":
+ return renderContentPanel(c.styles, "output", t.commandOutput(), bodyWidth, 8)
+ case "spawn_agent", "wait_agent", "close_agent", "send_agent_message":
+ return renderContentPanel(c.styles, "agent", t.agentDetails(), bodyWidth, 8)
+ default:
+ return renderContentPanel(c.styles, toolDisplayName(t.name), t.resultContent(), bodyWidth, 8)
+ }
+}
+
+func (t *toolItem) detailView(c *Chat, width, height int) string {
+ innerW := max(24, width-4)
+ sections := []string{
+ c.styles.AssistantLabel.Render(toolDisplayName(t.name)),
+ c.styles.MsgTimestamp.Render(strings.ToUpper(t.status)),
+ }
+ if summary := t.summaryText(); summary != "" {
+ sections = append(sections, wrap.String(summary, innerW))
+ }
+ if meta := t.metaSummary(); meta != "" {
+ sections = append(sections, meta)
+ }
+ if body := t.detailBody(c, innerW); body != "" {
+ sections = append(sections, body)
+ }
+ content := strings.Join(sections, "\n\n")
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(common.ColorBorder).
+ Padding(0, 1).
+ Width(width).
+ MaxHeight(height).
+ Render(content)
+ return box
+}
+
+func (t *toolItem) metaSummary() string {
+ var lines []string
+ if dur := t.durationText(); dur != "" {
+ lines = append(lines, "duration: "+dur)
+ }
+ if code, ok := intFromMap(t.metadata, "exit_code"); ok {
+ lines = append(lines, fmt.Sprintf("exit code: %d", code))
+ }
+ if cwd := stringFromMap(t.metadata, "cwd"); cwd != "" {
+ lines = append(lines, "cwd: "+cwd)
+ }
+ if taskID := stringFromMap(t.metadata, "task_id"); taskID != "" {
+ lines = append(lines, "task: "+taskID)
+ }
+ if count, ok := intFromMap(t.metadata, "lines_added"); ok {
+ removed, _ := intFromMap(t.metadata, "lines_removed")
+ lines = append(lines, fmt.Sprintf("changes: +%d -%d", count, removed))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (t *toolItem) detailBody(c *Chat, width int) string {
+ switch t.name {
+ case "read_file":
+ return renderContentPanel(c.styles, prettyPath(stringFromMap(t.toolInput(), "file_path")), t.resultContent(), width, 200)
+ case "write_file", "edit_file":
+ if diff := t.diffPreview(); diff != "" {
+ return renderContentPanel(c.styles, prettyPath(stringFromMap(t.toolInput(), "file_path")), diff, width, 200)
+ }
+ return renderContentPanel(c.styles, prettyPath(stringFromMap(t.toolInput(), "file_path")), t.resultContent(), width, 200)
+ case "apply_patch":
+ body := t.resultContent()
+ if patch := stringFromMap(t.toolInput(), "patch"); patch != "" {
+ body = body + "\n\n" + patch
+ }
+ return renderContentPanel(c.styles, "patch", body, width, 220)
+ case "bash":
+ cmd := stringFromMap(t.toolInput(), "command")
+ body := t.commandOutput()
+ if cmd != "" {
+ body = "$ " + cmd + "\n\n" + body
+ }
+ return renderContentPanel(c.styles, "bash", body, width, 180)
+ case "spawn_agent", "wait_agent", "close_agent", "send_agent_message":
+ return renderContentPanel(c.styles, "agent", t.agentDetails(), width, 160)
+ default:
+ return renderContentPanel(c.styles, toolDisplayName(t.name), t.resultContent(), width, 140)
+ }
+}
+
+func (t *toolItem) resultContent() string {
+ if content := stringFromMap(t.metadata, "content"); content != "" {
+ return content
+ }
+ return ""
+}
+
+func (t *toolItem) diffPreview() string {
+ if patch := nestedString(t.metadata["git_diff"], "patch", "Patch"); patch != "" {
+ return patch
+ }
+ if structured := prettyJSON(t.metadata["structured_patch"]); structured != "" {
+ return structured
+ }
+ if original := stringFromMap(t.metadata, "original_file"); original != "" {
+ if content := stringFromMap(t.metadata, "content"); content != "" {
+ return "--- before\n" + original + "\n\n+++ after\n" + content
+ }
+ }
+ return ""
+}
+
+func (t *toolItem) commandOutput() string {
+ stdout := stringFromMap(t.metadata, "stdout")
+ stderr := stringFromMap(t.metadata, "stderr")
+ if stdout == "" && stderr == "" {
+ if content := t.resultContent(); content != "" {
+ return content
+ }
+ return t.label
+ }
+ if stdout != "" && stderr != "" {
+ return stdout + "\n\n[stderr]\n" + stderr
+ }
+ if stdout != "" {
+ return stdout
+ }
+ return stderr
+}
+
+func (t *toolItem) agentDetails() string {
+ input := t.toolInput()
+ var parts []string
+ if nickname := stringFromMap(input, "nickname"); nickname != "" {
+ parts = append(parts, "nickname: "+nickname)
+ }
+ if role := stringFromMap(input, "role"); role != "" {
+ parts = append(parts, "role: "+role)
+ }
+ if agentID := stringFromMap(input, "agent_id"); agentID != "" {
+ parts = append(parts, "agent: "+agentID)
+ }
+ if prompt := stringFromMap(input, "prompt"); prompt != "" {
+ parts = append(parts, "prompt:\n"+prompt)
+ }
+ if msg := stringFromMap(input, "message"); msg != "" {
+ parts = append(parts, "message:\n"+msg)
+ }
+ if content := t.resultContent(); content != "" {
+ parts = append(parts, content)
+ }
+ return strings.Join(parts, "\n\n")
+}
+
+type systemItem struct{ content string }
+
+func (s *systemItem) isFinished() bool { return true }
+func (s *systemItem) invalidate() {}
+func (s *systemItem) render(c *Chat, _ int) string {
+ return c.styles.MsgTimestamp.Render("─ " + s.content)
+}
+
+type errorItem struct{ content string }
+
+func (e *errorItem) isFinished() bool { return true }
+func (e *errorItem) invalidate() {}
+func (e *errorItem) render(c *Chat, _ int) string {
+ return c.styles.ToolError.Render("✗ " + e.content)
+}
+
+type toolRegion struct {
+ startLine int
+ endLine int
+ msgIndex int
+ expanderStart int
+ expanderEnd int
+ detailStart int
+ detailEnd int
+}
+
+type thinkingRegion struct {
+ startLine int
+ endLine int
+ msgIndex int
+}
+
+type Chat struct {
+ styles common.Styles
+ viewport *viewport.Model
+ renderer *glamour.TermRenderer
+ messages []msgItem
+ width int
+ height int
+ follow bool
+
+ selectedTool int
+ detailOpen bool
+
+ renderedContent string
+ renderedLines []string
+ plainContent string
+ plainLines []string
+ toolRegions []toolRegion
+ thinkingRegions []thinkingRegion
+ selection mouseSelection
+}
+
+func NewChat(styles common.Styles, width, height int) *Chat {
+ vp := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
+ vp.SetContent("")
+ r := common.MarkdownRenderer(width)
+ return &Chat{
+ styles: styles,
+ viewport: &vp,
+ renderer: r,
+ follow: true,
+ width: width,
+ height: height,
+ selectedTool: -1,
+ }
+}
+
+func (c *Chat) SetSize(width, height int) {
+ c.width = width
+ c.height = height
+ c.viewport.SetWidth(width)
+ c.viewport.SetHeight(height)
+ if r := common.MarkdownRenderer(width); r != nil {
+ c.renderer = r
+ }
+ for _, m := range c.messages {
+ m.invalidate()
+ }
+ c.refresh()
+}
+
+func (c *Chat) AddUserMessage(text string) {
+ c.messages = append(c.messages, &userItem{content: text, timestamp: time.Now()})
+ c.refresh()
+}
+
+func (c *Chat) StartAssistantMessage() {
+ c.messages = append(c.messages, newAssistantItem())
+ c.refresh()
+}
+
+func (c *Chat) AppendChunk(text string, isThinking bool) {
+ if text == "" {
+ return
+ }
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if a, ok := c.messages[i].(*assistantItem); ok && a.streaming {
+ if isThinking {
+ a.appendThinking(text)
+ } else {
+ a.appendContent(text)
+ }
+ c.refresh()
+ return
+ }
+ }
+ isContinuation := false
+ continuationStart := time.Time{}
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if _, ok := c.messages[i].(*userItem); ok {
+ break
+ }
+ if a, ok := c.messages[i].(*assistantItem); ok {
+ isContinuation = true
+ continuationStart = a.startedAt
+ break
+ }
+ }
+ if isContinuation {
+ c.messages = append(c.messages, newContinuationItem(continuationStart))
+ } else {
+ c.messages = append(c.messages, newAssistantItem())
+ }
+ c.AppendChunk(text, isThinking)
+}
+
+func (c *Chat) FinishAssistantMessage(inputTokens, outputTokens int, stopReason string) {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if a, ok := c.messages[i].(*assistantItem); ok && a.streaming {
+ a.finish(inputTokens, outputTokens, stopReason, true)
+ c.refresh()
+ return
+ }
+ }
+}
+
+func (c *Chat) AddToolProgress(toolUseID, toolName, status, label string, metadata map[string]any) {
+ if toolUseID != "" {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if t, ok := c.messages[i].(*toolItem); ok && t.id == toolUseID {
+ t.status = status
+ t.label = label
+ if len(metadata) > 0 {
+ t.metadata = cloneMap(metadata)
+ }
+ if t.isDone() {
+ t.finishedAt = time.Now()
+ }
+ t.invalidate()
+ c.refresh()
+ return
+ }
+ }
+ } else {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if t, ok := c.messages[i].(*toolItem); ok && t.name == toolName && !t.isDone() {
+ t.status = status
+ t.label = label
+ if len(metadata) > 0 {
+ t.metadata = cloneMap(metadata)
+ }
+ if t.isDone() {
+ t.finishedAt = time.Now()
+ }
+ t.invalidate()
+ c.refresh()
+ return
+ }
+ }
+ }
+
+ c.sealActiveAssistant()
+ c.messages = append(c.messages, newToolItem(toolUseID, toolName, status, label, metadata))
+ c.selectedTool = len(c.messages) - 1
+ c.refresh()
+}
+
+func (c *Chat) sealActiveAssistant() {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ a, ok := c.messages[i].(*assistantItem)
+ if !ok || !a.streaming {
+ continue
+ }
+ hasThinking := a.thinking != nil && strings.TrimSpace(a.thinking.content) != ""
+ if a.content == "" && !hasThinking {
+ c.messages = append(c.messages[:i], c.messages[i+1:]...)
+ } else {
+ a.finish(0, 0, "", false)
+ }
+ return
+ }
+}
+
+func (c *Chat) AddError(err error) {
+ c.messages = append(c.messages, &errorItem{content: err.Error()})
+ c.refresh()
+}
+
+func (c *Chat) AddSystem(text string) {
+ c.messages = append(c.messages, &systemItem{content: text})
+ c.refresh()
+}
+
+func (c *Chat) Clear() {
+ c.messages = c.messages[:0]
+ c.selectedTool = -1
+ c.detailOpen = false
+ c.refresh()
+}
+
+func (c *Chat) GetLastAssistantText() string {
+ var parts []string
+ inCurrentTurn := false
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ switch m := c.messages[i].(type) {
+ case *userItem:
+ if inCurrentTurn {
+ goto done
+ }
+ case *assistantItem:
+ inCurrentTurn = true
+ if m.content != "" {
+ parts = append([]string{m.content}, parts...)
+ }
+ }
+ }
+done:
+ return strings.Join(parts, "\n\n")
+}
+
+func (c *Chat) GetLastUserText() string {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if u, ok := c.messages[i].(*userItem); ok {
+ return u.content
+ }
+ }
+ return ""
+}
+
+func (c *Chat) ToggleThinking() {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if a, ok := c.messages[i].(*assistantItem); ok {
+ if a.thinking != nil {
+ a.thinking.toggle()
+ c.refresh()
+ }
+ return
+ }
+ }
+}
+
+func (c *Chat) HasThinking() bool {
+ for i := len(c.messages) - 1; i >= 0; i-- {
+ if a, ok := c.messages[i].(*assistantItem); ok {
+ return a.thinking != nil
+ }
+ }
+ return false
+}
+
+func (c *Chat) HasTools() bool {
+ for _, item := range c.messages {
+ if _, ok := item.(*toolItem); ok {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Chat) HasSelectedTool() bool {
+ return c.selectedToolIndex() >= 0
+}
+
+func (c *Chat) DetailsOpen() bool {
+ return c.detailOpen && c.HasSelectedTool()
+}
+
+func (c *Chat) ToggleSelectedToolExpanded() bool {
+ if tool := c.selectedToolItem(); tool != nil {
+ tool.expanded = !tool.expanded
+ tool.invalidate()
+ c.refresh()
+ return true
+ }
+ return false
+}
+
+func (c *Chat) ToggleDetails() bool {
+ if !c.HasSelectedTool() {
+ return false
+ }
+ c.detailOpen = !c.detailOpen
+ c.refresh()
+ return true
+}
+
+func (c *Chat) CloseDetails() {
+ if c.detailOpen {
+ c.detailOpen = false
+ c.refresh()
+ }
+}
+
+func (c *Chat) SelectNextTool() bool {
+ for i, start := range c.toolIndices() {
+ if start > c.selectedToolIndex() {
+ c.selectedTool = start
+ c.refresh()
+ _ = i
+ return true
+ }
+ }
+ indices := c.toolIndices()
+ if len(indices) > 0 && c.selectedToolIndex() < 0 {
+ c.selectedTool = indices[0]
+ c.refresh()
+ return true
+ }
+ return false
+}
+
+func (c *Chat) SelectPrevTool() bool {
+ indices := c.toolIndices()
+ for i := len(indices) - 1; i >= 0; i-- {
+ if indices[i] < c.selectedToolIndex() {
+ c.selectedTool = indices[i]
+ c.refresh()
+ return true
+ }
+ }
+ if len(indices) > 0 && c.selectedToolIndex() < 0 {
+ c.selectedTool = indices[len(indices)-1]
+ c.refresh()
+ return true
+ }
+ return false
+}
+
+func (c *Chat) HandleMouseDown(x, y int) bool {
+ line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1))
+ if line < 0 || line >= len(c.plainLines) {
+ return false
+ }
+ clicks := c.selection.begin(line, max(0, x), time.Now())
+ switch clicks {
+ case 2:
+ c.selectWordAt(line, max(0, x))
+ case 3:
+ c.selectLineAt(line)
+ }
+ c.refresh()
+ return true
+}
+
+func (c *Chat) HandleMouseDrag(x, y int) bool {
+ if !c.selection.dragging {
+ return false
+ }
+ if len(c.plainLines) == 0 {
+ return false
+ }
+ if y < 0 {
+ c.ScrollUp(1)
+ y = 0
+ } else if y >= c.height {
+ c.ScrollDown(1)
+ y = max(0, c.height-1)
+ }
+ line := c.viewport.YOffset() + clampInt(y, 0, max(0, c.height-1))
+ line = clampInt(line, 0, len(c.plainLines)-1)
+ c.selection.update(line, max(0, x))
+ c.refresh()
+ return true
+}
+
+func (c *Chat) HandleMouseUp(x, y int) string {
+ if !c.selection.dragging {
+ return ""
+ }
+ _ = c.HandleMouseDrag(x, y)
+ wasMoved := c.selection.finish()
+ text := ""
+ if wasMoved {
+ text = c.selectedText()
+ c.refresh()
+ } else {
+ line := c.selection.startLine
+ c.selection.clear()
+ if idx := c.thinkingIndexAtLine(line); idx >= 0 {
+ c.handleThinkingLineClick(idx)
+ } else if idx := c.toolIndexAtLine(line); idx >= 0 {
+ c.handleToolLineClick(idx, max(0, x), line)
+ } else {
+ c.refresh()
+ }
+ }
+ return text
+}
+
+func (c *Chat) HasMouseCapture() bool {
+ return c.selection.dragging
+}
+
+func (c *Chat) handleToolLineClick(msgIndex, x, line int) {
+ if msgIndex < 0 || msgIndex >= len(c.messages) {
+ return
+ }
+ tool, ok := c.messages[msgIndex].(*toolItem)
+ if !ok {
+ return
+ }
+ for _, region := range c.toolRegions {
+ if region.msgIndex != msgIndex {
+ continue
+ }
+ if line == region.startLine && x >= region.expanderStart && x < region.expanderEnd && tool.supportsPreview() {
+ tool.expanded = !tool.expanded
+ tool.invalidate()
+ c.selectedTool = msgIndex
+ c.refresh()
+ return
+ }
+ if line == region.startLine && x >= region.detailStart && x < region.detailEnd {
+ c.selectedTool = msgIndex
+ c.detailOpen = !(c.selectedTool == msgIndex && c.detailOpen)
+ c.refresh()
+ return
+ }
+ break
+ }
+ c.selectedTool = msgIndex
+ c.refresh()
+}
+
+func (c *Chat) handleThinkingLineClick(msgIndex int) {
+ if msgIndex < 0 || msgIndex >= len(c.messages) {
+ return
+ }
+ assistant, ok := c.messages[msgIndex].(*assistantItem)
+ if !ok || assistant.thinking == nil {
+ return
+ }
+ assistant.thinking.toggle()
+ assistant.invalidate()
+ c.refresh()
+}
+
+func (c *Chat) thinkingIndexAtLine(line int) int {
+ for _, region := range c.thinkingRegions {
+ if line >= region.startLine && line <= region.endLine {
+ return region.msgIndex
+ }
+ }
+ return -1
+}
+
+func (c *Chat) toolIndexAtLine(line int) int {
+ for _, region := range c.toolRegions {
+ if line >= region.startLine && line <= region.endLine {
+ return region.msgIndex
+ }
+ }
+ return -1
+}
+
+func (c *Chat) selectWordAt(line, col int) {
+ if line < 0 || line >= len(c.plainLines) {
+ return
+ }
+ runes := []rune(c.plainLines[line])
+ if len(runes) == 0 {
+ c.selection.setRange(line, 0, line, 0)
+ return
+ }
+ col = clampInt(col, 0, len(runes)-1)
+ isWord := func(r rune) bool {
+ return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
+ }
+ if !isWord(runes[col]) {
+ for col < len(runes) && !isWord(runes[col]) {
+ col++
+ }
+ if col >= len(runes) {
+ c.selection.setRange(line, 0, line, len(runes))
+ return
+ }
+ }
+ start := col
+ for start > 0 && isWord(runes[start-1]) {
+ start--
+ }
+ end := col
+ for end < len(runes) && isWord(runes[end]) {
+ end++
+ }
+ c.selection.setRange(line, start, line, end)
+}
+
+func (c *Chat) selectLineAt(line int) {
+ if line < 0 || line >= len(c.plainLines) {
+ return
+ }
+ runes := []rune(c.plainLines[line])
+ c.selection.setRange(line, 0, line, len(runes))
+}
+
+func (c *Chat) HasSelection() bool {
+ return c.selection.hasSelection()
+}
+
+func (c *Chat) SelectedText() string {
+ return c.selectedText()
+}
+
+func (c *Chat) selectedText() string {
+ if len(c.plainLines) == 0 {
+ return ""
+ }
+ startLn, startCo, endLn, endCo := c.selectionRange()
+ if startLn < 0 || endLn < 0 {
+ return ""
+ }
+ var parts []string
+ for line := startLn; line <= endLn; line++ {
+ current := c.plainLines[line]
+ runes := []rune(current)
+ lineStart := 0
+ lineEnd := len(runes)
+ if line == startLn {
+ lineStart = clampInt(startCo, 0, len(runes))
+ }
+ if line == endLn {
+ lineEnd = clampInt(endCo, 0, len(runes))
+ }
+ if line == startLn && line == endLn && lineEnd < lineStart {
+ lineStart, lineEnd = lineEnd, lineStart
+ }
+ if lineEnd < lineStart {
+ lineEnd = lineStart
+ }
+ parts = append(parts, normalizeCopiedLine(string(runes[lineStart:lineEnd])))
+ }
+ joined := strings.Join(parts, "\n")
+ joined = strings.Trim(joined, "\n")
+ return joined
+}
+
+func normalizeCopiedLine(line string) string {
+ switch {
+ case strings.HasPrefix(line, "● > "):
+ return strings.TrimPrefix(line, "● > ")
+ case strings.HasPrefix(line, "● "):
+ return strings.TrimPrefix(line, "● ")
+ case line == "●":
+ return ""
+ case strings.HasPrefix(line, "─ "):
+ return strings.TrimPrefix(line, "─ ")
+ case strings.HasPrefix(line, "✗ "):
+ return strings.TrimPrefix(line, "✗ ")
+ default:
+ return line
+ }
+}
+
+func (c *Chat) selectionRange() (int, int, int, int) {
+ return c.selection.rangeOrInvalid()
+}
+
+func clampInt(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+
+func (c *Chat) DetailView(width, height int) string {
+ tool := c.selectedToolItem()
+ if tool == nil {
+ return ""
+ }
+ return tool.detailView(c, width, height)
+}
+
+func (c *Chat) ScrollUp(n int) { c.follow = false; c.viewport.ScrollUp(n) }
+func (c *Chat) ScrollDown(n int) { c.viewport.ScrollDown(n); c.follow = c.viewport.AtBottom() }
+func (c *Chat) PageUp() { c.follow = false; c.viewport.HalfPageUp() }
+func (c *Chat) PageDown() { c.viewport.HalfPageDown(); c.follow = c.viewport.AtBottom() }
+func (c *Chat) GotoTop() { c.follow = false; c.viewport.GotoTop() }
+func (c *Chat) GotoBottom() { c.follow = true; c.viewport.GotoBottom() }
+func (c *Chat) View() string { return c.viewport.View() }
+
+func (c *Chat) selectedToolIndex() int {
+ if c.selectedTool < 0 || c.selectedTool >= len(c.messages) {
+ return -1
+ }
+ if _, ok := c.messages[c.selectedTool].(*toolItem); !ok {
+ return -1
+ }
+ return c.selectedTool
+}
+
+func (c *Chat) selectedToolItem() *toolItem {
+ if idx := c.selectedToolIndex(); idx >= 0 {
+ if tool, ok := c.messages[idx].(*toolItem); ok {
+ return tool
+ }
+ }
+ return nil
+}
+
+func (c *Chat) toolIndices() []int {
+ indices := make([]int, 0)
+ for i, item := range c.messages {
+ if _, ok := item.(*toolItem); ok {
+ indices = append(indices, i)
+ }
+ }
+ return indices
+}
+
+func (c *Chat) refresh() {
+ var sb strings.Builder
+ var plainSB strings.Builder
+ lastWasTool := false
+ wroteAny := false
+ line := 0
+ toolRegions := make([]toolRegion, 0)
+ thinkingRegions := make([]thinkingRegion, 0)
+ for i, item := range c.messages {
+ var rendered string
+ if tool, ok := item.(*toolItem); ok {
+ rendered = tool.renderSelected(c, c.width, i == c.selectedToolIndex())
+ } else {
+ rendered = item.render(c, c.width)
+ }
+ if rendered == "" {
+ continue
+ }
+ plainRendered := ansi.Strip(rendered)
+ if wroteAny {
+ _, currIsTool := item.(*toolItem)
+ if lastWasTool && currIsTool {
+ sb.WriteString("\n")
+ plainSB.WriteString("\n")
+ line += 1
+ } else {
+ sb.WriteString("\n\n")
+ plainSB.WriteString("\n\n")
+ line += 2
+ }
+ }
+ startLine := line
+ sb.WriteString(rendered)
+ plainSB.WriteString(plainRendered)
+ height := max(1, lipgloss.Height(plainRendered))
+ if _, ok := item.(*toolItem); ok {
+ toolRegions = append(toolRegions, toolRegion{startLine: startLine, endLine: startLine + height - 1, msgIndex: i, expanderStart: 0, expanderEnd: 1, detailStart: 2, detailEnd: 3})
+ }
+ if assistant, ok := item.(*assistantItem); ok && assistant.thinking != nil && strings.TrimSpace(assistant.thinking.content) != "" {
+ thinkingStart := startLine
+ if assistant.showLabel {
+ thinkingStart++
+ }
+ thinkingRendered := assistant.thinking.render(c.styles, c.width)
+ thinkingHeight := max(1, lipgloss.Height(ansi.Strip(thinkingRendered)))
+ thinkingRegions = append(thinkingRegions, thinkingRegion{startLine: thinkingStart, endLine: thinkingStart + thinkingHeight - 1, msgIndex: i})
+ }
+ line += height
+ _, lastWasTool = item.(*toolItem)
+ wroteAny = true
+ }
+ content := sb.String()
+ plain := plainSB.String()
+ c.renderedContent = content
+ if content == "" {
+ c.renderedLines = nil
+ } else {
+ c.renderedLines = strings.Split(content, "\n")
+ }
+ c.plainContent = plain
+ if plain == "" {
+ c.plainLines = nil
+ } else {
+ c.plainLines = strings.Split(plain, "\n")
+ }
+ c.toolRegions = toolRegions
+ c.thinkingRegions = thinkingRegions
+ if c.selection.hasSelection() {
+ c.viewport.SetContent(c.highlightedSelectionContent())
+ } else {
+ c.viewport.SetContent(content)
+ }
+ if c.follow {
+ c.viewport.GotoBottom()
+ }
+}
+func (c *Chat) highlightedSelectionContent() string {
+ if len(c.renderedLines) == 0 {
+ return c.renderedContent
+ }
+ startLn, startCo, endLn, endCo := c.selectionRange()
+ if startLn < 0 || endLn < 0 {
+ return c.renderedContent
+ }
+ lines := make([]string, len(c.renderedLines))
+ copy(lines, c.renderedLines)
+ for line := startLn; line <= endLn; line++ {
+ renderedLine := lines[line]
+ lineWidth := ansi.StringWidth(renderedLine)
+ lineStart := 0
+ lineEnd := lineWidth
+ if line == startLn {
+ lineStart = clampInt(startCo, 0, lineWidth)
+ }
+ if line == endLn {
+ lineEnd = clampInt(endCo, 0, lineWidth)
+ }
+ if line == startLn && line == endLn && lineEnd < lineStart {
+ lineStart, lineEnd = lineEnd, lineStart
+ }
+ if lineEnd < lineStart {
+ lineEnd = lineStart
+ }
+ before := ansi.Cut(renderedLine, 0, lineStart)
+ middle := ansi.Cut(renderedLine, lineStart, lineEnd)
+ after := ansi.Cut(renderedLine, lineEnd, lineWidth)
+ if middle == "" && lineStart < lineWidth {
+ middle = ansi.Cut(renderedLine, lineStart, lineStart+1)
+ after = ansi.Cut(renderedLine, lineStart+1, lineWidth)
+ }
+ lines[line] = before + applySelectionStyle(middle, c.styles.Selection) + after
+ }
+ return strings.Join(lines, "\n")
+}
+
+func headerLine(style lipgloss.Style, width int) string {
+ return style.Render(strings.Repeat("─", max(0, width)))
+}
+
+func compactTokenCount(n int) string {
+ switch {
+ case n >= 1_000_000:
+ return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0"), ".") + "M"
+ case n >= 1_000:
+ return strings.TrimSuffix(strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000), ".0"), ".") + "k"
+ default:
+ return fmt.Sprintf("%d", n)
+ }
+}
+
+func truncate(s string, maxLen int) string {
+ if maxLen <= 0 || len([]rune(s)) <= maxLen {
+ return s
+ }
+ r := []rune(s)
+ return string(r[:maxLen-1]) + "…"
+}
+
+func (c *Chat) Size() (int, int) { return c.width, c.height }
+
+func renderContentPanel(styles common.Styles, title, body string, width, maxLines int) string {
+ if strings.TrimSpace(body) == "" {
+ return styles.MsgTimestamp.Render("No output")
+ }
+ clean := common.Escape(strings.ReplaceAll(body, "\r\n", "\n"))
+ lines := strings.Split(clean, "\n")
+ hidden := 0
+ if maxLines > 0 && len(lines) > maxLines {
+ hidden = len(lines) - maxLines
+ lines = lines[:maxLines]
+ }
+ wrapped := make([]string, 0, len(lines)+1)
+ innerW := max(16, width-4)
+ for _, line := range lines {
+ wrapped = append(wrapped, wrap.String(line, innerW))
+ }
+ if hidden > 0 {
+ wrapped = append(wrapped, styles.MsgTimestamp.Render(fmt.Sprintf("… %d more lines", hidden)))
+ }
+ panelBody := strings.Join(wrapped, "\n")
+ if title != "" {
+ panelBody = styles.Key.Render(title) + "\n" + panelBody
+ }
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(common.ColorBorder).
+ Padding(0, 1).
+ Width(width).
+ Render(panelBody)
+}
+
+func cloneMap(in map[string]any) map[string]any {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]any, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func normalizeMap(v any) map[string]any {
+ switch m := v.(type) {
+ case map[string]any:
+ return m
+ case nil:
+ return nil
+ default:
+ b, err := json.Marshal(v)
+ if err != nil {
+ return nil
+ }
+ var out map[string]any
+ if err := json.Unmarshal(b, &out); err != nil {
+ return nil
+ }
+ return out
+ }
+}
+
+func nestedString(v any, keys ...string) string {
+ m := normalizeMap(v)
+ for _, key := range keys {
+ if s, ok := stringAny(m[key]); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+func stringFromMap(m map[string]any, key string) string {
+ if m == nil {
+ return ""
+ }
+ s, _ := stringAny(m[key])
+ return s
+}
+
+func intFromMap(m map[string]any, key string) (int, bool) {
+ if m == nil {
+ return 0, false
+ }
+ switch v := m[key].(type) {
+ case int:
+ return v, true
+ case int32:
+ return int(v), true
+ case int64:
+ return int(v), true
+ case float64:
+ return int(v), true
+ case float32:
+ return int(v), true
+ case json.Number:
+ i, err := v.Int64()
+ return int(i), err == nil
+ default:
+ return 0, false
+ }
+}
+
+func stringAny(v any) (string, bool) {
+ switch x := v.(type) {
+ case string:
+ return x, true
+ case json.Number:
+ return x.String(), true
+ case fmt.Stringer:
+ return x.String(), true
+ case nil:
+ return "", false
+ default:
+ return fmt.Sprintf("%v", x), true
+ }
+}
+
+func prettyPath(path string) string {
+ if path == "" {
+ return ""
+ }
+ clean := filepath.Clean(path)
+ return strings.ReplaceAll(clean, "\\", "/")
+}
+
+func compactPath(path string) string {
+ pretty := prettyPath(path)
+ if pretty == "" {
+ return ""
+ }
+ base := filepath.Base(pretty)
+ if base == "." || base == "/" {
+ return pretty
+ }
+ return base
+}
+
+func prettyJSON(v any) string {
+ if v == nil {
+ return ""
+ }
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+func indentBlock(s, indent string) string {
+ lines := strings.Split(s, "\n")
+ for i, line := range lines {
+ lines[i] = indent + line
+ }
+ return strings.Join(lines, "\n")
+}
+
+func formatDuration(d time.Duration) string {
+ if d < time.Second {
+ return fmt.Sprintf("%dms", d.Milliseconds())
+ }
+ return fmt.Sprintf("%.1fs", d.Seconds())
+}
+
+func toolDisplayName(name string) string {
+ name = strings.ReplaceAll(name, "_", " ")
+ name = strings.ReplaceAll(name, "-", " ")
+ parts := strings.Fields(name)
+ for i, part := range parts {
+ if part == "api" || part == "mcp" {
+ parts[i] = strings.ToUpper(part)
+ continue
+ }
+ parts[i] = strings.ToUpper(part[:1]) + part[1:]
+ }
+ return strings.Join(parts, " ")
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return ""
+ }
+ if idx := strings.IndexByte(s, '\n'); idx >= 0 {
+ return s[:idx]
+ }
+ return s
+}
diff --git a/internal/tui/components/chat_golden_test.go b/internal/tui/components/chat_golden_test.go
new file mode 100644
index 0000000..6424ba4
--- /dev/null
+++ b/internal/tui/components/chat_golden_test.go
@@ -0,0 +1,76 @@
+package components
+
+import (
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+)
+
+var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)
+
+func TestChatGoldenTurnWithTool(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 60, 40)
+ fixed := time.Date(2026, 6, 4, 14, 5, 0, 0, time.UTC)
+
+ c.messages = []msgItem{
+ &userItem{content: "Run the tool", timestamp: fixed},
+ &assistantItem{content: "I will inspect the workspace.", showLabel: true},
+ &toolItem{id: "tool-1", name: "bash", status: "completed", label: "ls -la", metadata: map[string]any{"tool_input": map[string]any{"command": "ls -la"}}, startedAt: fixed, finishedAt: fixed.Add(500 * time.Millisecond)},
+ &assistantItem{content: "The workspace contains 3 files.", showLabel: false},
+ }
+ c.refresh()
+
+ assertGolden(t, "testdata/chat/turn_with_tool.golden", normalizeChatView(c.View()))
+}
+
+func TestChatGoldenCollapsedThinking(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 60, 40)
+ fixed := time.Date(2026, 6, 4, 14, 5, 0, 0, time.UTC)
+ thinking := &thinkingBlock{
+ content: strings.Join([]string{
+ "line 1", "line 2", "line 3", "line 4", "line 5", "line 6",
+ "line 7", "line 8", "line 9", "line 10", "line 11", "line 12",
+ }, "\n"),
+ streaming: false,
+ startedAt: fixed,
+ finishedAt: fixed.Add(1500 * time.Millisecond),
+ collapsed: true,
+ }
+ c.messages = []msgItem{
+ &assistantItem{thinking: thinking, content: "Final answer.", showLabel: true},
+ }
+ c.refresh()
+
+ assertGolden(t, "testdata/chat/collapsed_thinking.golden", normalizeChatView(c.View()))
+}
+
+func assertGolden(t *testing.T, rel string, got string) {
+ t.Helper()
+ path := filepath.Join(rel)
+ if os.Getenv("UPDATE_GOLDEN") == "1" {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir golden dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
+ t.Fatalf("write golden: %v", err)
+ }
+ }
+ wantBytes, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read golden %s: %v", rel, err)
+ }
+ want := strings.TrimSpace(string(wantBytes))
+ if got != want {
+ t.Fatalf("golden mismatch for %s\n\n--- got ---\n%s\n\n--- want ---\n%s", rel, got, want)
+ }
+}
+
+func normalizeChatView(s string) string {
+ s = ansiPattern.ReplaceAllString(s, "")
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ return strings.TrimSpace(s)
+}
diff --git a/internal/tui/components/chat_test.go b/internal/tui/components/chat_test.go
new file mode 100644
index 0000000..b804f8d
--- /dev/null
+++ b/internal/tui/components/chat_test.go
@@ -0,0 +1,383 @@
+package components
+
+import (
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+ "strings"
+ "testing"
+)
+
+func TestChatAddToolProgressSealsAssistantAndCreatesContinuation(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+
+ c.AddUserMessage("user prompt")
+ c.StartAssistantMessage()
+ c.AppendChunk("first answer", false)
+ c.AddToolProgress("tool-1", "bash", "running", "running", nil)
+ c.AppendChunk("second answer", false)
+ c.FinishAssistantMessage(0, 0, "")
+
+ if got := len(c.messages); got != 4 {
+ t.Fatalf("expected 4 chat items, got %d", got)
+ }
+
+ firstAssistant, ok := c.messages[1].(*assistantItem)
+ if !ok {
+ t.Fatalf("expected first assistant item at index 1, got %T", c.messages[1])
+ }
+ if !firstAssistant.showLabel {
+ t.Fatalf("expected first assistant item to keep the label")
+ }
+
+ tool, ok := c.messages[2].(*toolItem)
+ if !ok {
+ t.Fatalf("expected tool item at index 2, got %T", c.messages[2])
+ }
+ if tool.id != "tool-1" {
+ t.Fatalf("expected tool id tool-1, got %q", tool.id)
+ }
+
+ continuation, ok := c.messages[3].(*assistantItem)
+ if !ok {
+ t.Fatalf("expected continuation assistant item at index 3, got %T", c.messages[3])
+ }
+ if continuation.showLabel {
+ t.Fatalf("expected continuation assistant item to omit the label")
+ }
+
+ if got := c.GetLastAssistantText(); got != "first answer\n\nsecond answer" {
+ t.Fatalf("unexpected assistant text: %q", got)
+ }
+}
+
+func TestChatAddToolProgressDropsEmptyAssistantPlaceholder(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+
+ c.StartAssistantMessage()
+ c.AddToolProgress("tool-1", "bash", "running", "running", nil)
+
+ if got := len(c.messages); got != 1 {
+ t.Fatalf("expected 1 chat item after sealing empty assistant, got %d", got)
+ }
+ if _, ok := c.messages[0].(*toolItem); !ok {
+ t.Fatalf("expected only tool item to remain, got %T", c.messages[0])
+ }
+}
+
+func TestThinkingBlockToggleChangesCollapsedState(t *testing.T) {
+ tb := newThinkingBlock()
+ for i := 0; i < 12; i++ {
+ tb.append("line\n")
+ }
+ tb.finish()
+
+ if !tb.collapsed {
+ t.Fatalf("expected thinking block to start collapsed")
+ }
+
+ collapsed := tb.render(common.DefaultStyles(), 50)
+ if want := "8 lines hidden"; !strings.Contains(collapsed, want) {
+ t.Fatalf("expected collapsed render to mention %q, got %q", want, collapsed)
+ }
+
+ tb.toggle()
+ if tb.collapsed {
+ t.Fatalf("expected toggle to expand thinking block")
+ }
+
+ expanded := tb.render(common.DefaultStyles(), 50)
+ if strings.Contains(expanded, "lines hidden") {
+ t.Fatalf("expected expanded render to show all lines, got %q", expanded)
+ }
+}
+
+func TestChatToolSelectionAndDetails(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddToolProgress("tool-1", "read_file", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"file_path": "/tmp/a.txt"},
+ "content": "alpha\nbeta\ngamma",
+ })
+ c.AddToolProgress("tool-2", "bash", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"command": "ls -la"},
+ "stdout": "file-a\nfile-b",
+ })
+
+ if !c.HasSelectedTool() {
+ t.Fatalf("expected latest tool to be selected")
+ }
+ if !c.SelectPrevTool() {
+ t.Fatalf("expected previous tool selection to succeed")
+ }
+ if !c.ToggleSelectedToolExpanded() {
+ t.Fatalf("expected selected tool expansion to succeed")
+ }
+ if !c.ToggleDetails() {
+ t.Fatalf("expected selected tool details to toggle")
+ }
+ if !c.DetailsOpen() {
+ t.Fatalf("expected details pane to be open")
+ }
+ if got := c.DetailView(40, 20); !strings.Contains(got, "a.txt") {
+ t.Fatalf("expected detail view to mention selected file, got %q", got)
+ }
+}
+
+func TestToolSummaryUsesCompactFileName(t *testing.T) {
+ tool := newToolItem("tool-1", "write_file", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"file_path": "/tmp/example/nested/file.txt"},
+ "type": "create",
+ })
+ if got := tool.summaryText(); got != "file.txt · create" {
+ t.Fatalf("unexpected compact summary: %q", got)
+ }
+}
+
+func TestChatToolLineClickTogglesExpansion(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddToolProgress("tool-1", "read_file", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"file_path": "/tmp/a.txt"},
+ "content": "alpha\nbeta",
+ })
+ if len(c.toolRegions) == 0 {
+ t.Fatalf("expected tool regions to be populated")
+ }
+ line := c.toolRegions[0].startLine
+ if !c.HandleMouseDown(0, line) {
+ t.Fatalf("expected mouse down on tool line to be handled")
+ }
+ if got := c.HandleMouseUp(0, line); got != "" {
+ t.Fatalf("expected click on tool line not to copy text, got %q", got)
+ }
+ tool := c.selectedToolItem()
+ if tool == nil || !tool.expanded {
+ t.Fatalf("expected selected tool to toggle expanded on click")
+ }
+}
+
+func TestChatMouseSelectionExtractsPlainText(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddUserMessage("hello world")
+ if !c.HandleMouseDown(0, 0) {
+ t.Fatalf("expected mouse down to start selection")
+ }
+ if !c.HandleMouseDrag(5, 0) {
+ t.Fatalf("expected mouse drag to update selection")
+ }
+ got := c.HandleMouseUp(5, 0)
+ if strings.TrimSpace(got) == "" {
+ t.Fatalf("expected selected text to be copied")
+ }
+}
+
+func TestChatMouseSelectionHighlightsDuringDrag(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddUserMessage("hello world")
+ if !c.HandleMouseDown(0, 0) {
+ t.Fatalf("expected mouse down to start selection")
+ }
+ if !c.HandleMouseDrag(5, 0) {
+ t.Fatalf("expected mouse drag to update selection")
+ }
+ view := c.View()
+ if strings.TrimSpace(view) == "" {
+ t.Fatalf("expected non-empty view during selection")
+ }
+ if view == c.plainContent {
+ t.Fatalf("expected highlighted selection to add styling")
+ }
+}
+
+func TestChatThinkingLineClickTogglesCollapse(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.StartAssistantMessage()
+ for i := 0; i < 12; i++ {
+ c.AppendChunk("thinking line\n", true)
+ }
+ c.FinishAssistantMessage(0, 0, "")
+
+ assistant, ok := c.messages[0].(*assistantItem)
+ if !ok || assistant.thinking == nil {
+ t.Fatalf("expected assistant thinking block")
+ }
+ if len(c.thinkingRegions) == 0 {
+ t.Fatalf("expected thinking regions to be populated")
+ }
+ line := c.thinkingRegions[0].startLine
+ if !c.HandleMouseDown(0, line) {
+ t.Fatalf("expected mouse down on thinking line to be handled")
+ }
+ if got := c.HandleMouseUp(0, line); got != "" {
+ t.Fatalf("expected click on thinking line not to copy text, got %q", got)
+ }
+ if assistant.thinking.collapsed {
+ t.Fatalf("expected thinking block to expand on click")
+ }
+}
+
+func TestThinkingBlockRenderShowsMouseHint(t *testing.T) {
+ tb := newThinkingBlock()
+ for i := 0; i < 12; i++ {
+ tb.append("line\n")
+ }
+ tb.finish()
+ rendered := tb.render(common.DefaultStyles(), 50)
+ if !strings.Contains(rendered, "click to expand") {
+ t.Fatalf("expected mouse hint in thinking footer, got %q", rendered)
+ }
+ if strings.Contains(rendered, "ctrl+t") {
+ t.Fatalf("expected ctrl+t hint to be removed from visible footer, got %q", rendered)
+ }
+}
+
+func TestChatToolDetailsZoneTogglesDetails(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddToolProgress("tool-1", "read_file", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"file_path": "/tmp/a.txt"},
+ "content": "alpha\nbeta",
+ })
+ if len(c.toolRegions) == 0 {
+ t.Fatalf("expected tool regions to be populated")
+ }
+ region := c.toolRegions[0]
+ if !c.HandleMouseDown(region.detailStart, region.startLine) {
+ t.Fatalf("expected mouse down on tool detail zone to be handled")
+ }
+ if got := c.HandleMouseUp(region.detailStart, region.startLine); got != "" {
+ t.Fatalf("expected click on tool detail zone not to copy text, got %q", got)
+ }
+ if !c.DetailsOpen() {
+ t.Fatalf("expected details pane to open from detail click zone")
+ }
+}
+
+func TestChatToolBodyClickSelectsWithoutToggling(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddToolProgress("tool-1", "read_file", "completed", "done", map[string]any{
+ "tool_input": map[string]any{"file_path": "/tmp/a.txt"},
+ "content": "alpha\nbeta",
+ })
+ tool := c.selectedToolItem()
+ if tool == nil {
+ t.Fatalf("expected selected tool")
+ }
+ tool.expanded = false
+ tool.invalidate()
+ c.selectedTool = -1
+ c.detailOpen = false
+ c.refresh()
+ region := c.toolRegions[0]
+ if !c.HandleMouseDown(6, region.startLine) {
+ t.Fatalf("expected mouse down on tool body to be handled")
+ }
+ if got := c.HandleMouseUp(6, region.startLine); got != "" {
+ t.Fatalf("expected click on tool body not to copy text, got %q", got)
+ }
+ if c.selectedToolIndex() < 0 {
+ t.Fatalf("expected tool body click to select the tool")
+ }
+ if c.DetailsOpen() {
+ t.Fatalf("expected tool body click not to toggle details")
+ }
+ if tool.expanded {
+ t.Fatalf("expected tool body click not to expand preview")
+ }
+}
+
+func TestUserItemRenderKeepsMessageInlineWithMarker(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ u := &userItem{content: "hello world"}
+ rendered := u.render(c, 80)
+ lines := strings.Split(rendered, "\n")
+ if len(lines) == 0 || !strings.Contains(lines[0], "hello world") {
+ t.Fatalf("expected first rendered line to include user content, got %q", rendered)
+ }
+}
+
+func TestChatMouseSelectionPersistsAfterRelease(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddUserMessage("hello world")
+ if !c.HandleMouseDown(0, 0) {
+ t.Fatalf("expected mouse down to start selection")
+ }
+ if !c.HandleMouseDrag(5, 0) {
+ t.Fatalf("expected mouse drag to update selection")
+ }
+ got := c.HandleMouseUp(5, 0)
+ if strings.TrimSpace(got) == "" {
+ t.Fatalf("expected selected text to be copied")
+ }
+ if view := c.View(); view == c.renderedContent {
+ t.Fatalf("expected highlighted selection to remain after mouse release")
+ }
+}
+
+func TestApplySelectionStyleReappliesBackgroundAfterReset(t *testing.T) {
+ style := common.DefaultStyles().Selection
+ prefix, _ := selectionRenderParts(style)
+ got := applySelectionStyle("[31mred[0m plain", style)
+ if !strings.Contains(got, "[0m"+prefix+" plain") {
+ t.Fatalf("expected selection background to be reapplied after reset, got %q", got)
+ }
+}
+
+func TestChatMouseDoubleClickSelectsWord(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddUserMessage("hello brave world")
+ if !c.HandleMouseDown(10, 0) {
+ t.Fatalf("expected first mouse down")
+ }
+ _ = c.HandleMouseUp(10, 0)
+ if !c.HandleMouseDown(10, 0) {
+ t.Fatalf("expected second mouse down")
+ }
+ if got := c.selectedText(); got != "brave" {
+ t.Fatalf("expected double-click to select word, got %q", got)
+ }
+}
+
+func TestChatMouseTripleClickSelectsLine(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.AddUserMessage("hello brave world")
+ for i := 0; i < 2; i++ {
+ if !c.HandleMouseDown(10, 0) {
+ t.Fatalf("expected mouse down %d", i+1)
+ }
+ _ = c.HandleMouseUp(10, 0)
+ }
+ if !c.HandleMouseDown(10, 0) {
+ t.Fatalf("expected third mouse down")
+ }
+ if got := c.selectedText(); got != "hello brave world" {
+ t.Fatalf("expected triple-click to select visual line, got %q", got)
+ }
+}
+
+func TestChatMouseDragAutoScrollsAtBottom(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 40, 4)
+ for i := 0; i < 10; i++ {
+ c.AddUserMessage("line")
+ }
+ c.GotoTop()
+ startOffset := c.viewport.YOffset()
+ if !c.HandleMouseDown(0, c.height-1) {
+ t.Fatalf("expected mouse down at bottom visible line")
+ }
+ if !c.HandleMouseDrag(0, c.height+2) {
+ t.Fatalf("expected drag to continue")
+ }
+ if c.viewport.YOffset() <= startOffset {
+ t.Fatalf("expected drag at bottom edge to autoscroll down")
+ }
+}
+
+func TestChatSelectedTextStripsAssistantMarkerLine(t *testing.T) {
+ c := NewChat(common.DefaultStyles(), 80, 20)
+ c.StartAssistantMessage()
+ c.AppendChunk("hello from assistant", false)
+ c.FinishAssistantMessage(0, 0, "")
+ c.HandleMouseDown(0, 0)
+ c.HandleMouseDrag(25, 1)
+ _ = c.HandleMouseUp(25, 1)
+ if got := c.selectedText(); strings.Contains(got, "●") {
+ t.Fatalf("expected copied assistant text to drop visual marker, got %q", got)
+ }
+}
diff --git a/internal/tui/components/command_palette.go b/internal/tui/components/command_palette.go
new file mode 100644
index 0000000..0af5a4b
--- /dev/null
+++ b/internal/tui/components/command_palette.go
@@ -0,0 +1,361 @@
+package components
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+type PaletteItemKind string
+
+const (
+ PaletteSectionKind PaletteItemKind = "section"
+ PaletteActionKind PaletteItemKind = "action"
+ PaletteRouteKind PaletteItemKind = "route"
+ PaletteInfoKind PaletteItemKind = "info"
+)
+
+// PaletteItem is one entry in the settings hub or one of its subsections.
+type PaletteItem struct {
+ Kind PaletteItemKind
+ ID string
+ Name string
+ Shortcut string
+ Desc string
+}
+
+type paletteView string
+
+const (
+ paletteViewRoot paletteView = "root"
+ paletteViewSection paletteView = "section"
+)
+
+// CommandPalette is the ctrl+p overlay. It now behaves as a settings hub with
+// nested sections such as Commands, Providers, Models, Tools, MCP, and Skills.
+type CommandPalette struct {
+ rootItems []PaletteItem
+ sectionItems map[string][]PaletteItem
+ list common.ListState[PaletteItem]
+ styles common.Styles
+ width int
+ height int
+ view paletteView
+ activeSection string
+}
+
+func NewCommandPalette(styles common.Styles) *CommandPalette {
+ p := &CommandPalette{
+ styles: styles,
+ list: common.NewListState(func(item PaletteItem, needle string) bool {
+ return strings.Contains(strings.ToLower(item.Name), needle) ||
+ strings.Contains(strings.ToLower(item.Desc), needle)
+ }),
+ sectionItems: defaultPaletteSections(),
+ }
+ p.rootItems = defaultPaletteRootItems()
+ p.Open("")
+ return p
+}
+
+func defaultPaletteRootItems() []PaletteItem {
+ return []PaletteItem{
+ {Kind: PaletteSectionKind, ID: "commands", Name: "Commands", Desc: "Shortcuts, sessions, copy actions, and app controls"},
+ {Kind: PaletteRouteKind, ID: "providers", Name: "Providers", Shortcut: "ctrl+,", Desc: "Configure API keys and provider credentials"},
+ {Kind: PaletteRouteKind, ID: "models", Name: "Models", Shortcut: "ctrl+m", Desc: "Switch the active AI model"},
+ {Kind: PaletteSectionKind, ID: "tools", Name: "Tools", Desc: "Current tool UX and future browser entry point"},
+ {Kind: PaletteSectionKind, ID: "mcp", Name: "MCP", Desc: "Server usage notes and future management surface"},
+ {Kind: PaletteSectionKind, ID: "skills", Name: "Skills", Desc: "Slash-skill workflow and future skill discovery"},
+ }
+}
+
+func defaultPaletteSections() map[string][]PaletteItem {
+ return map[string][]PaletteItem{
+ "commands": {
+ {Kind: PaletteActionKind, ID: "new-session", Name: "New Session", Shortcut: "ctrl+n", Desc: "Start a fresh conversation"},
+ {Kind: PaletteActionKind, ID: "sessions", Name: "Sessions", Shortcut: "ctrl+s", Desc: "Browse and resume past sessions"},
+ {Kind: PaletteActionKind, ID: "copy-msg", Name: "Copy Last Message", Shortcut: "ctrl+u", Desc: "Copy your last message to clipboard"},
+ {Kind: PaletteActionKind, ID: "quit", Name: "Quit", Shortcut: "ctrl+c", Desc: "Exit Nexus"},
+ },
+ "tools": {
+ {Kind: PaletteInfoKind, ID: "tool-inline", Name: "Inline Tool Previews", Desc: "Expand tools in chat with space or a click on the expander"},
+ {Kind: PaletteInfoKind, ID: "tool-details", Name: "Tool Details Pane", Desc: "Open the right-side tool details pane with o or the details hit target"},
+ {Kind: PaletteInfoKind, ID: "tool-browser", Name: "Dedicated Tool Browser", Desc: "A richer tool browser can land here later without changing the root settings flow"},
+ },
+ "mcp": {
+ {Kind: PaletteInfoKind, ID: "mcp-usage", Name: "MCP During Runs", Desc: "Configured MCP servers are available to the agent during execution"},
+ {Kind: PaletteInfoKind, ID: "mcp-manage", Name: "MCP Management", Desc: "Dedicated MCP browsing and management will land here"},
+ },
+ "skills": {
+ {Kind: PaletteInfoKind, ID: "skill-run", Name: "Run a Skill", Desc: "Type /skill_name directly in chat to invoke a skill"},
+ {Kind: PaletteInfoKind, ID: "skill-discovery", Name: "Skill Discovery", Desc: "A dedicated TUI skill browser can land here once workspace-side discovery is wired"},
+ },
+ }
+}
+
+func (p *CommandPalette) SetSize(width, height int) { p.width = width; p.height = height }
+
+func (p *CommandPalette) Open(filter string) {
+ p.view = paletteViewRoot
+ p.activeSection = ""
+ p.list.ResetItems(p.rootItems, false)
+ p.list.SetFilter(filter)
+}
+
+func (p *CommandPalette) OpenSection(sectionID string) bool {
+ items, ok := p.sectionItems[sectionID]
+ if !ok {
+ return false
+ }
+ p.view = paletteViewSection
+ p.activeSection = sectionID
+ p.list.ResetItems(items, false)
+ p.list.ClearFilter()
+ return true
+}
+
+func (p *CommandPalette) SetSectionItems(sectionID string, items []PaletteItem) {
+ copied := append([]PaletteItem(nil), items...)
+ p.sectionItems[sectionID] = copied
+ if p.view == paletteViewSection && p.activeSection == sectionID {
+ p.list.ResetItems(copied, false)
+ p.list.ClearFilter()
+ }
+}
+
+func (p *CommandPalette) Back() bool {
+ if p.view == paletteViewSection {
+ p.Open("")
+ return true
+ }
+ return false
+}
+
+func (p *CommandPalette) TypeFilter(ch string) { p.list.TypeFilter(ch) }
+func (p *CommandPalette) DeleteFilter() { p.list.DeleteFilter() }
+func (p *CommandPalette) Up() { p.list.Up() }
+func (p *CommandPalette) Down() { p.list.Down() }
+
+func (p *CommandPalette) Selected() *PaletteItem {
+ item, ok := p.list.Selected()
+ if !ok {
+ return nil
+ }
+ return &item
+}
+
+func (p *CommandPalette) View() string {
+ w := p.panelWidth()
+ innerW := w - 4
+ h := p.panelHeight()
+ title := p.styles.BrowserTitle.Render(" " + p.title())
+ filterContent := " search " + p.list.Filter() + "█"
+ filterLine := p.styles.BrowserFilter.Width(innerW).Render(filterContent)
+ sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW))
+
+ rows := p.renderRows(innerW, h)
+ if len(rows) == 0 {
+ rows = append(rows, p.styles.BrowserItem.Render(" no matches"))
+ }
+
+ hint := p.styles.Footer.Render(" ↑↓ navigate enter open ← back esc close")
+ if p.view == paletteViewRoot {
+ hint = p.styles.Footer.Render(" ↑↓ navigate enter open esc close /skill in chat runs a skill")
+ }
+ parts := []string{title, filterLine, sep, "", p.subtitle(innerW), ""}
+ parts = append(parts, rows...)
+ parts = append(parts, "", sep, hint)
+ content := strings.Join(parts, "\n")
+ return p.styles.BrowserBorder.Width(w).Height(h).Render(content)
+}
+
+func (p *CommandPalette) title() string {
+ if p.view == paletteViewRoot {
+ return "Settings"
+ }
+ return "Settings / " + sectionLabel(p.activeSection)
+}
+
+func (p *CommandPalette) subtitle(innerW int) string {
+ var text string
+ if p.view == paletteViewRoot {
+ text = "choose a section"
+ } else {
+ switch p.activeSection {
+ case "commands":
+ text = "run commands and workspace actions"
+ case "tools":
+ text = "tool UX and browsing"
+ case "mcp":
+ text = "MCP usage and future management"
+ case "skills":
+ text = "slash skills and future discovery"
+ default:
+ text = "browse settings"
+ }
+ }
+ return p.styles.MsgTimestamp.Width(innerW).Render(" " + text)
+}
+
+func (p *CommandPalette) renderRows(innerW, panelHeight int) []string {
+ filtered := p.list.FilteredItems()
+ cursor := p.list.Cursor()
+ rows := make([]string, 0, len(filtered)*2)
+ for i, item := range filtered {
+ rows = append(rows, p.renderItem(item, i == cursor, innerW))
+ if i < len(filtered)-1 {
+ rows = append(rows, "")
+ }
+ }
+ return p.visibleRows(rows, panelHeight, innerW)
+}
+
+func (p *CommandPalette) panelWidth() int {
+ return common.Clamp(p.width-8, 54, 96)
+}
+
+func (p *CommandPalette) panelHeight() int {
+ return common.Clamp(p.height-4, 12, 26)
+}
+
+func (p *CommandPalette) visibleRows(rows []string, panelHeight, innerW int) []string {
+ maxRows := panelHeight - 9
+ if maxRows < 1 {
+ maxRows = 1
+ }
+ if len(rows) <= maxRows {
+ return rows
+ }
+ cursorRow := p.list.Cursor() * 2
+ start := cursorRow - maxRows/2
+ if start < 0 {
+ start = 0
+ }
+ if maxStart := len(rows) - maxRows; start > maxStart {
+ start = maxStart
+ }
+ if start%2 != 0 {
+ start--
+ if start < 0 {
+ start = 0
+ }
+ }
+ end := start + maxRows
+ if end > len(rows) {
+ end = len(rows)
+ start = end - maxRows
+ if start < 0 {
+ start = 0
+ }
+ }
+ visible := append([]string(nil), rows[start:end]...)
+ if start > 0 && len(visible) > 0 {
+ visible[0] = p.styles.MsgTimestamp.Width(innerW).Render(" ↑ more")
+ }
+ if end < len(rows) && len(visible) > 0 {
+ visible[len(visible)-1] = p.styles.MsgTimestamp.Width(innerW).Render(" ↓ more")
+ }
+ return visible
+}
+
+func sectionLabel(sectionID string) string {
+ switch sectionID {
+ case "commands":
+ return "Commands"
+ case "tools":
+ return "Tools"
+ case "mcp":
+ return "MCP"
+ case "skills":
+ return "Skills"
+ default:
+ return titleLabel(sectionID)
+ }
+}
+
+func titleLabel(value string) string {
+ if value == "" {
+ return ""
+ }
+ parts := strings.FieldsFunc(value, func(r rune) bool {
+ return r == '-' || r == '_' || r == ' '
+ })
+ if len(parts) == 0 {
+ return strings.ToUpper(value[:1]) + value[1:]
+ }
+ for i, part := range parts {
+ if part == "" {
+ continue
+ }
+ parts[i] = strings.ToUpper(part[:1]) + part[1:]
+ }
+ return strings.Join(parts, " ")
+}
+
+func (p *CommandPalette) renderItem(item PaletteItem, selected bool, innerW int) string {
+ shortcutStr := ""
+ shortcutW := 0
+ if item.Shortcut != "" {
+ shortcutStr = p.styles.Key.Render(item.Shortcut)
+ shortcutW = lipgloss.Width(shortcutStr)
+ }
+
+ nameW := lipgloss.Width(item.Name)
+ leftPad := 4
+ descMax := innerW - leftPad - nameW - 4 - shortcutW
+ if descMax < 0 {
+ descMax = 0
+ }
+
+ desc := item.Desc
+ if len(desc) > descMax {
+ if descMax > 1 {
+ desc = desc[:descMax-1] + "…"
+ } else {
+ desc = ""
+ }
+ }
+
+ indicatorSymbol := "▶ "
+ if item.Kind == PaletteInfoKind {
+ indicatorSymbol = "• "
+ }
+
+ if selected {
+ indicator := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(indicatorSymbol)
+ nameStr := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(item.Name)
+ descStr := p.styles.MsgTimestamp.Render(desc)
+ left := " " + indicator + nameStr
+ if desc != "" {
+ left += " " + descStr
+ }
+ pad := innerW - lipgloss.Width(left) - shortcutW - 2
+ if pad < 1 {
+ pad = 1
+ }
+ line := left + strings.Repeat(" ", pad) + shortcutStr
+ return p.styles.BrowserSelected.Width(innerW).Render(line)
+ }
+
+ prefix := " "
+ if item.Kind == PaletteInfoKind {
+ prefix = " • "
+ }
+ nameStr := lipgloss.NewStyle().Foreground(common.ColorText).Render(item.Name)
+ descStr := p.styles.MsgTimestamp.Render(desc)
+ left := prefix + nameStr
+ if desc != "" {
+ left += " " + descStr
+ }
+ pad := innerW - lipgloss.Width(left) - shortcutW - 2
+ if pad < 1 {
+ pad = 1
+ }
+ line := left + strings.Repeat(" ", pad) + shortcutStr
+ return p.styles.BrowserItem.Width(innerW).Render(line)
+}
+
+func (p *CommandPalette) Centered() string {
+ return common.CenterHorizontally(p.View(), p.width)
+}
diff --git a/internal/tui/components/command_palette_test.go b/internal/tui/components/command_palette_test.go
new file mode 100644
index 0000000..1e637f2
--- /dev/null
+++ b/internal/tui/components/command_palette_test.go
@@ -0,0 +1,94 @@
+package components
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+func TestCommandPaletteViewShowsSettingsRootSections(t *testing.T) {
+ p := NewCommandPalette(common.DefaultStyles())
+ p.SetSize(100, 30)
+ view := p.View()
+ for _, want := range []string{"Settings", "Commands", "Providers", "Models", "Tools", "MCP", "Skills", "choose a section"} {
+ if !strings.Contains(view, want) {
+ t.Fatalf("expected settings root to contain %q, got %q", want, view)
+ }
+ }
+ if strings.Contains(view, "Clear Chat") {
+ t.Fatalf("expected root settings view not to show nested command entries, got %q", view)
+ }
+}
+
+func TestCommandPaletteOpenCommandsSection(t *testing.T) {
+ p := NewCommandPalette(common.DefaultStyles())
+ p.SetSize(100, 30)
+ if !p.OpenSection("commands") {
+ t.Fatalf("expected commands section to open")
+ }
+ view := p.View()
+ for _, want := range []string{"Settings / Commands", "run commands and workspace actions", "New Session", "Quit"} {
+ if !strings.Contains(view, want) {
+ t.Fatalf("expected commands section to contain %q, got %q", want, view)
+ }
+ }
+}
+
+func TestCommandPaletteBackReturnsToRoot(t *testing.T) {
+ p := NewCommandPalette(common.DefaultStyles())
+ if !p.OpenSection("skills") {
+ t.Fatalf("expected skills section to open")
+ }
+ if !p.Back() {
+ t.Fatalf("expected back to return to root settings view")
+ }
+ view := p.View()
+ if !strings.Contains(view, "choose a section") {
+ t.Fatalf("expected root view after back, got %q", view)
+ }
+}
+
+func TestCommandPaletteSetSectionItemsReplacesLiveSection(t *testing.T) {
+ p := NewCommandPalette(common.DefaultStyles())
+ p.SetSectionItems("tools", []PaletteItem{{
+ Kind: PaletteInfoKind,
+ ID: "tool-bash",
+ Name: "bash",
+ Desc: "system · Run shell commands",
+ }})
+ if !p.OpenSection("tools") {
+ t.Fatalf("expected tools section to open")
+ }
+ view := p.View()
+ if !strings.Contains(view, "bash") || !strings.Contains(view, "Run shell commands") {
+ t.Fatalf("expected live tools section to contain replacement item, got %q", view)
+ }
+}
+
+func TestCommandPaletteViewportKeepsPanelScrollable(t *testing.T) {
+ p := NewCommandPalette(common.DefaultStyles())
+ items := make([]PaletteItem, 0, 18)
+ for i := 0; i < 18; i++ {
+ items = append(items, PaletteItem{Kind: PaletteInfoKind, ID: "skill", Name: "skill-" + string(rune('a'+i)), Desc: "desc"})
+ }
+ p.SetSectionItems("skills", items)
+ p.SetSize(120, 18)
+ if !p.OpenSection("skills") {
+ t.Fatalf("expected skills section to open")
+ }
+ view := p.View()
+ if strings.Contains(view, "skill-r") {
+ t.Fatalf("expected overflowing rows to be clipped from initial viewport, got %q", view)
+ }
+ for i := 0; i < 17; i++ {
+ p.Down()
+ }
+ view = p.View()
+ if !strings.Contains(view, "skill-r") {
+ t.Fatalf("expected viewport to scroll to lower rows, got %q", view)
+ }
+ if !strings.Contains(view, "↑ more") {
+ t.Fatalf("expected scrolled viewport to indicate more rows above, got %q", view)
+ }
+}
diff --git a/internal/tui/model/config_panel.go b/internal/tui/components/config_panel.go
similarity index 69%
rename from internal/tui/model/config_panel.go
rename to internal/tui/components/config_panel.go
index 1b1b4bf..a3c5081 100644
--- a/internal/tui/model/config_panel.go
+++ b/internal/tui/components/config_panel.go
@@ -1,24 +1,23 @@
-package model
+package components
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
)
// configPanel is the provider configuration overlay (ctrl+, or via commands palette).
// It has two modes:
// - list mode: shows all providers with their API key status
// - edit mode: shows fields for a selected provider with text inputs
-type configPanel struct {
- styles Styles
+type ConfigPanel struct {
+ styles common.Styles
// ── list mode ──────────────────────────────────────────────────────────
providers []tui.ProviderStatus
- filtered []tui.ProviderStatus
- cursor int
- filter string
+ list common.ListState[tui.ProviderStatus]
// ── edit mode ──────────────────────────────────────────────────────────
editing bool
@@ -37,76 +36,54 @@ type cfgFieldInput struct {
draft string // what the user is currently typing
}
-func newConfigPanel(styles Styles) *configPanel {
- return &configPanel{styles: styles}
+func NewConfigPanel(styles common.Styles) *ConfigPanel {
+ return &ConfigPanel{
+ styles: styles,
+ list: common.NewListState(func(pv tui.ProviderStatus, needle string) bool {
+ return strings.Contains(strings.ToLower(pv.DisplayName), needle) ||
+ strings.Contains(strings.ToLower(pv.Description), needle)
+ }),
+ }
}
-func (p *configPanel) SetSize(w, h int) { p.width = w; p.height = h }
+func (p *ConfigPanel) SetSize(w, h int) { p.width = w; p.height = h }
// SetProviders refreshes the provider list.
-func (p *configPanel) SetProviders(providers []tui.ProviderStatus) {
+func (p *ConfigPanel) SetProviders(providers []tui.ProviderStatus) {
p.providers = providers
- p.cursor = 0
- p.applyFilter()
+ p.list.SetItems(providers)
}
-func (p *configPanel) applyFilter() {
- if p.filter == "" {
- p.filtered = make([]tui.ProviderStatus, len(p.providers))
- copy(p.filtered, p.providers)
- return
- }
- needle := strings.ToLower(p.filter)
- p.filtered = p.filtered[:0]
- for _, pv := range p.providers {
- if strings.Contains(strings.ToLower(pv.DisplayName), needle) ||
- strings.Contains(strings.ToLower(pv.Description), needle) {
- p.filtered = append(p.filtered, pv)
- }
- }
- if p.cursor >= len(p.filtered) {
- p.cursor = max(0, len(p.filtered)-1)
- }
-}
+func (p *ConfigPanel) TypeFilter(ch string) { p.list.TypeFilter(ch) }
+func (p *ConfigPanel) DeleteFilter() { p.list.DeleteFilter() }
-func (p *configPanel) TypeFilter(ch string) { p.filter += ch; p.applyFilter() }
-func (p *configPanel) DeleteFilter() {
- if len(p.filter) > 0 {
- p.filter = p.filter[:len(p.filter)-1]
- p.applyFilter()
- }
-}
-
-func (p *configPanel) Up() {
+func (p *ConfigPanel) Up() {
if p.editing {
if p.fieldCursor > 0 {
p.fieldCursor--
}
- } else {
- if p.cursor > 0 {
- p.cursor--
- }
+ return
}
+ p.list.Up()
}
-func (p *configPanel) Down() {
+func (p *ConfigPanel) Down() {
if p.editing {
if p.fieldCursor < len(p.inputs)-1 {
p.fieldCursor++
}
- } else {
- if p.cursor < len(p.filtered)-1 {
- p.cursor++
- }
+ return
}
+ p.list.Down()
}
// EnterEdit switches to edit mode for the currently selected provider.
-func (p *configPanel) EnterEdit() {
- if p.cursor < 0 || p.cursor >= len(p.filtered) {
+func (p *ConfigPanel) EnterEdit() {
+ selected, ok := p.list.Selected()
+ if !ok {
return
}
- p.editProvider = p.filtered[p.cursor]
+ p.editProvider = selected
p.inputs = make([]cfgFieldInput, len(p.editProvider.Fields))
for i, f := range p.editProvider.Fields {
p.inputs[i] = cfgFieldInput{field: f, draft: ""}
@@ -118,14 +95,14 @@ func (p *configPanel) EnterEdit() {
}
// ExitEdit returns to list mode.
-func (p *configPanel) ExitEdit() {
+func (p *ConfigPanel) ExitEdit() {
p.editing = false
p.statusMsg = ""
// Reload the providers so the list reflects saved state.
}
// TypeChar appends a character to the active field draft.
-func (p *configPanel) TypeChar(ch string) {
+func (p *ConfigPanel) TypeChar(ch string) {
if !p.editing || p.fieldCursor >= len(p.inputs) {
return
}
@@ -134,7 +111,7 @@ func (p *configPanel) TypeChar(ch string) {
}
// DeleteChar removes the last character from the active field draft.
-func (p *configPanel) DeleteChar() {
+func (p *ConfigPanel) DeleteChar() {
if !p.editing || p.fieldCursor >= len(p.inputs) {
return
}
@@ -146,10 +123,10 @@ func (p *configPanel) DeleteChar() {
}
// ToggleReveal shows/hides the secret value in the active field.
-func (p *configPanel) ToggleReveal() { p.showSecret = !p.showSecret }
+func (p *ConfigPanel) ToggleReveal() { p.showSecret = !p.showSecret }
// CurrentFieldDraft returns the current draft text and whether it's a secret field.
-func (p *configPanel) CurrentFieldDraft() (draft string, isSecret bool, fieldKey string) {
+func (p *ConfigPanel) CurrentFieldDraft() (draft string, isSecret bool, fieldKey string) {
if !p.editing || p.fieldCursor >= len(p.inputs) {
return "", false, ""
}
@@ -158,7 +135,7 @@ func (p *configPanel) CurrentFieldDraft() (draft string, isSecret bool, fieldKey
}
// SetSaved marks the current field as successfully saved and updates isSet.
-func (p *configPanel) SetSaved() {
+func (p *ConfigPanel) SetSaved() {
if p.fieldCursor < len(p.inputs) {
p.inputs[p.fieldCursor].field.IsSet = true
p.inputs[p.fieldCursor].draft = ""
@@ -172,16 +149,16 @@ func (p *configPanel) SetSaved() {
}
}
p.statusMsg = "✓ Saved"
- p.applyFilter()
+ p.list.ResetItems(p.providers, true)
}
-func (p *configPanel) SetError(msg string) { p.statusMsg = "✗ " + msg }
-func (p *configPanel) ClearStatus() { p.statusMsg = "" }
+func (p *ConfigPanel) SetError(msg string) { p.statusMsg = "✗ " + msg }
+func (p *ConfigPanel) ClearStatus() { p.statusMsg = "" }
// ─── View ────────────────────────────────────────────────────────────────────
-func (p *configPanel) View() string {
- w := clamp(p.width*4/5, 54, 90)
+func (p *ConfigPanel) View() string {
+ w := common.Clamp(p.width*4/5, 54, 90)
innerW := w - 4
if p.editing {
@@ -190,15 +167,17 @@ func (p *configPanel) View() string {
return p.viewList(w, innerW)
}
-func (p *configPanel) viewList(w, innerW int) string {
+func (p *ConfigPanel) viewList(w, innerW int) string {
title := p.styles.BrowserTitle.Render(" Provider Configuration")
- filterLine := p.styles.BrowserFilter.Width(innerW).Render(" / " + p.filter + "█")
+ filterLine := p.styles.BrowserFilter.Width(innerW).Render(" / " + p.list.Filter() + "█")
sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW))
+ filtered := p.list.FilteredItems()
+ cursor := p.list.Cursor()
var rows []string
- for i, pv := range p.filtered {
+ for i, pv := range filtered {
statusStr := p.providerStatusTag(pv)
- nameStr := lipgloss.NewStyle().Foreground(colorText).Render(pv.DisplayName)
+ nameStr := lipgloss.NewStyle().Foreground(common.ColorText).Render(pv.DisplayName)
descStr := ""
if pv.Description != "" {
maxDesc := innerW - lipgloss.Width(pv.DisplayName) - lipgloss.Width(statusStr) - 10
@@ -210,9 +189,9 @@ func (p *configPanel) viewList(w, innerW int) string {
}
var row string
- if i == p.cursor {
- indicator := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render("▶ ")
- left := " " + indicator + lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(pv.DisplayName)
+ if i == cursor {
+ indicator := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render("▶ ")
+ left := " " + indicator + lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(pv.DisplayName)
if descStr != "" {
left += " " + descStr
}
@@ -251,8 +230,8 @@ func (p *configPanel) viewList(w, innerW int) string {
return p.styles.BrowserBorder.Width(w).Render(strings.Join(parts, "\n"))
}
-func (p *configPanel) viewEdit(w, innerW int) string {
- providerTitle := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(p.editProvider.DisplayName)
+func (p *ConfigPanel) viewEdit(w, innerW int) string {
+ providerTitle := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(p.editProvider.DisplayName)
title := p.styles.BrowserTitle.Render(" " + providerTitle)
sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW))
@@ -270,16 +249,16 @@ func (p *configPanel) viewEdit(w, innerW int) string {
// Label line
var labelStyle lipgloss.Style
if selected {
- labelStyle = lipgloss.NewStyle().Bold(true).Foreground(colorPrimary)
+ labelStyle = lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary)
} else {
- labelStyle = lipgloss.NewStyle().Foreground(colorText)
+ labelStyle = lipgloss.NewStyle().Foreground(common.ColorText)
}
labelLine := " " + labelStyle.Render(inp.field.Label)
if inp.field.EnvVar != "" {
labelLine += " " + p.styles.MsgTimestamp.Render("("+inp.field.EnvVar+")")
}
if inp.field.IsSet && inp.draft == "" {
- labelLine += " " + lipgloss.NewStyle().Foreground(colorGreen).Render("✓ set")
+ labelLine += " " + lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓ set")
}
rows = append(rows, labelLine)
@@ -294,8 +273,8 @@ func (p *configPanel) viewEdit(w, innerW int) string {
if display == "" && inp.field.IsSet {
display = p.styles.MsgTimestamp.Render("(keep existing — type to replace)")
}
- cursor := lipgloss.NewStyle().Foreground(colorPrimary).Render("█")
- valLine = " ▶ " + lipgloss.NewStyle().Foreground(colorText).Render(display) + cursor
+ cursor := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("█")
+ valLine = " ▶ " + lipgloss.NewStyle().Foreground(common.ColorText).Render(display) + cursor
if inp.field.Secret {
revealHint := "ctrl+v: reveal"
if p.showSecret {
@@ -311,7 +290,7 @@ func (p *configPanel) viewEdit(w, innerW int) string {
if inp.field.Secret {
display = strings.Repeat("•", len(inp.draft))
}
- valLine = " " + lipgloss.NewStyle().Foreground(colorMuted).Render(display)
+ valLine = " " + lipgloss.NewStyle().Foreground(common.ColorMuted).Render(display)
} else {
valLine = " " + p.styles.MsgTimestamp.Render("(not set)")
}
@@ -328,9 +307,9 @@ func (p *configPanel) viewEdit(w, innerW int) string {
if p.statusMsg != "" {
var st lipgloss.Style
if strings.HasPrefix(p.statusMsg, "✓") {
- st = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
+ st = lipgloss.NewStyle().Foreground(common.ColorGreen).Bold(true)
} else {
- st = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
+ st = lipgloss.NewStyle().Foreground(common.ColorRed).Bold(true)
}
statusLine = " " + st.Render(p.statusMsg)
}
@@ -347,7 +326,7 @@ func (p *configPanel) viewEdit(w, innerW int) string {
return p.styles.BrowserBorder.Width(w).Render(strings.Join(parts, "\n"))
}
-func (p *configPanel) providerStatusTag(pv tui.ProviderStatus) string {
+func (p *ConfigPanel) providerStatusTag(pv tui.ProviderStatus) string {
if !pv.NeedsKey {
return p.styles.MsgTimestamp.Render("─ local")
}
@@ -363,24 +342,15 @@ func (p *configPanel) providerStatusTag(pv tui.ProviderStatus) string {
}
_ = anySet
if allSet {
- return lipgloss.NewStyle().Foreground(colorGreen).Render("✓ configured")
+ return lipgloss.NewStyle().Foreground(common.ColorGreen).Render("✓ configured")
}
- return lipgloss.NewStyle().Foreground(colorRed).Render("✗ not configured")
+ return lipgloss.NewStyle().Foreground(common.ColorRed).Render("✗ not configured")
}
// centred returns the panel horizontally centred (vertical centring via overlayOn).
-func (p *configPanel) centred() string {
- box := p.View()
- lines := strings.Split(box, "\n")
- boxW := lipgloss.Width(lines[0])
- left := max(0, (p.width-boxW)/2)
- pad := strings.Repeat(" ", left)
- var sb strings.Builder
- for i, l := range lines {
- if i > 0 {
- sb.WriteString("\n")
- }
- sb.WriteString(pad + l)
- }
- return sb.String()
+func (p *ConfigPanel) Centered() string {
+ return common.CenterHorizontally(p.View(), p.width)
}
+
+func (p *ConfigPanel) IsEditing() bool { return p.editing }
+func (p *ConfigPanel) EditedProviderID() string { return p.editProvider.ID }
diff --git a/internal/tui/components/config_panel_test.go b/internal/tui/components/config_panel_test.go
new file mode 100644
index 0000000..47ad5fb
--- /dev/null
+++ b/internal/tui/components/config_panel_test.go
@@ -0,0 +1,78 @@
+package components
+
+import (
+ "testing"
+
+ "github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+func TestConfigPanelFilterAndEnterEdit(t *testing.T) {
+ p := NewConfigPanel(common.DefaultStyles())
+ p.SetProviders([]tui.ProviderStatus{
+ {
+ ID: "anthropic",
+ DisplayName: "Anthropic",
+ Description: "Claude models",
+ NeedsKey: true,
+ Fields: []tui.ProviderFieldStatus{{Key: "api_key", Label: "API Key", Secret: true, Required: true}},
+ },
+ {
+ ID: "openai",
+ DisplayName: "OpenAI",
+ Description: "GPT models",
+ NeedsKey: true,
+ Fields: []tui.ProviderFieldStatus{{Key: "api_key", Label: "API Key", Secret: true, Required: true}},
+ },
+ })
+
+ p.TypeFilter("o")
+ p.TypeFilter("p")
+ p.TypeFilter("e")
+ p.TypeFilter("n")
+
+ if got := len(p.list.FilteredItems()); got != 1 {
+ t.Fatalf("expected 1 filtered provider, got %d", got)
+ }
+
+ p.EnterEdit()
+ if !p.editing {
+ t.Fatalf("expected config panel to enter edit mode")
+ }
+ if got := p.editProvider.ID; got != "openai" {
+ t.Fatalf("expected openai provider in edit mode, got %q", got)
+ }
+ if got := len(p.inputs); got != 1 {
+ t.Fatalf("expected 1 input field, got %d", got)
+ }
+}
+
+func TestConfigPanelSetSavedRefreshesProviderState(t *testing.T) {
+ p := NewConfigPanel(common.DefaultStyles())
+ p.SetProviders([]tui.ProviderStatus{
+ {
+ ID: "openai",
+ DisplayName: "OpenAI",
+ Description: "GPT models",
+ NeedsKey: true,
+ Fields: []tui.ProviderFieldStatus{{Key: "api_key", Label: "API Key", Secret: true, Required: true}},
+ },
+ })
+
+ p.EnterEdit()
+ p.inputs[0].draft = "super-secret"
+ p.SetSaved()
+
+ if got := p.statusMsg; got != "✓ Saved" {
+ t.Fatalf("expected saved status message, got %q", got)
+ }
+ if !p.inputs[0].field.IsSet {
+ t.Fatalf("expected current input field to be marked as set")
+ }
+ if !p.providers[0].Fields[0].IsSet {
+ t.Fatalf("expected backing provider state to be marked as set")
+ }
+ if got := len(p.list.FilteredItems()); got != 1 {
+ t.Fatalf("expected provider list to stay in sync, got %d items", got)
+ }
+}
diff --git a/internal/tui/model/completions.go b/internal/tui/components/file_completions.go
similarity index 79%
rename from internal/tui/model/completions.go
rename to internal/tui/components/file_completions.go
index e62f314..858dff3 100644
--- a/internal/tui/model/completions.go
+++ b/internal/tui/components/file_completions.go
@@ -1,6 +1,7 @@
-package model
+package components
import (
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
"os"
"path/filepath"
"strings"
@@ -10,8 +11,8 @@ import (
// fileCompletions is the @-triggered file picker popup shown above the input.
// When the user types @ the popup opens; typing more chars filters the list.
-type fileCompletions struct {
- styles Styles
+type FileCompletions struct {
+ styles common.Styles
workDir string
items []string // full relative paths from workDir
filtered []string
@@ -21,14 +22,14 @@ type fileCompletions struct {
width int
}
-func newFileCompletions(styles Styles, workDir string) *fileCompletions {
- return &fileCompletions{
+func NewFileCompletions(styles common.Styles, workDir string) *FileCompletions {
+ return &FileCompletions{
styles: styles,
workDir: workDir,
}
}
-func (c *fileCompletions) Open(workDir string) {
+func (c *FileCompletions) Open(workDir string) {
c.workDir = workDir
c.query = ""
c.cursor = 0
@@ -36,21 +37,21 @@ func (c *fileCompletions) Open(workDir string) {
c.load()
}
-func (c *fileCompletions) Close() {
+func (c *FileCompletions) Close() {
c.open = false
c.query = ""
c.cursor = 0
}
-func (c *fileCompletions) IsOpen() bool { return c.open }
+func (c *FileCompletions) IsOpen() bool { return c.open }
-func (c *fileCompletions) TypeChar(ch string) {
+func (c *FileCompletions) TypeChar(ch string) {
c.query += ch
c.cursor = 0
c.filter()
}
-func (c *fileCompletions) Backspace() {
+func (c *FileCompletions) Backspace() {
if len(c.query) > 0 {
c.query = c.query[:len(c.query)-1]
c.cursor = 0
@@ -61,19 +62,19 @@ func (c *fileCompletions) Backspace() {
}
}
-func (c *fileCompletions) Up() {
+func (c *FileCompletions) Up() {
if c.cursor > 0 {
c.cursor--
}
}
-func (c *fileCompletions) Down() {
+func (c *FileCompletions) Down() {
if c.cursor < len(c.filtered)-1 {
c.cursor++
}
}
// Selected returns the currently highlighted item, or "".
-func (c *fileCompletions) Selected() string {
+func (c *FileCompletions) Selected() string {
if c.cursor >= 0 && c.cursor < len(c.filtered) {
return c.filtered[c.cursor]
}
@@ -81,17 +82,17 @@ func (c *fileCompletions) Selected() string {
}
// Query returns the current filter text (what was typed after @).
-func (c *fileCompletions) Query() string { return c.query }
+func (c *FileCompletions) Query() string { return c.query }
-func (c *fileCompletions) SetSize(width int) { c.width = width }
+func (c *FileCompletions) SetSize(width int) { c.width = width }
-func (c *fileCompletions) load() {
+func (c *FileCompletions) load() {
c.items = c.items[:0]
c.walkDir(c.workDir, "", 0)
c.filter()
}
-func (c *fileCompletions) walkDir(base, rel string, depth int) {
+func (c *FileCompletions) walkDir(base, rel string, depth int) {
if depth > 3 {
return
}
@@ -118,7 +119,7 @@ func (c *fileCompletions) walkDir(base, rel string, depth int) {
}
}
-func (c *fileCompletions) filter() {
+func (c *FileCompletions) filter() {
c.filtered = c.filtered[:0]
if c.query == "" {
// Show recent/common files first
@@ -156,7 +157,7 @@ func (c *fileCompletions) filter() {
}
// View renders the completions popup (rendered as a string, placed above the input).
-func (c *fileCompletions) View(inputWidth int) string {
+func (c *FileCompletions) View(inputWidth int) string {
if !c.open {
return ""
}
@@ -202,7 +203,7 @@ func (c *fileCompletions) View(inputWidth int) string {
content := title + "\n" + sep + "\n" + strings.Join(rows, "\n")
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
- BorderForeground(colorPrimary).
+ BorderForeground(common.ColorPrimary).
PaddingLeft(1).PaddingRight(1).
Width(w).
Render(content)
diff --git a/internal/tui/model/model_dialog.go b/internal/tui/components/model_picker.go
similarity index 67%
rename from internal/tui/model/model_dialog.go
rename to internal/tui/components/model_picker.go
index cda1188..aea1687 100644
--- a/internal/tui/model/model_dialog.go
+++ b/internal/tui/components/model_picker.go
@@ -1,4 +1,4 @@
-package model
+package components
import (
"fmt"
@@ -6,81 +6,53 @@ import (
"charm.land/lipgloss/v2"
"github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
)
// modelDialog is the Ctrl+M model selection overlay.
// Models are grouped by provider, with providers shown as headers.
-type modelDialog struct {
- styles Styles
- models []tui.ProviderModel
- filtered []tui.ProviderModel
- filter string
- cursor int // index into filtered[] (headers excluded)
- width int
- height int
+type ModelPicker struct {
+ styles common.Styles
+ models []tui.ProviderModel
+ list common.ListState[tui.ProviderModel]
+ width int
+ height int
}
-func newModelDialog(styles Styles) *modelDialog {
- return &modelDialog{styles: styles}
+func NewModelPicker(styles common.Styles) *ModelPicker {
+ return &ModelPicker{
+ styles: styles,
+ list: common.NewListState(func(m tui.ProviderModel, needle string) bool {
+ return strings.Contains(strings.ToLower(m.DisplayName), needle) ||
+ strings.Contains(strings.ToLower(m.Description), needle) ||
+ strings.Contains(strings.ToLower(m.Provider), needle)
+ }),
+ }
}
-func (d *modelDialog) SetModels(models []tui.ProviderModel) {
+func (d *ModelPicker) SetModels(models []tui.ProviderModel) {
d.models = models
- d.cursor = 0
- d.applyFilter()
+ d.list.SetItems(models)
}
-func (d *modelDialog) SetSize(width, height int) {
+func (d *ModelPicker) SetSize(width, height int) {
d.width = width
d.height = height
}
-func (d *modelDialog) TypeFilter(ch string) { d.filter += ch; d.cursor = 0; d.applyFilter() }
-func (d *modelDialog) DeleteFilter() {
- if len(d.filter) > 0 {
- d.filter = d.filter[:len(d.filter)-1]
- d.cursor = 0
- d.applyFilter()
- }
-}
-func (d *modelDialog) ClearFilter() { d.filter = ""; d.cursor = 0; d.applyFilter() }
-
-func (d *modelDialog) Up() {
- if d.cursor > 0 {
- d.cursor--
- }
-}
-
-func (d *modelDialog) Down() {
- if d.cursor < len(d.filtered)-1 {
- d.cursor++
- }
-}
+func (d *ModelPicker) TypeFilter(ch string) { d.list.TypeFilter(ch) }
+func (d *ModelPicker) DeleteFilter() { d.list.DeleteFilter() }
+func (d *ModelPicker) ClearFilter() { d.list.ClearFilter() }
+func (d *ModelPicker) Up() { d.list.Up() }
+func (d *ModelPicker) Down() { d.list.Down() }
// Selected returns the currently highlighted model, or nil.
-func (d *modelDialog) Selected() *tui.ProviderModel {
- if d.cursor >= 0 && d.cursor < len(d.filtered) {
- m := d.filtered[d.cursor]
- return &m
- }
- return nil
-}
-
-func (d *modelDialog) applyFilter() {
- if d.filter == "" {
- d.filtered = make([]tui.ProviderModel, len(d.models))
- copy(d.filtered, d.models)
- return
- }
- needle := strings.ToLower(d.filter)
- d.filtered = d.filtered[:0]
- for _, m := range d.models {
- if strings.Contains(strings.ToLower(m.DisplayName), needle) ||
- strings.Contains(strings.ToLower(m.Description), needle) ||
- strings.Contains(strings.ToLower(m.Provider), needle) {
- d.filtered = append(d.filtered, m)
- }
+func (d *ModelPicker) Selected() *tui.ProviderModel {
+ m, ok := d.list.Selected()
+ if !ok {
+ return nil
}
+ return &m
}
// prettyProvider returns a display name for a provider identifier.
@@ -120,7 +92,7 @@ type visualRow struct {
}
// buildVisualRows groups filtered models by provider and returns visual rows.
-func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
+func (d *ModelPicker) buildVisualRows(innerW int) []visualRow {
type group struct {
provider string
models []tui.ProviderModel
@@ -128,7 +100,7 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
var groups []group
providerIdx := map[string]int{}
- for _, m := range d.filtered {
+ for _, m := range d.list.FilteredItems() {
key := strings.ToLower(strings.TrimSpace(m.Provider))
if key == "" {
key = "other"
@@ -143,7 +115,7 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
headerStyle := lipgloss.NewStyle().
Bold(true).
- Foreground(colorSecondary).
+ Foreground(common.ColorSecondary).
Padding(0, 1)
var rows []visualRow
@@ -170,7 +142,7 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
name = m.DisplayName
}
- selected := globalIdx == d.cursor
+ selected := globalIdx == d.list.Cursor()
// Build the right-side info (description + context).
info := ""
@@ -188,7 +160,7 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
var text string
if selected {
- nameStr := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(name)
+ nameStr := lipgloss.NewStyle().Bold(true).Foreground(common.ColorPrimary).Render(name)
left := " ▶ " + nameStr
if info != "" {
left += " " + info
@@ -201,7 +173,7 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
left + strings.Repeat(" ", pad) + ctxStr,
)
} else {
- nameStr := lipgloss.NewStyle().Foreground(colorText).Render(name)
+ nameStr := lipgloss.NewStyle().Foreground(common.ColorText).Render(name)
left := " " + nameStr
if info != "" {
left += " " + info
@@ -223,13 +195,13 @@ func (d *modelDialog) buildVisualRows(innerW int) []visualRow {
}
// View renders the model selection panel.
-func (d *modelDialog) View() string {
+func (d *ModelPicker) View() string {
// Width: 80% of terminal, capped at 90, minimum 54.
- w := clamp(d.width*4/5, 54, 90)
+ w := common.Clamp(d.width*4/5, 54, 90)
innerW := w - 4 // account for border + padding
title := d.styles.BrowserTitle.Render(" Switch Model")
- filterLine := d.styles.BrowserFilter.Width(innerW).Render(" / " + d.filter + "█")
+ filterLine := d.styles.BrowserFilter.Width(innerW).Render(" / " + d.list.Filter() + "█")
sep := d.styles.MsgTimestamp.Render(strings.Repeat("─", innerW))
// Build all visual rows.
@@ -238,7 +210,7 @@ func (d *modelDialog) View() string {
// Find the visual position of the selected cursor row.
selectedVR := 0
for i, row := range allRows {
- if row.cursorIdx == d.cursor {
+ if row.cursorIdx == d.list.Cursor() {
selectedVR = i
break
}
@@ -246,7 +218,7 @@ func (d *modelDialog) View() string {
// Determine the scroll window — max lines in the content area.
// Available content height: terminal height minus chrome (title, filter, seps, hint = ~6 lines).
- maxVisible := clamp(d.height-10, 6, 18)
+ maxVisible := common.Clamp(d.height-10, 6, 18)
start := 0
if selectedVR >= maxVisible {
@@ -263,16 +235,17 @@ func (d *modelDialog) View() string {
}
scrollNote := ""
- if len(d.filtered) > 0 {
+ filtered := d.list.FilteredItems()
+ if len(filtered) > 0 {
visible := 0
for _, row := range allRows[start:end] {
if row.cursorIdx >= 0 {
visible++
}
}
- if len(d.filtered) > visible {
+ if len(filtered) > visible {
scrollNote = d.styles.MsgTimestamp.Render(
- fmt.Sprintf(" %d of %d models", d.cursor+1, len(d.filtered)),
+ fmt.Sprintf(" %d of %d models", d.list.Cursor()+1, len(filtered)),
)
}
}
@@ -293,18 +266,6 @@ func (d *modelDialog) View() string {
// centred returns the panel positioned horizontally centred.
// Vertical centering is handled by overlayOn().
-func (d *modelDialog) centred() string {
- box := d.View()
- lines := strings.Split(box, "\n")
- boxW := lipgloss.Width(lines[0]) // use first line (top border) for true width
- left := max(0, (d.width-boxW)/2)
- pad := strings.Repeat(" ", left)
- var sb strings.Builder
- for i, l := range lines {
- if i > 0 {
- sb.WriteString("\n")
- }
- sb.WriteString(pad + l)
- }
- return sb.String()
+func (d *ModelPicker) Centered() string {
+ return common.CenterHorizontally(d.View(), d.width)
}
diff --git a/internal/tui/components/mouse_selection.go b/internal/tui/components/mouse_selection.go
new file mode 100644
index 0000000..aa1de82
--- /dev/null
+++ b/internal/tui/components/mouse_selection.go
@@ -0,0 +1,112 @@
+package components
+
+import "time"
+
+const (
+ doubleClickThreshold = 450 * time.Millisecond
+ clickSlop = 1
+)
+
+type mouseSelection struct {
+ dragging bool
+ moved bool
+ active bool
+
+ startLine int
+ startCol int
+ endLine int
+ endCol int
+
+ lastClickAt time.Time
+ lastClickLine int
+ lastClickCol int
+ lastClickCount int
+}
+
+func (s *mouseSelection) begin(line, col int, now time.Time) int {
+ count := 1
+ if !s.lastClickAt.IsZero() && now.Sub(s.lastClickAt) <= doubleClickThreshold && absInt(line-s.lastClickLine) == 0 && absInt(col-s.lastClickCol) <= clickSlop {
+ count = s.lastClickCount + 1
+ if count > 3 {
+ count = 1
+ }
+ }
+ s.lastClickAt = now
+ s.lastClickLine = line
+ s.lastClickCol = col
+ s.lastClickCount = count
+
+ s.dragging = count == 1
+ s.moved = false
+ s.active = false
+ s.startLine = line
+ s.startCol = col
+ s.endLine = line
+ s.endCol = col
+ return count
+}
+
+func (s *mouseSelection) update(line, col int) {
+ if !s.dragging {
+ return
+ }
+ s.endLine = line
+ s.endCol = col
+ if line != s.startLine || col != s.startCol {
+ s.moved = true
+ }
+}
+
+func (s *mouseSelection) finish() bool {
+ wasMoved := s.moved
+ s.dragging = false
+ s.moved = false
+ if wasMoved {
+ s.active = true
+ }
+ return wasMoved
+}
+
+func (s *mouseSelection) clear() {
+ s.dragging = false
+ s.moved = false
+ s.active = false
+ s.startLine = 0
+ s.startCol = 0
+ s.endLine = 0
+ s.endCol = 0
+}
+
+func (s *mouseSelection) setRange(startLine, startCol, endLine, endCol int) {
+ s.dragging = false
+ s.moved = false
+ s.active = true
+ s.startLine = startLine
+ s.startCol = startCol
+ s.endLine = endLine
+ s.endCol = endCol
+}
+
+func (s *mouseSelection) hasSelection() bool {
+ return s.active || (s.dragging && s.moved)
+}
+
+func (s *mouseSelection) rangeOrInvalid() (int, int, int, int) {
+ if !s.hasSelection() {
+ return -1, -1, -1, -1
+ }
+ startLn, startCo := s.startLine, s.startCol
+ endLn, endCo := s.endLine, s.endCol
+ if endLn < startLn || (endLn == startLn && endCo < startCo) {
+ startLn, endLn = endLn, startLn
+ startCo, endCo = endCo, startCo
+ }
+ return startLn, startCo, endLn, endCo
+}
+
+func absInt(v int) int {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
diff --git a/internal/tui/components/mouse_selection_render.go b/internal/tui/components/mouse_selection_render.go
new file mode 100644
index 0000000..f0c9cc7
--- /dev/null
+++ b/internal/tui/components/mouse_selection_render.go
@@ -0,0 +1,30 @@
+package components
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+)
+
+func selectionRenderParts(style lipgloss.Style) (string, string) {
+ sample := style.Render("x")
+ idx := strings.Index(sample, "x")
+ if idx < 0 {
+ return "", ""
+ }
+ return sample[:idx], sample[idx+1:]
+}
+
+func applySelectionStyle(s string, style lipgloss.Style) string {
+ if s == "" {
+ return ""
+ }
+ prefix, suffix := selectionRenderParts(style)
+ if prefix == "" && suffix == "" {
+ return s
+ }
+ out := prefix + s
+ out = strings.ReplaceAll(out, "[0m", "[0m"+prefix)
+ out = strings.ReplaceAll(out, "[m", "[m"+prefix)
+ return out + suffix
+}
diff --git a/internal/tui/model/permission.go b/internal/tui/components/permission_dialog.go
similarity index 83%
rename from internal/tui/model/permission.go
rename to internal/tui/components/permission_dialog.go
index 7b9c82c..c856935 100644
--- a/internal/tui/model/permission.go
+++ b/internal/tui/components/permission_dialog.go
@@ -1,7 +1,8 @@
-package model
+package components
import (
"fmt"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
"strings"
"charm.land/lipgloss/v2"
@@ -9,32 +10,32 @@ import (
)
// permissionDialog is the modal overlay shown when the agent needs approval.
-type permissionDialog struct {
- styles Styles
+type PermissionDialog struct {
+ styles common.Styles
pending *tui.PromptRequestMsg
width int
height int
}
-func newPermissionDialog(styles Styles) *permissionDialog {
- return &permissionDialog{styles: styles}
+func NewPermissionDialog(styles common.Styles) *PermissionDialog {
+ return &PermissionDialog{styles: styles}
}
-func (p *permissionDialog) SetSize(width, height int) {
+func (p *PermissionDialog) SetSize(width, height int) {
p.width = width
p.height = height
}
-func (p *permissionDialog) SetPending(msg *tui.PromptRequestMsg) {
+func (p *PermissionDialog) SetPending(msg *tui.PromptRequestMsg) {
p.pending = msg
}
-func (p *permissionDialog) HasPending() bool {
+func (p *PermissionDialog) HasPending() bool {
return p.pending != nil
}
// Resolve sends the user's response and clears the pending request.
-func (p *permissionDialog) Resolve(value any, cancelled bool) {
+func (p *PermissionDialog) Resolve(value any, cancelled bool) {
if p.pending == nil {
return
}
@@ -46,7 +47,7 @@ func (p *permissionDialog) Resolve(value any, cancelled bool) {
}
// View renders the permission dialog centred on the screen.
-func (p *permissionDialog) View() string {
+func (p *PermissionDialog) View() string {
if p.pending == nil {
return ""
}
diff --git a/internal/tui/model/session_list.go b/internal/tui/components/session_list.go
similarity index 55%
rename from internal/tui/model/session_list.go
rename to internal/tui/components/session_list.go
index 20b2928..52384ab 100644
--- a/internal/tui/model/session_list.go
+++ b/internal/tui/components/session_list.go
@@ -1,131 +1,90 @@
-package model
+package components
import (
"fmt"
"strings"
"time"
- "charm.land/lipgloss/v2"
"github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
)
// sessionList is the session browser overlay.
-type sessionList struct {
- styles Styles
+type SessionList struct {
+ styles common.Styles
sessions []tui.SessionInfo
- filtered []tui.SessionInfo
- filter string
- cursor int
+ list common.ListState[tui.SessionInfo]
width int
height int
editing bool // whether the filter input has focus
}
-func newSessionList(styles Styles) *sessionList {
- return &sessionList{
- styles: styles,
+func NewSessionList(styles common.Styles) *SessionList {
+ return &SessionList{
+ styles: styles,
+ list: common.NewListState(func(sess tui.SessionInfo, needle string) bool {
+ return strings.Contains(strings.ToLower(sess.ShortID), needle)
+ }),
editing: true,
}
}
-func (s *sessionList) SetSessions(sessions []tui.SessionInfo) {
+func (s *SessionList) SetSessions(sessions []tui.SessionInfo) {
s.sessions = sessions
- s.cursor = 0
- s.applyFilter()
+ s.list.SetItems(sessions)
}
-func (s *sessionList) SetSize(width, height int) {
+func (s *SessionList) SetSize(width, height int) {
s.width = width
s.height = height
}
-func (s *sessionList) TypeFilter(ch string) {
- s.filter += ch
- s.cursor = 0
- s.applyFilter()
-}
-
-func (s *sessionList) DeleteFilter() {
- if len(s.filter) > 0 {
- s.filter = s.filter[:len(s.filter)-1]
- s.cursor = 0
- s.applyFilter()
- }
-}
-
-func (s *sessionList) ClearFilter() {
- s.filter = ""
- s.cursor = 0
- s.applyFilter()
-}
-
-func (s *sessionList) Up() {
- if s.cursor > 0 {
- s.cursor--
- }
-}
-
-func (s *sessionList) Down() {
- if s.cursor < len(s.filtered)-1 {
- s.cursor++
- }
-}
+func (s *SessionList) TypeFilter(ch string) { s.list.TypeFilter(ch) }
+func (s *SessionList) DeleteFilter() { s.list.DeleteFilter() }
+func (s *SessionList) ClearFilter() { s.list.ClearFilter() }
+func (s *SessionList) Up() { s.list.Up() }
+func (s *SessionList) Down() { s.list.Down() }
// Selected returns the session ID at the current cursor position, or "".
-func (s *sessionList) Selected() string {
- if s.cursor >= 0 && s.cursor < len(s.filtered) {
- return s.filtered[s.cursor].ID
+func (s *SessionList) Selected() string {
+ sess, ok := s.list.Selected()
+ if !ok {
+ return ""
}
- return ""
+ return sess.ID
}
// DeleteSelected returns the session ID to delete, if any.
-func (s *sessionList) DeleteSelected() string {
+func (s *SessionList) DeleteSelected() string {
id := s.Selected()
if id == "" {
return ""
}
- // Remove from sessions slice.
+
for i, sess := range s.sessions {
if sess.ID == id {
s.sessions = append(s.sessions[:i], s.sessions[i+1:]...)
break
}
}
- if s.cursor >= len(s.filtered)-1 {
- s.cursor = max(0, len(s.filtered)-2)
- }
- s.applyFilter()
+ s.list.ResetItems(s.sessions, true)
return id
}
-func (s *sessionList) applyFilter() {
- if s.filter == "" {
- s.filtered = make([]tui.SessionInfo, len(s.sessions))
- copy(s.filtered, s.sessions)
- return
- }
- needle := strings.ToLower(s.filter)
- s.filtered = s.filtered[:0]
- for _, sess := range s.sessions {
- if strings.Contains(strings.ToLower(sess.ShortID), needle) {
- s.filtered = append(s.filtered, sess)
- }
- }
-}
-
// View renders the session browser in a box centred on (width, height).
-func (s *sessionList) View() string {
+func (s *SessionList) View() string {
const boxWidth = 60
const maxItems = 10
w := min(boxWidth, s.width-4)
+ filtered := s.list.FilteredItems()
+ cursor := s.list.Cursor()
// Title
title := s.styles.BrowserTitle.Render(" Sessions")
// Filter line
- filterContent := s.filter
+ filterContent := s.list.Filter()
if s.editing {
filterContent += "█" // cursor
}
@@ -135,18 +94,18 @@ func (s *sessionList) View() string {
sep := strings.Repeat("─", w-4)
// Items
- start := max(0, s.cursor-maxItems+1)
- end := min(len(s.filtered), start+maxItems)
+ start := max(0, cursor-maxItems+1)
+ end := min(len(filtered), start+maxItems)
var rows []string
for i := start; i < end; i++ {
- sess := s.filtered[i]
+ sess := filtered[i]
age := formatAge(sess.UpdatedAt)
info := fmt.Sprintf("%s · %s · %d turns", sess.ShortID, age, sess.Turns)
if len(info) > w-4 {
info = info[:w-4]
}
- if i == s.cursor {
+ if i == cursor {
rows = append(rows, s.styles.BrowserSelected.Width(w-2).Render("▶ "+info))
} else {
rows = append(rows, s.styles.BrowserItem.Width(w-2).Render(" "+info))
@@ -154,7 +113,7 @@ func (s *sessionList) View() string {
}
if len(rows) == 0 {
- if s.filter != "" {
+ if s.list.Filter() != "" {
rows = append(rows, s.styles.BrowserItem.Render(" no matches"))
} else {
rows = append(rows, s.styles.BrowserItem.Render(" no sessions yet"))
@@ -178,20 +137,8 @@ func (s *sessionList) View() string {
// centred returns the box horizontally centred.
// Vertical centering is handled by overlayOn().
-func (s *sessionList) centred() string {
- box := s.View()
- boxLines := strings.Split(box, "\n")
- boxW := lipgloss.Width(boxLines[0])
- leftPad := max(0, (s.width-boxW)/2)
- pad := strings.Repeat(" ", leftPad)
- var sb strings.Builder
- for i, line := range boxLines {
- if i > 0 {
- sb.WriteString("\n")
- }
- sb.WriteString(pad + line)
- }
- return sb.String()
+func (s *SessionList) Centered() string {
+ return common.CenterHorizontally(s.View(), s.width)
}
func formatAge(t time.Time) string {
@@ -212,3 +159,5 @@ func formatAge(t time.Time) string {
return t.Format("Jan 2")
}
}
+
+func (s *SessionList) Size() (int, int) { return s.width, s.height }
diff --git a/internal/tui/components/session_list_test.go b/internal/tui/components/session_list_test.go
new file mode 100644
index 0000000..bc3dee0
--- /dev/null
+++ b/internal/tui/components/session_list_test.go
@@ -0,0 +1,70 @@
+package components
+
+import (
+ "testing"
+ "time"
+
+ "github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+func TestSessionListFilterAndDeleteSelected(t *testing.T) {
+ now := time.Now()
+ s := NewSessionList(common.DefaultStyles())
+ s.SetSessions([]tui.SessionInfo{
+ {ID: "sess-1", ShortID: "alpha123", UpdatedAt: now, Turns: 1},
+ {ID: "sess-2", ShortID: "beta456", UpdatedAt: now, Turns: 2},
+ {ID: "sess-3", ShortID: "beta789", UpdatedAt: now, Turns: 3},
+ })
+
+ s.TypeFilter("b")
+ s.TypeFilter("e")
+ s.TypeFilter("t")
+ s.TypeFilter("a")
+
+ if got := len(s.list.FilteredItems()); got != 2 {
+ t.Fatalf("expected 2 filtered sessions, got %d", got)
+ }
+ if got := s.Selected(); got != "sess-2" {
+ t.Fatalf("expected first filtered session to be selected, got %q", got)
+ }
+
+ s.Down()
+ if got := s.Selected(); got != "sess-3" {
+ t.Fatalf("expected second filtered session to be selected after Down, got %q", got)
+ }
+
+ deleted := s.DeleteSelected()
+ if deleted != "sess-3" {
+ t.Fatalf("expected deleted session sess-3, got %q", deleted)
+ }
+
+ if got := len(s.sessions); got != 2 {
+ t.Fatalf("expected 2 sessions after delete, got %d", got)
+ }
+ if got := len(s.list.FilteredItems()); got != 1 {
+ t.Fatalf("expected 1 filtered session after delete, got %d", got)
+ }
+ if got := s.Selected(); got != "sess-2" {
+ t.Fatalf("expected cursor to fall back to sess-2, got %q", got)
+ }
+}
+
+func TestSessionListClearFilterRestoresAllSessions(t *testing.T) {
+ now := time.Now()
+ s := NewSessionList(common.DefaultStyles())
+ s.SetSessions([]tui.SessionInfo{
+ {ID: "sess-1", ShortID: "alpha123", UpdatedAt: now},
+ {ID: "sess-2", ShortID: "beta456", UpdatedAt: now},
+ })
+
+ s.TypeFilter("z")
+ if got := len(s.list.FilteredItems()); got != 0 {
+ t.Fatalf("expected no filtered sessions, got %d", got)
+ }
+
+ s.ClearFilter()
+ if got := len(s.list.FilteredItems()); got != 2 {
+ t.Fatalf("expected all sessions after clearing filter, got %d", got)
+ }
+}
diff --git a/internal/tui/components/skill_completions.go b/internal/tui/components/skill_completions.go
new file mode 100644
index 0000000..59083f6
--- /dev/null
+++ b/internal/tui/components/skill_completions.go
@@ -0,0 +1,211 @@
+package components
+
+import (
+ "sort"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/EngineerProjects/nexus-engine/internal/tui"
+ "github.com/EngineerProjects/nexus-engine/internal/tui/common"
+)
+
+const skillPopupVisible = 3
+
+// SkillCompletions is the /-triggered skill picker shown just above the composer.
+type SkillCompletions struct {
+ styles common.Styles
+ items []tui.SkillInfo
+ filtered []tui.SkillInfo
+ query string
+ cursor int
+ open bool
+}
+
+func NewSkillCompletions(styles common.Styles) *SkillCompletions {
+ return &SkillCompletions{styles: styles}
+}
+
+func (c *SkillCompletions) Sync(items []tui.SkillInfo, input string) {
+ query, ok := skillQuery(input)
+ if !ok {
+ c.Close()
+ return
+ }
+ c.open = true
+ c.query = query
+ c.items = append(c.items[:0], items...)
+ c.filter()
+}
+
+func (c *SkillCompletions) Close() {
+ c.open = false
+ c.query = ""
+ c.cursor = 0
+ c.filtered = c.filtered[:0]
+}
+
+func (c *SkillCompletions) IsOpen() bool { return c.open }
+
+func (c *SkillCompletions) Up() {
+ if c.cursor > 0 {
+ c.cursor--
+ }
+}
+
+func (c *SkillCompletions) Down() {
+ if c.cursor < len(c.filtered)-1 {
+ c.cursor++
+ }
+}
+
+func (c *SkillCompletions) Scroll(delta int) {
+ if delta < 0 {
+ c.Up()
+ return
+ }
+ if delta > 0 {
+ c.Down()
+ }
+}
+
+func (c *SkillCompletions) Selected() string {
+ if c.cursor >= 0 && c.cursor < len(c.filtered) {
+ return "/" + c.filtered[c.cursor].Name
+ }
+ return ""
+}
+
+func (c *SkillCompletions) Width(inputWidth int) int {
+ return min(max(36, inputWidth-10), 84)
+}
+
+func (c *SkillCompletions) Height(inputWidth int) int {
+ if !c.open {
+ return 0
+ }
+ visible := min(len(c.filtered), skillPopupVisible)
+ if visible == 0 {
+ visible = 1
+ }
+ return visible + 2
+}
+
+func (c *SkillCompletions) ClickRow(row int) string {
+ start, end := c.visibleRange()
+ idx := start + row
+ if idx < start || idx >= end || idx >= len(c.filtered) {
+ return ""
+ }
+ c.cursor = idx
+ return "/" + c.filtered[idx].Name
+}
+
+func (c *SkillCompletions) View(inputWidth int) string {
+ if !c.open {
+ return ""
+ }
+ w := c.Width(inputWidth)
+ start, end := c.visibleRange()
+ if len(c.filtered) == 0 {
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(common.ColorPrimary).
+ PaddingLeft(1).PaddingRight(1).
+ Width(w).
+ Render(c.styles.MsgTimestamp.Render("no skills matching /" + c.query))
+ }
+
+ rows := make([]string, 0, end-start)
+ for i := start; i < end; i++ {
+ item := c.filtered[i]
+ name := "/" + item.Name
+ desc := strings.TrimSpace(item.Description)
+ if desc == "" {
+ desc = strings.TrimSpace(item.WhenToUse)
+ }
+ desc = truncateText(desc, max(12, w-lipgloss.Width(name)-10))
+ left := name
+ if desc != "" {
+ left += c.styles.MsgTimestamp.Render(" " + desc)
+ }
+ if i == c.cursor {
+ rows = append(rows, c.styles.BrowserSelected.Width(w-4).Render("> "+left))
+ } else {
+ rows = append(rows, c.styles.BrowserItem.Width(w-4).Render(" "+left))
+ }
+ }
+ content := strings.Join(rows, "\n")
+ return lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(common.ColorPrimary).
+ PaddingLeft(1).PaddingRight(1).
+ Width(w).
+ Render(content)
+}
+
+func (c *SkillCompletions) visibleRange() (int, int) {
+ visible := skillPopupVisible
+ if len(c.filtered) < visible {
+ visible = len(c.filtered)
+ }
+ if visible <= 0 {
+ return 0, 0
+ }
+ start := c.cursor - (visible - 1)
+ if start < 0 {
+ start = 0
+ }
+ maxStart := len(c.filtered) - visible
+ if start > maxStart {
+ start = maxStart
+ }
+ if start < 0 {
+ start = 0
+ }
+ return start, start + visible
+}
+
+func (c *SkillCompletions) filter() {
+ c.filtered = c.filtered[:0]
+ q := strings.ToLower(strings.TrimSpace(c.query))
+ for _, item := range c.items {
+ name := strings.ToLower(item.Name)
+ if q == "" || strings.HasPrefix(name, q) {
+ c.filtered = append(c.filtered, item)
+ }
+ }
+ if len(c.filtered) == 0 && q != "" {
+ for _, item := range c.items {
+ if strings.Contains(strings.ToLower(item.Name), q) {
+ c.filtered = append(c.filtered, item)
+ }
+ }
+ }
+ sort.SliceStable(c.filtered, func(i, j int) bool {
+ return c.filtered[i].Name < c.filtered[j].Name
+ })
+ if c.cursor >= len(c.filtered) {
+ c.cursor = max(0, len(c.filtered)-1)
+ }
+}
+
+func skillQuery(input string) (string, bool) {
+ if input == "" || !strings.HasPrefix(input, "/") {
+ return "", false
+ }
+ if strings.ContainsAny(input, " \t\n") {
+ return "", false
+ }
+ return strings.TrimPrefix(input, "/"), true
+}
+
+func truncateText(s string, limit int) string {
+ if limit <= 0 || lipgloss.Width(s) <= limit {
+ return s
+ }
+ runes := []rune(s)
+ if limit <= 1 || len(runes) <= limit {
+ return string(runes)
+ }
+ return string(runes[:limit-1]) + "…"
+}
diff --git a/internal/tui/components/testdata/chat/collapsed_thinking.golden b/internal/tui/components/testdata/chat/collapsed_thinking.golden
new file mode 100644
index 0000000..072d84e
--- /dev/null
+++ b/internal/tui/components/testdata/chat/collapsed_thinking.golden
@@ -0,0 +1,11 @@
+●
+╭────────────────────────────────────────────────────────╮
+│ … 8 lines hidden │
+│ line 9 │
+│ line 10 │
+│ line 11 │
+│ line 12 │
+╰────────────────────────────────────────────────────────╯
+ Thought for 1.5s click to expand
+
+ Final answer.
diff --git a/internal/tui/components/testdata/chat/turn_with_tool.golden b/internal/tui/components/testdata/chat/turn_with_tool.golden
new file mode 100644
index 0000000..938b40a
--- /dev/null
+++ b/internal/tui/components/testdata/chat/turn_with_tool.golden
@@ -0,0 +1,10 @@
+● > Run the tool
+
+●
+
+ I will inspect the workspace.
+
+▸ ⊞ ✓ Bash done ls -la (500ms)
+
+
+ The workspace contains 3 files.
diff --git a/internal/tui/model/chat.go b/internal/tui/model/chat.go
deleted file mode 100644
index 905e2dd..0000000
--- a/internal/tui/model/chat.go
+++ /dev/null
@@ -1,704 +0,0 @@
-package model
-
-import (
- "fmt"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/viewport"
- "charm.land/glamour/v2"
- "charm.land/lipgloss/v2"
- "github.com/muesli/reflow/wrap"
-)
-
-// ─── Tool icons ───────────────────────────────────────────────────────────────
-
-var toolIcons = map[string]string{
- "bash": "❯",
- "write_file": "✏",
- "edit_file": "✏",
- "apply_patch": "✏",
- "read_file": "◻",
- "list_directory": "◫",
- "glob": "◈",
- "grep": "◈",
- "web_fetch": "◉",
- "web_search": "◉",
- "job_output": "◆",
- "job_kill": "⊗",
- "write_stdin": "❯",
- "create_directory": "◫",
-}
-
-func toolIconFor(name string) string {
- if icon, ok := toolIcons[name]; ok {
- return icon
- }
- return "◆"
-}
-
-// ─── Message item interface ───────────────────────────────────────────────────
-
-// msgItem is the renderable unit in the chat viewport.
-type msgItem interface {
- render(c *chat, width int) string
- isFinished() bool
- invalidate()
-}
-
-// ─── Thinking block ───────────────────────────────────────────────────────────
-
-const (
- thinkTailLines = 10 // lines shown when collapsed
-)
-
-type thinkingBlock struct {
- content string
- streaming bool
- startedAt time.Time
- finishedAt time.Time
- collapsed bool
-
- cacheWidth int
- cacheRender string
-}
-
-func newThinkingBlock() *thinkingBlock {
- return &thinkingBlock{
- streaming: true,
- collapsed: true,
- startedAt: time.Now(),
- }
-}
-
-func (tb *thinkingBlock) append(text string) {
- tb.content += text
- tb.cacheWidth = 0
-}
-
-func (tb *thinkingBlock) finish() {
- tb.streaming = false
- tb.finishedAt = time.Now()
- tb.cacheWidth = 0
-}
-
-func (tb *thinkingBlock) toggle() {
- tb.collapsed = !tb.collapsed
- tb.cacheWidth = 0
-}
-
-func (tb *thinkingBlock) render(styles Styles, width int) string {
- if !tb.streaming && tb.cacheWidth == width && tb.cacheRender != "" {
- return tb.cacheRender
- }
-
- innerW := width - 6
- if innerW < 10 {
- innerW = 10
- }
-
- lines := strings.Split(strings.TrimRight(tb.content, "\n"), "\n")
- var shownLines []string
- var hiddenCount int
-
- if tb.collapsed && len(lines) > thinkTailLines {
- hiddenCount = len(lines) - thinkTailLines
- shownLines = lines[len(lines)-thinkTailLines:]
- } else {
- shownLines = lines
- }
-
- var inner strings.Builder
- if hiddenCount > 0 {
- inner.WriteString(styles.MsgTimestamp.Render(fmt.Sprintf("… %d lines hidden", hiddenCount)))
- inner.WriteString("\n")
- }
- for i, line := range shownLines {
- inner.WriteString(styles.MsgTimestamp.Render(wrap.String(line, innerW)))
- if i < len(shownLines)-1 {
- inner.WriteString("\n")
- }
- }
-
- boxStyle := lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colorBorder).
- Padding(0, 1).
- Width(width - 2)
-
- box := boxStyle.Render(inner.String())
-
- // Footer: duration + toggle hint
- var footParts []string
- if tb.streaming {
- footParts = append(footParts, styles.MsgTimestamp.Render("thinking…"))
- } else {
- dur := tb.finishedAt.Sub(tb.startedAt).Round(100 * time.Millisecond)
- footParts = append(footParts,
- styles.MsgTimestamp.Render(fmt.Sprintf("Thought for %.1fs", dur.Seconds())))
- if tb.collapsed {
- footParts = append(footParts,
- styles.Key.Render("ctrl+t")+" "+styles.Desc.Render("expand"))
- } else {
- footParts = append(footParts,
- styles.Key.Render("ctrl+t")+" "+styles.Desc.Render("collapse"))
- }
- }
- foot := " " + strings.Join(footParts, " ")
-
- result := box + "\n" + foot
- if !tb.streaming {
- tb.cacheWidth = width
- tb.cacheRender = result
- }
- return result
-}
-
-// ─── assistantItem ────────────────────────────────────────────────────────────
-
-type assistantItem struct {
- thinking *thinkingBlock
- content string
- streaming bool
- finishedAt time.Time
-
- // showLabel is true only for the first assistantItem in a turn.
- // Subsequent items (post-tool text) omit the "Nexus" header so the
- // whole turn reads as one continuous agent response.
- showLabel bool
-
- contentCacheWidth int
- contentCacheRender string
-}
-
-func newAssistantItem() *assistantItem {
- return &assistantItem{streaming: true, showLabel: true}
-}
-
-// newContinuationItem creates a follow-up assistant item within the same
-// turn (text that arrives after a tool call). No "Nexus" label — it
-// visually continues from the previous segment.
-func newContinuationItem() *assistantItem {
- return &assistantItem{streaming: true, showLabel: false}
-}
-
-func (a *assistantItem) appendThinking(text string) {
- if text == "" {
- return // never create a thinkingBlock for empty deltas
- }
- if a.thinking == nil {
- a.thinking = newThinkingBlock()
- }
- a.thinking.append(text)
- a.contentCacheWidth = 0
-}
-
-func (a *assistantItem) appendContent(text string) {
- // Seal thinking block when content begins
- if a.thinking != nil && a.thinking.streaming {
- a.thinking.finish()
- }
- a.content += text
- a.contentCacheWidth = 0
-}
-
-func (a *assistantItem) finish() {
- a.streaming = false
- a.finishedAt = time.Now()
- if a.thinking != nil && a.thinking.streaming {
- a.thinking.finish()
- }
- a.contentCacheWidth = 0
-}
-
-func (a *assistantItem) isFinished() bool { return !a.streaming }
-func (a *assistantItem) invalidate() { a.contentCacheWidth = 0 }
-
-func (a *assistantItem) render(c *chat, width int) string {
- var sb strings.Builder
-
- // Only the first item per turn shows the "Nexus" label.
- if a.showLabel {
- sb.WriteString(c.styles.AssistantLabel.Render("Nexus"))
- sb.WriteString("\n")
- }
-
- // Only render thinking if it has actual content.
- if a.thinking != nil && strings.TrimSpace(a.thinking.content) != "" {
- sb.WriteString(a.thinking.render(c.styles, width))
- sb.WriteString("\n")
- }
-
- if a.content != "" {
- var rendered string
- if !a.streaming && a.contentCacheWidth == width && a.contentCacheRender != "" {
- rendered = a.contentCacheRender
- } else {
- var err error
- rendered, err = c.renderer.Render(a.content)
- if err != nil {
- rendered = a.content
- }
- rendered = strings.TrimRight(rendered, "\n")
- if !a.streaming {
- a.contentCacheWidth = width
- a.contentCacheRender = rendered
- }
- }
- sb.WriteString(rendered)
- } else if a.streaming {
- sb.WriteString(c.styles.MsgTimestamp.Render("…"))
- }
-
- return sb.String()
-}
-
-// ─── userItem ────────────────────────────────────────────────────────────────
-
-type userItem struct {
- content string
- timestamp time.Time
- cacheW int
- cacheR string
-}
-
-func (u *userItem) isFinished() bool { return true }
-func (u *userItem) invalidate() { u.cacheW = 0 }
-
-func (u *userItem) render(c *chat, width int) string {
- if u.cacheW == width && u.cacheR != "" {
- return u.cacheR
- }
- header := c.styles.UserLabel.Render("You") + " " +
- c.styles.MsgTimestamp.Render(u.timestamp.Format("15:04"))
- body := c.styles.UserMsg.Render(wrap.String(u.content, width-2))
- r := header + "\n" + body
- u.cacheW = width
- u.cacheR = r
- return r
-}
-
-// ─── toolItem ────────────────────────────────────────────────────────────────
-
-type toolItem struct {
- id string // ToolUseID — unique per call
- name string
- status string // "pending" | "running" | "completed" | "failed" | "done" | "error"
- label string
- startedAt time.Time
- finishedAt time.Time
-
- cacheW int
- cacheR string
-}
-
-func newToolItem(id, name, status, label string) *toolItem {
- return &toolItem{
- id: id,
- name: name,
- status: status,
- label: label,
- startedAt: time.Now(),
- }
-}
-
-func (t *toolItem) isDone() bool {
- return t.status == "completed" || t.status == "failed" ||
- t.status == "done" || t.status == "error"
-}
-
-func (t *toolItem) isFinished() bool { return t.isDone() }
-func (t *toolItem) invalidate() { t.cacheW = 0 }
-
-func (t *toolItem) renderIcon(styles Styles) string {
- switch {
- case t.status == "completed" || t.status == "done":
- return styles.ToolDone.Render("✓")
- case t.status == "failed" || t.status == "error":
- return styles.ToolError.Render("✗")
- default:
- return styles.ToolProgress.Render(toolIconFor(t.name))
- }
-}
-
-func (t *toolItem) render(c *chat, width int) string {
- if t.isDone() && t.cacheW == width && t.cacheR != "" {
- return t.cacheR
- }
-
- icon := t.renderIcon(c.styles)
-
- var nameStyle lipgloss.Style
- switch {
- case t.status == "completed" || t.status == "done":
- nameStyle = c.styles.ToolDone
- case t.status == "failed" || t.status == "error":
- nameStyle = c.styles.ToolError
- default:
- nameStyle = c.styles.ToolProgress
- }
-
- var sb strings.Builder
- sb.WriteString(" ")
- sb.WriteString(icon)
- sb.WriteString(" ")
- sb.WriteString(nameStyle.Render(t.name))
-
- // Label (truncated to fit)
- if t.label != "" && t.label != t.status {
- maxLabelW := width - 30
- if maxLabelW < 10 {
- maxLabelW = 10
- }
- short := truncate(t.label, maxLabelW)
- sb.WriteString(c.styles.MsgTimestamp.Render(" " + short))
- }
-
- // Duration for finished tools
- if t.isDone() && !t.finishedAt.IsZero() {
- d := t.finishedAt.Sub(t.startedAt)
- var durStr string
- if d < time.Second {
- durStr = fmt.Sprintf("%dms", d.Milliseconds())
- } else {
- durStr = fmt.Sprintf("%.1fs", d.Seconds())
- }
- sb.WriteString(c.styles.MsgTimestamp.Render(" (" + durStr + ")"))
- }
-
- r := sb.String()
- if t.isDone() {
- t.cacheW = width
- t.cacheR = r
- }
- return r
-}
-
-// ─── systemItem ──────────────────────────────────────────────────────────────
-
-type systemItem struct {
- content string
-}
-
-func (s *systemItem) isFinished() bool { return true }
-func (s *systemItem) invalidate() {}
-
-func (s *systemItem) render(c *chat, _ int) string {
- return c.styles.MsgTimestamp.Render("─ " + s.content)
-}
-
-// ─── errorItem ───────────────────────────────────────────────────────────────
-
-type errorItem struct {
- content string
-}
-
-func (e *errorItem) isFinished() bool { return true }
-func (e *errorItem) invalidate() {}
-
-func (e *errorItem) render(c *chat, _ int) string {
- return c.styles.ToolError.Render("✗ " + e.content)
-}
-
-// ─── chat ────────────────────────────────────────────────────────────────────
-
-type chat struct {
- styles Styles
- viewport *viewport.Model
- renderer *glamour.TermRenderer
- messages []msgItem
- width int
- height int
- follow bool
-}
-
-func newChat(styles Styles, width, height int) *chat {
- vp := viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
- vp.SetContent("")
- r, _ := glamour.NewTermRenderer(
- glamour.WithEnvironmentConfig(),
- glamour.WithWordWrap(clampInt(width-4, 20, width)),
- )
- return &chat{
- styles: styles,
- viewport: &vp,
- renderer: r,
- follow: true,
- width: width,
- height: height,
- }
-}
-
-func (c *chat) SetSize(width, height int) {
- c.width = width
- c.height = height
- c.viewport.SetWidth(width)
- c.viewport.SetHeight(height)
- if r, err := glamour.NewTermRenderer(
- glamour.WithEnvironmentConfig(),
- glamour.WithWordWrap(clampInt(width-4, 20, width)),
- ); err == nil {
- c.renderer = r
- }
- for _, m := range c.messages {
- m.invalidate()
- }
- c.refresh()
-}
-
-// ─── Public mutation API ──────────────────────────────────────────────────────
-
-func (c *chat) AddUserMessage(text string) {
- c.messages = append(c.messages, &userItem{
- content: text,
- timestamp: time.Now(),
- })
- c.refresh()
-}
-
-func (c *chat) StartAssistantMessage() {
- c.messages = append(c.messages, newAssistantItem())
- c.refresh()
-}
-
-func (c *chat) AppendChunk(text string, isThinking bool) {
- if text == "" {
- return // nothing to render; avoid creating empty items
- }
- for i := len(c.messages) - 1; i >= 0; i-- {
- if a, ok := c.messages[i].(*assistantItem); ok && a.streaming {
- if isThinking {
- a.appendThinking(text)
- } else {
- a.appendContent(text)
- }
- c.refresh()
- return
- }
- }
- // No active streaming item — post-tool text within the same turn.
- // Search backwards; if we see an assistantItem before hitting a userItem
- // (or the start), this is a continuation — omit the "Nexus" label.
- isContinuation := false
- for i := len(c.messages) - 1; i >= 0; i-- {
- if _, ok := c.messages[i].(*userItem); ok {
- break
- }
- if _, ok := c.messages[i].(*assistantItem); ok {
- isContinuation = true
- break
- }
- }
- if isContinuation {
- c.messages = append(c.messages, newContinuationItem())
- } else {
- c.messages = append(c.messages, newAssistantItem())
- }
- c.AppendChunk(text, isThinking)
-}
-
-func (c *chat) FinishAssistantMessage() {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if a, ok := c.messages[i].(*assistantItem); ok && a.streaming {
- a.finish()
- c.refresh()
- return
- }
- }
-}
-
-// AddToolProgress adds or updates a tool call entry.
-// toolUseID is the unique per-call identifier; if empty, falls back to
-// name-based matching on the most recent undone tool with that name.
-func (c *chat) AddToolProgress(toolUseID, toolName, status, label string) {
- // Update existing tool item if found.
- if toolUseID != "" {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if t, ok := c.messages[i].(*toolItem); ok && t.id == toolUseID {
- t.status = status
- t.label = label
- if t.isDone() {
- t.finishedAt = time.Now()
- }
- t.invalidate()
- c.refresh()
- return
- }
- }
- } else {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if t, ok := c.messages[i].(*toolItem); ok && t.name == toolName && !t.isDone() {
- t.status = status
- t.label = label
- if t.isDone() {
- t.finishedAt = time.Now()
- }
- t.invalidate()
- c.refresh()
- return
- }
- }
- }
-
- // New tool: seal the current streaming assistant item so post-tool text
- // appears in a fresh item after this tool entry (crush interleave pattern).
- c.sealActiveAssistant()
-
- c.messages = append(c.messages, newToolItem(toolUseID, toolName, status, label))
- c.refresh()
-}
-
-// sealActiveAssistant closes the last streaming assistantItem so subsequent
-// ChunkMsgs create a new one. An empty item (no content, no thinking) is
-// removed rather than kept as a blank entry.
-func (c *chat) sealActiveAssistant() {
- for i := len(c.messages) - 1; i >= 0; i-- {
- a, ok := c.messages[i].(*assistantItem)
- if !ok || !a.streaming {
- continue
- }
- // Drop placeholder if it has no visible content at all.
- hasThinking := a.thinking != nil && strings.TrimSpace(a.thinking.content) != ""
- if a.content == "" && !hasThinking {
- c.messages = append(c.messages[:i], c.messages[i+1:]...)
- } else {
- a.finish()
- }
- return
- }
-}
-
-func (c *chat) AddError(err error) {
- c.messages = append(c.messages, &errorItem{content: err.Error()})
- c.refresh()
-}
-
-func (c *chat) AddSystem(text string) {
- c.messages = append(c.messages, &systemItem{content: text})
- c.refresh()
-}
-
-func (c *chat) Clear() {
- c.messages = c.messages[:0]
- c.refresh()
-}
-
-// GetLastAssistantText returns the plain-text content of the most recent
-// completed assistant turn (all segments concatenated, no markdown symbols).
-func (c *chat) GetLastAssistantText() string {
- var parts []string
- inCurrentTurn := false
- for i := len(c.messages) - 1; i >= 0; i-- {
- switch m := c.messages[i].(type) {
- case *userItem:
- if inCurrentTurn {
- // Reached the user message that started this turn — stop.
- goto done
- }
- case *assistantItem:
- inCurrentTurn = true
- if m.content != "" {
- parts = append([]string{m.content}, parts...)
- }
- }
- }
-done:
- return strings.Join(parts, "\n\n")
-}
-
-// GetLastUserText returns the text of the most recent user message.
-func (c *chat) GetLastUserText() string {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if u, ok := c.messages[i].(*userItem); ok {
- return u.content
- }
- }
- return ""
-}
-
-// ToggleThinking toggles the collapse state of the most recent thinking block.
-func (c *chat) ToggleThinking() {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if a, ok := c.messages[i].(*assistantItem); ok {
- if a.thinking != nil {
- a.thinking.toggle()
- c.refresh()
- }
- return
- }
- }
-}
-
-// HasThinking reports whether the most recent assistant item has a thinking block.
-func (c *chat) HasThinking() bool {
- for i := len(c.messages) - 1; i >= 0; i-- {
- if a, ok := c.messages[i].(*assistantItem); ok {
- return a.thinking != nil
- }
- }
- return false
-}
-
-// ─── Scroll ──────────────────────────────────────────────────────────────────
-
-func (c *chat) ScrollUp(n int) { c.follow = false; c.viewport.ScrollUp(n) }
-func (c *chat) ScrollDown(n int) { c.viewport.ScrollDown(n); c.follow = c.viewport.AtBottom() }
-func (c *chat) PageUp() { c.follow = false; c.viewport.HalfPageUp() }
-func (c *chat) PageDown() { c.viewport.HalfPageDown(); c.follow = c.viewport.AtBottom() }
-func (c *chat) GotoTop() { c.follow = false; c.viewport.GotoTop() }
-func (c *chat) GotoBottom() { c.follow = true; c.viewport.GotoBottom() }
-
-func (c *chat) View() string { return c.viewport.View() }
-
-// ─── Internal ────────────────────────────────────────────────────────────────
-
-func (c *chat) refresh() {
- var sb strings.Builder
- lastWasTool := false
- wroteAny := false
- for _, item := range c.messages {
- rendered := item.render(c, c.width)
- if rendered == "" {
- continue // skip invisible items; no separator either
- }
- if wroteAny {
- _, currIsTool := item.(*toolItem)
- if lastWasTool && currIsTool {
- sb.WriteString("\n") // consecutive tools: no blank line
- } else {
- sb.WriteString("\n\n") // all other boundaries: blank line
- }
- }
- sb.WriteString(rendered)
- _, lastWasTool = item.(*toolItem)
- wroteAny = true
- }
- c.viewport.SetContent(sb.String())
- if c.follow {
- c.viewport.GotoBottom()
- }
-}
-
-// ─── Helpers ─────────────────────────────────────────────────────────────────
-
-func headerLine(style lipgloss.Style, width int) string {
- return style.Render(strings.Repeat("─", max(0, width)))
-}
-
-// clampInt clamps v to [lo, hi].
-func clampInt(v, lo, hi int) int {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
-}
-
-func truncate(s string, maxLen int) string {
- if maxLen <= 0 || len(s) <= maxLen {
- return s
- }
- return s[:maxLen-1] + "…"
-}
diff --git a/internal/tui/model/commands.go b/internal/tui/model/commands.go
deleted file mode 100644
index 33ff062..0000000
--- a/internal/tui/model/commands.go
+++ /dev/null
@@ -1,333 +0,0 @@
-package model
-
-import (
- "strings"
-
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
-)
-
-// paletteItem is a single entry in the commands palette.
-type paletteItem struct {
- id string
- name string
- shortcut string
- desc string
- // action receives a pointer to the Model so it can mutate state and return a Cmd.
- // The palette is already closed before action is called.
- action func(m *Model) tea.Cmd
-}
-
-// commandPalette is the ctrl+p overlay listing all actions and slash commands.
-type commandPalette struct {
- items []paletteItem
- filtered []paletteItem
- selected int
- filter string
- styles Styles
- width int
- height int
-}
-
-func newCommandPalette(styles Styles) *commandPalette {
- p := &commandPalette{styles: styles}
- p.items = defaultPaletteItems()
- p.filtered = make([]paletteItem, len(p.items))
- copy(p.filtered, p.items)
- return p
-}
-
-func defaultPaletteItems() []paletteItem {
- return []paletteItem{
- {
- id: "new-session",
- name: "New Session",
- shortcut: "ctrl+n",
- desc: "Start a fresh conversation",
- action: func(m *Model) tea.Cmd {
- return m.createSession()
- },
- },
- {
- id: "sessions",
- name: "Sessions",
- shortcut: "ctrl+s",
- desc: "Browse and resume past sessions",
- action: func(m *Model) tea.Cmd {
- m.state = stateSessions
- return m.loadSessions()
- },
- },
- {
- id: "model",
- name: "Switch Model",
- shortcut: "ctrl+m",
- desc: "Change the active AI model",
- action: func(m *Model) tea.Cmd {
- m.returnState = stateCommands
- m.state = stateModelSelect
- m.modelSelect.ClearFilter()
- return m.listModels()
- },
- },
- {
- id: "thinking",
- name: "Toggle Thinking",
- shortcut: "ctrl+t",
- desc: "Expand or collapse the thinking block",
- action: func(m *Model) tea.Cmd {
- m.chat.ToggleThinking()
- return nil
- },
- },
- {
- id: "select",
- name: "Select Mode",
- shortcut: "ctrl+e",
- desc: "Enable native mouse text selection",
- action: func(m *Model) tea.Cmd {
- m.selectMode = !m.selectMode
- return nil
- },
- },
- {
- id: "copy-msg",
- name: "Copy Last Message",
- shortcut: "ctrl+u",
- desc: "Copy your last message to clipboard",
- action: func(m *Model) tea.Cmd {
- text := m.chat.GetLastUserText()
- if text != "" {
- return m.copyToClipboard(text, "Message copied")
- }
- return nil
- },
- },
- {
- id: "provider-config",
- name: "Provider Config",
- shortcut: "ctrl+,",
- desc: "Configure API keys and providers",
- action: func(m *Model) tea.Cmd {
- m.state = stateProviderConfig
- return m.loadProviderConfig()
- },
- },
- {
- id: "clear",
- name: "/clear",
- shortcut: "",
- desc: "Clear the chat display",
- action: func(m *Model) tea.Cmd {
- m.chat.Clear()
- if m.activeSession != "" {
- m.chat.AddSystem("Chat cleared")
- }
- return nil
- },
- },
- {
- id: "quit",
- name: "Quit",
- shortcut: "ctrl+c",
- desc: "Exit Nexus",
- action: func(m *Model) tea.Cmd {
- m.cancel()
- return tea.Quit
- },
- },
- }
-}
-
-func (p *commandPalette) SetSize(width, height int) {
- p.width = width
- p.height = height
-}
-
-// Open resets and optionally pre-fills the filter.
-func (p *commandPalette) Open(filter string) {
- p.filter = filter
- p.selected = 0
- p.applyFilter()
-}
-
-func (p *commandPalette) TypeFilter(ch string) {
- p.filter += ch
- p.selected = 0
- p.applyFilter()
-}
-
-func (p *commandPalette) DeleteFilter() {
- if len(p.filter) > 0 {
- p.filter = p.filter[:len(p.filter)-1]
- p.selected = 0
- p.applyFilter()
- }
-}
-
-func (p *commandPalette) Up() {
- if p.selected > 0 {
- p.selected--
- }
-}
-
-func (p *commandPalette) Down() {
- if p.selected < len(p.filtered)-1 {
- p.selected++
- }
-}
-
-// Execute runs the selected item's action against m and returns the Cmd.
-func (p *commandPalette) Execute(m *Model) tea.Cmd {
- if p.selected < 0 || p.selected >= len(p.filtered) {
- return nil
- }
- return p.filtered[p.selected].action(m)
-}
-
-func (p *commandPalette) applyFilter() {
- if p.filter == "" {
- p.filtered = make([]paletteItem, len(p.items))
- copy(p.filtered, p.items)
- return
- }
- needle := strings.ToLower(p.filter)
- p.filtered = p.filtered[:0]
- for _, item := range p.items {
- if strings.Contains(strings.ToLower(item.name), needle) ||
- strings.Contains(strings.ToLower(item.desc), needle) {
- p.filtered = append(p.filtered, item)
- }
- }
- if p.selected >= len(p.filtered) {
- p.selected = max(0, len(p.filtered)-1)
- }
-}
-
-// View renders the palette box.
-func (p *commandPalette) View() string {
- // Match model dialog width: 80% of terminal, capped at 90, minimum 54.
- w := clamp(p.width*4/5, 54, 90)
- // innerW is the usable content width within the border+padding.
- // BrowserBorder has PaddingLeft(1)+PaddingRight(1) included in Width(w),
- // so the content width passed to inner elements is w-2. We use w-4 to
- // leave a small margin and match the model dialog pattern.
- innerW := w - 4
-
- title := p.styles.BrowserTitle.Render(" Commands")
-
- // Filter line — same width calc as model dialog.
- filterContent := " / " + p.filter + "█"
- filterLine := p.styles.BrowserFilter.Width(innerW).Render(filterContent)
-
- // Separator — use innerW (not innerW+2) so it never exceeds the actual
- // content area regardless of how lipgloss v2 interprets Width(w).
- sep := p.styles.MsgTimestamp.Render(strings.Repeat("─", innerW))
-
- // Build item rows (one per item, blank line between items for readability).
- var rows []string
- for i, item := range p.filtered {
- row := p.renderItem(item, i == p.selected, innerW)
- rows = append(rows, row)
- // Blank spacer between items (not after the last one).
- if i < len(p.filtered)-1 {
- rows = append(rows, "")
- }
- }
- if len(rows) == 0 {
- rows = append(rows, p.styles.BrowserItem.Render(" no matches"))
- }
-
- hint := p.styles.Footer.Render(" ↑↓ navigate enter confirm esc close")
-
- parts := []string{title, filterLine, sep, ""}
- parts = append(parts, rows...)
- parts = append(parts, "", sep, hint)
-
- content := strings.Join(parts, "\n")
- return p.styles.BrowserBorder.Width(w).Render(content)
-}
-
-// renderItem renders a single command item row.
-func (p *commandPalette) renderItem(item paletteItem, selected bool, innerW int) string {
- // Shortcut column (right side) — render with key style.
- shortcutStr := ""
- shortcutW := 0
- if item.shortcut != "" {
- shortcutStr = p.styles.Key.Render(item.shortcut)
- shortcutW = lipgloss.Width(shortcutStr)
- }
-
- // Name column.
- nameW := lipgloss.Width(item.name)
-
- // Description fills the middle — compute available space.
- // Layout: " ▶ " (4) + name + " " (2) + desc + " " (2) + shortcut
- leftPad := 4 // " ▶ " or " "
- descMax := innerW - leftPad - nameW - 4 - shortcutW
- if descMax < 0 {
- descMax = 0
- }
-
- desc := item.desc
- if len(desc) > descMax {
- if descMax > 1 {
- desc = desc[:descMax-1] + "…"
- } else {
- desc = ""
- }
- }
-
- if selected {
- indicator := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render("▶ ")
- nameStr := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(item.name)
- descStr := p.styles.MsgTimestamp.Render(desc)
-
- left := " " + indicator + nameStr
- if desc != "" {
- left += " " + descStr
- }
-
- // Pad to push shortcut to the right.
- pad := innerW - lipgloss.Width(left) - shortcutW - 2
- if pad < 1 {
- pad = 1
- }
- line := left + strings.Repeat(" ", pad) + shortcutStr
- return p.styles.BrowserSelected.Width(innerW).Render(line)
- }
-
- nameStr := lipgloss.NewStyle().Foreground(colorText).Render(item.name)
- descStr := p.styles.MsgTimestamp.Render(desc)
-
- left := " " + nameStr
- if desc != "" {
- left += " " + descStr
- }
-
- pad := innerW - lipgloss.Width(left) - shortcutW - 2
- if pad < 1 {
- pad = 1
- }
- line := left + strings.Repeat(" ", pad) + shortcutStr
- return p.styles.BrowserItem.Width(innerW).Render(line)
-}
-
-// centred returns the palette positioned horizontally centred.
-// Vertical centering is handled by overlayOn().
-func (p *commandPalette) centred() string {
- box := p.View()
- lines := strings.Split(box, "\n")
- // Use the first line (top border) to measure the true rendered width.
- boxW := lipgloss.Width(lines[0])
- left := max(0, (p.width-boxW)/2)
- pad := strings.Repeat(" ", left)
- var sb strings.Builder
- for i, l := range lines {
- if i > 0 {
- sb.WriteString("\n")
- }
- sb.WriteString(pad + l)
- }
- return sb.String()
-}
diff --git a/internal/tui/model/styles.go b/internal/tui/model/styles.go
deleted file mode 100644
index fe66a68..0000000
--- a/internal/tui/model/styles.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package model
-
-import "charm.land/lipgloss/v2"
-
-// Palette — orange / grey accent matching the Nexus logo.
-// lipgloss/v2 Color returns an interface, so these must be var not const.
-var (
- colorPrimary = lipgloss.Color("#E8630A") // orange
- colorSecondary = lipgloss.Color("#FF8C42") // lighter orange
- colorMuted = lipgloss.Color("#6B7280") // grey
- colorBorder = lipgloss.Color("#374151") // dark border
- colorText = lipgloss.Color("#F9FAFB") // near-white text
- colorGreen = lipgloss.Color("#10B981")
- colorRed = lipgloss.Color("#EF4444")
- colorYellow = lipgloss.Color("#F59E0B")
- colorBlue = lipgloss.Color("#3B82F6")
- colorUserMsg = lipgloss.Color("#A5B4FC") // lavender for user messages
-)
-
-// Styles groups all lipgloss styles used by the TUI.
-type Styles struct {
- // Header
- Logo lipgloss.Style
- HeaderModel lipgloss.Style
- HeaderSep lipgloss.Style
- HeaderID lipgloss.Style
- HeaderBusy lipgloss.Style
- HeaderReady lipgloss.Style
-
- // Chat
- UserLabel lipgloss.Style
- AssistantLabel lipgloss.Style
- UserMsg lipgloss.Style
- MsgTimestamp lipgloss.Style
- ToolProgress lipgloss.Style
- ToolDone lipgloss.Style
- ToolError lipgloss.Style
- ErrorMsg lipgloss.Style
-
- // Input
- InputBorder lipgloss.Style
- InputPrompt lipgloss.Style
- InputPlaceholder lipgloss.Style
-
- // Session browser
- BrowserBorder lipgloss.Style
- BrowserTitle lipgloss.Style
- BrowserItem lipgloss.Style
- BrowserSelected lipgloss.Style
- BrowserFilter lipgloss.Style
-
- // Permission dialog
- PermBorder lipgloss.Style
- PermTitle lipgloss.Style
- PermBody lipgloss.Style
- PermYes lipgloss.Style
- PermNo lipgloss.Style
- PermAlways lipgloss.Style
-
- // Footer
- Footer lipgloss.Style
- Key lipgloss.Style
- Desc lipgloss.Style
-}
-
-// DefaultStyles returns the theme used by the TUI.
-func DefaultStyles() Styles {
- s := Styles{}
-
- // Header
- s.Logo = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorPrimary)
- s.HeaderModel = lipgloss.NewStyle().
- Foreground(colorMuted)
- s.HeaderSep = lipgloss.NewStyle().
- Foreground(colorBorder)
- s.HeaderID = lipgloss.NewStyle().
- Foreground(colorMuted).
- Faint(true)
- s.HeaderBusy = lipgloss.NewStyle().
- Foreground(colorYellow)
- s.HeaderReady = lipgloss.NewStyle().
- Foreground(colorGreen)
-
- // Chat
- s.UserLabel = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorUserMsg)
- s.AssistantLabel = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorSecondary)
- s.UserMsg = lipgloss.NewStyle().
- Foreground(colorText)
- s.MsgTimestamp = lipgloss.NewStyle().
- Foreground(colorMuted).
- Faint(true)
- s.ToolProgress = lipgloss.NewStyle().
- Foreground(colorYellow)
- s.ToolDone = lipgloss.NewStyle().
- Foreground(colorGreen)
- s.ToolError = lipgloss.NewStyle().
- Foreground(colorRed)
- s.ErrorMsg = lipgloss.NewStyle().
- Foreground(colorRed).
- Bold(true)
-
- // Input
- s.InputBorder = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colorBorder).
- PaddingLeft(1).PaddingRight(1)
- s.InputPrompt = lipgloss.NewStyle().
- Foreground(colorPrimary).
- Bold(true)
-
- // Session browser
- s.BrowserBorder = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colorPrimary).
- PaddingLeft(1).PaddingRight(1)
- s.BrowserTitle = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorPrimary).
- Padding(0, 1)
- s.BrowserItem = lipgloss.NewStyle().
- Foreground(colorText).
- Padding(0, 1)
- s.BrowserSelected = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorPrimary).
- Background(lipgloss.Color("#1F2937")).
- Padding(0, 1)
- s.BrowserFilter = lipgloss.NewStyle().
- Foreground(colorText).
- Padding(0, 1)
-
- // Permission dialog
- s.PermBorder = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- BorderForeground(colorYellow).
- PaddingLeft(2).PaddingRight(2).
- PaddingTop(1).PaddingBottom(1)
- s.PermTitle = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorYellow)
- s.PermBody = lipgloss.NewStyle().
- Foreground(colorText)
- s.PermYes = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorGreen)
- s.PermNo = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorRed)
- s.PermAlways = lipgloss.NewStyle().
- Bold(true).
- Foreground(colorBlue)
-
- // Footer
- s.Footer = lipgloss.NewStyle().
- Foreground(colorMuted)
- s.Key = lipgloss.NewStyle().
- Foreground(colorPrimary).
- Bold(true)
- s.Desc = lipgloss.NewStyle().
- Foreground(colorMuted)
-
- return s
-}
diff --git a/internal/tui/model/ui.go b/internal/tui/model/ui.go
deleted file mode 100644
index 02b621a..0000000
--- a/internal/tui/model/ui.go
+++ /dev/null
@@ -1,1008 +0,0 @@
-// Package model implements the BubbleTea TUI for nexus-engine, adapted from
-// Charm's crush project architecture (BubbleTea state machine, workspace
-// abstraction, draw cache, permission dialog, session browser).
-package model
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "charm.land/bubbles/v2/spinner"
- "charm.land/bubbles/v2/textarea"
- tea "charm.land/bubbletea/v2"
- "charm.land/lipgloss/v2"
- "github.com/EngineerProjects/nexus-engine/internal/tui"
- clipboard "github.com/atotto/clipboard"
-)
-
-type uiState uint8
-
-const (
- stateWelcome uiState = iota
- stateChat
- stateSessions
- statePermission
- stateModelSelect
- stateCommands
- stateProviderConfig
-)
-
-// uiFocus mirrors crush's focus model: editor has the cursor / main lets
-// the user scroll chat with arrow keys.
-type uiFocus uint8
-
-const (
- uiFocusEditor uiFocus = iota // textarea is active (default)
- uiFocusMain // chat list is scrollable with arrow keys
-)
-
-const (
- headerHeight = 1
- footerHeight = 1
- inputMinH = 3
- inputMaxH = 7
- inputPadding = 2
-)
-
-// Model is the top-level BubbleTea model for nexus-engine's TUI.
-type Model struct {
- workspace tui.Workspace
- ctx context.Context
- cancel context.CancelFunc
-
- state uiState
- keys KeyMap
- styles Styles
-
- width int
- height int
-
- chat *chat
- sessions *sessionList
- permission *permissionDialog
- modelSelect *modelDialog
- commands *commandPalette
- configPanel *configPanel
- completions *fileCompletions
- attachments *attachments
- input textarea.Model
- spinner spinner.Model
-
- focus uiFocus
- busy bool
- activeSession string
- lastErr error
- permInput string
- copyNotice string // transient "Copied!" message shown in footer
- selectMode bool // when true: mouse capture disabled so terminal handles selection
- returnState uiState // state to restore when pressing ← from a sub-dialog
-}
-
-func New(ws tui.Workspace, ctx context.Context) Model {
- ctx, cancel := context.WithCancel(ctx)
-
- styles := DefaultStyles()
- keys := DefaultKeys()
-
- ta := textarea.New()
- ta.Placeholder = "Type a message… (enter to send, shift+enter for newline)"
- ta.ShowLineNumbers = false
- ta.CharLimit = 0
- ta.SetWidth(80)
- ta.SetHeight(inputMinH)
- // Don't call Focus() here — do it in Init() so the Cmd runs properly.
-
- sp := spinner.New()
- sp.Spinner = spinner.Dot
- sp.Style = lipgloss.NewStyle().Foreground(colorYellow)
-
- return Model{
- workspace: ws,
- ctx: ctx,
- cancel: cancel,
- state: stateWelcome,
- focus: uiFocusEditor,
- keys: keys,
- styles: styles,
- chat: newChat(styles, 80, 20),
- sessions: newSessionList(styles),
- permission: newPermissionDialog(styles),
- modelSelect: newModelDialog(styles),
- commands: newCommandPalette(styles),
- configPanel: newConfigPanel(styles),
- completions: newFileCompletions(styles, ws.WorkingDir()),
- attachments: newAttachments(styles),
- input: ta,
- spinner: sp,
- }
-}
-
-// ─── BubbleTea v2 interface ───────────────────────────────────────────────────
-
-func (m Model) Init() tea.Cmd {
- // Focus() in bubbles/v2 returns a Cmd that sets up the cursor — must run.
- return tea.Batch(
- m.input.Focus(),
- m.spinner.Tick,
- m.loadSessions(),
- )
-}
-
-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
-
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- m.height = msg.Height
- m = m.relayout()
-
- case spinner.TickMsg:
- if m.busy {
- newSp, cmd := m.spinner.Update(msg)
- m.spinner = newSp
- cmds = append(cmds, cmd)
- }
-
- case tui.ChunkMsg:
- if m.state == stateChat || m.state == statePermission {
- m.chat.AppendChunk(msg.Text, msg.IsThinking)
- }
-
- case tui.ToolProgressMsg:
- label := msg.Label
- if label == "" {
- label = msg.Status
- }
- m.chat.AddToolProgress(msg.ToolUseID, msg.ToolName, msg.Status, label)
-
- case tui.TurnStartMsg:
- m.busy = true
- m.chat.StartAssistantMessage()
- cmds = append(cmds, m.spinner.Tick)
-
- case tui.TurnDoneMsg:
- m.busy = false
- m.chat.FinishAssistantMessage()
- if msg.Err != nil {
- m.chat.AddError(msg.Err)
- }
-
- case tui.PromptRequestMsg:
- m.permission.SetPending(&msg)
- m.permInput = ""
- m.state = statePermission
-
- case tui.SessionListMsg:
- if msg.Err == nil {
- m.sessions.SetSessions(msg.Sessions)
- }
-
- case tui.SessionCreatedMsg:
- if msg.Err != nil {
- m.lastErr = msg.Err
- } else {
- m.activeSession = msg.ID
- m.state = stateChat
- m.focus = uiFocusEditor
- m.chat.Clear()
- m.chat.AddSystem("New session · " + shortID(msg.ID))
- cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd
- }
-
- case tui.SessionLoadedMsg:
- if msg.Err != nil {
- m.lastErr = msg.Err
- } else {
- m.activeSession = msg.ID
- m.state = stateChat
- m.focus = uiFocusEditor
- m.chat.Clear()
- m.chat.AddSystem("Resumed session · " + shortID(msg.ID))
- cmds = append(cmds, m.input.Focus()) // v2: Focus() returns a Cmd
- }
-
- case tui.ModelListMsg:
- if msg.Err == nil {
- m.modelSelect.SetModels(msg.Models)
- }
-
- case tui.ModelChangedMsg:
- // Header will pick up new model string from workspace.ModelString()
-
- case tui.ErrMsg:
- m.lastErr = msg.Err
-
- case clearCopyNoticeMsg:
- m.copyNotice = ""
-
- case providerConfigLoadedMsg:
- m.configPanel.SetProviders(msg.providers)
-
- case cfgSaveResultMsg:
- if msg.err != nil {
- m.configPanel.SetError(msg.err.Error())
- } else {
- m.configPanel.SetSaved()
- }
-
- // v2 uses KeyPressMsg instead of KeyMsg
- case tea.KeyPressMsg:
- consumed, cmd := m.handleKey(msg)
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- // Non-consumed keys flow to the textarea so regular characters,
- // backspace, and cursor movement work normally.
- if !consumed && (m.state == stateChat || m.state == stateWelcome) {
- newInput, inputCmd := m.input.Update(msg)
- m.input = newInput
- cmds = append(cmds, inputCmd)
- m = m.resizeInput()
- }
- return m, tea.Batch(cmds...)
-
- case tea.MouseWheelMsg:
- // Mouse wheel scrolls chat regardless of focus state (no Tab required).
- if m.state == stateChat || m.state == stateWelcome {
- switch msg.Button {
- case tea.MouseWheelUp:
- m.chat.ScrollUp(3)
- case tea.MouseWheelDown:
- m.chat.ScrollDown(3)
- }
- }
- return m, tea.Batch(cmds...)
- }
-
- // Non-key messages (spinner, window resize, etc.) are also forwarded
- // to the textarea so blinking and focus work correctly.
- if m.state == stateChat || m.state == stateWelcome {
- newInput, cmd := m.input.Update(msg)
- m.input = newInput
- cmds = append(cmds, cmd)
- m = m.resizeInput()
- }
-
- return m, tea.Batch(cmds...)
-}
-
-// View returns a tea.View (v2 API — not a string).
-func (m Model) View() tea.View {
- if m.width == 0 {
- return tea.NewView("")
- }
-
- var content string
- switch m.state {
- case stateWelcome:
- content = m.viewWelcome()
- case stateSessions:
- content = m.viewSessions()
- case stateModelSelect:
- content = m.viewModelSelect()
- case stateCommands:
- content = m.viewCommands()
- case stateProviderConfig:
- content = m.viewProviderConfig()
- case stateChat, statePermission:
- content = m.viewChat()
- default:
- content = m.viewChat()
- }
-
- v := tea.NewView(content)
- v.AltScreen = true
- if m.selectMode {
- // In select mode, release mouse capture so the terminal handles
- // native text selection. Mouse scroll is temporarily unavailable.
- v.MouseMode = tea.MouseModeNone
- } else {
- v.MouseMode = tea.MouseModeCellMotion
- }
- return v
-}
-
-// ─── Key handling ─────────────────────────────────────────────────────────────
-
-// handleKey processes a keypress. Returns (consumed, cmd):
-// - consumed=true → key was handled; do NOT forward to textarea
-// - consumed=false → key was not handled; forward to textarea for normal input
-func (m *Model) handleKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
- k := msg.String()
-
- // ── Permission dialog (all keys consumed) ────────────────────────────
- if m.state == statePermission && m.permission.HasPending() {
- switch {
- case k == "y" || k == "Y":
- m.permission.Resolve(true, false)
- m.state = stateChat
- case k == "n" || k == "N" || k == "esc":
- m.permission.Resolve(false, true)
- m.state = stateChat
- case k == "a" || k == "A":
- m.permission.Resolve("always", false)
- m.state = stateChat
- default:
- m.permInput += k
- }
- return true, nil
- }
-
- // ── Model selection (all keys consumed) ─────────────────────────────
- if m.state == stateModelSelect {
- switch k {
- case "esc", "ctrl+m":
- m.state = m.prevChatState()
- case "left":
- // Navigate back to the commands palette if that's where we came from,
- // otherwise close to the chat/welcome state.
- if m.returnState == stateCommands {
- m.state = stateCommands
- m.commands.Open("")
- } else {
- m.state = m.prevChatState()
- }
- case "up", "k":
- m.modelSelect.Up()
- case "down", "j":
- m.modelSelect.Down()
- case "enter":
- if sel := m.modelSelect.Selected(); sel != nil {
- m.workspace.SetModel(sel.Provider, sel.Identifier)
- m.state = m.prevChatState()
- }
- case "backspace":
- m.modelSelect.DeleteFilter()
- default:
- if len(k) == 1 {
- m.modelSelect.TypeFilter(k)
- }
- }
- return true, nil
- }
-
- // ── Commands palette (all keys consumed) ────────────────────────────
- if m.state == stateCommands {
- switch k {
- case "esc", "ctrl+p":
- m.state = m.prevChatState()
- case "up", "k":
- m.commands.Up()
- case "down", "j":
- m.commands.Down()
- case "enter":
- cmd := m.commands.Execute(m)
- if m.state == stateCommands {
- m.state = m.prevChatState()
- }
- return true, cmd
- case "backspace":
- m.commands.DeleteFilter()
- default:
- if len(k) == 1 {
- m.commands.TypeFilter(k)
- }
- }
- return true, nil
- }
-
- // ── Provider config panel (all keys consumed) ───────────────────────
- if m.state == stateProviderConfig {
- cp := m.configPanel
- if cp.editing {
- switch k {
- case "esc":
- m.state = m.prevChatState()
- case "left":
- cp.ExitEdit()
- // Reload provider status after editing.
- return true, m.loadProviderConfig()
- case "up", "k":
- cp.Up()
- case "down", "j":
- cp.Down()
- case "tab":
- cp.Down()
- case "enter":
- draft, _, fieldKey := cp.CurrentFieldDraft()
- if strings.TrimSpace(draft) == "" {
- return true, nil
- }
- providerID := cp.editProvider.ID
- return true, func() tea.Msg {
- err := m.workspace.SaveProviderField(m.ctx, providerID, fieldKey, strings.TrimSpace(draft))
- if err != nil {
- return cfgSaveResultMsg{err: err}
- }
- return cfgSaveResultMsg{}
- }
- case "backspace":
- cp.DeleteChar()
- case "ctrl+v":
- cp.ToggleReveal()
- default:
- if len(k) == 1 {
- cp.TypeChar(k)
- }
- }
- } else {
- switch k {
- case "esc", "ctrl+,":
- m.state = m.prevChatState()
- case "up", "k":
- cp.Up()
- case "down", "j":
- cp.Down()
- case "enter":
- cp.EnterEdit()
- case "backspace":
- cp.DeleteFilter()
- default:
- if len(k) == 1 {
- cp.TypeFilter(k)
- }
- }
- }
- return true, nil
- }
-
- // ── Session browser (all keys consumed) ─────────────────────────────
- if m.state == stateSessions {
- switch k {
- case "esc", "ctrl+s":
- m.state = m.prevChatState()
- case "up", "k":
- m.sessions.Up()
- case "down", "j":
- m.sessions.Down()
- case "enter":
- id := m.sessions.Selected()
- if id != "" {
- m.state = stateChat
- return true, m.loadSession(id)
- }
- case "d", "delete":
- id := m.sessions.DeleteSelected()
- if id != "" {
- return true, m.deleteSession(id)
- }
- case "backspace":
- m.sessions.DeleteFilter()
- default:
- if len(k) == 1 {
- m.sessions.TypeFilter(k)
- }
- }
- return true, nil
- }
-
- // ── Select mode toggle (ctrl+e) — works from any state ──────────────
- if k == "ctrl+e" {
- m.selectMode = !m.selectMode
- return true, nil
- }
-
- // ── Global shortcuts (always consumed) ──────────────────────────────
- switch k {
- case "ctrl+c":
- if m.busy {
- m.workspace.Cancel()
- return true, nil
- }
- m.cancel()
- return true, tea.Quit
- case "ctrl+q":
- m.cancel()
- return true, tea.Quit
- case "ctrl+p":
- if m.state != stateCommands {
- m.commands.Open("")
- m.state = stateCommands
- }
- return true, nil
- case "ctrl+,":
- if m.state != stateProviderConfig {
- m.state = stateProviderConfig
- return true, m.loadProviderConfig()
- }
- return true, nil
- case "ctrl+s":
- if m.state == stateChat || m.state == stateWelcome {
- m.state = stateSessions
- return true, m.loadSessions()
- }
- case "ctrl+n":
- return true, m.createSession()
- case "ctrl+m":
- if m.state != stateModelSelect {
- m.returnState = m.prevChatState()
- m.state = stateModelSelect
- m.modelSelect.ClearFilter()
- return true, m.listModels()
- }
- case "tab":
- // Tab toggles between editor focus (typing) and main focus (scrolling).
- if m.state == stateChat {
- if m.focus == uiFocusEditor {
- m.focus = uiFocusMain
- m.input.Blur()
- } else {
- m.focus = uiFocusEditor
- return true, m.input.Focus()
- }
- return true, nil
- }
- }
-
- // ── Chat / welcome: dispatch by focus state (crush pattern) ──────────
- if m.state == stateChat || m.state == stateWelcome {
-
- // When focus is on the chat list, arrow keys scroll rather than move cursor.
- if m.focus == uiFocusMain {
- switch k {
- case "up", "k":
- m.chat.ScrollUp(3)
- return true, nil
- case "down", "j":
- m.chat.ScrollDown(3)
- return true, nil
- case "pgup":
- m.chat.PageUp()
- return true, nil
- case "pgdown":
- m.chat.PageDown()
- return true, nil
- case "home":
- m.chat.GotoTop()
- return true, nil
- case "end":
- m.chat.GotoBottom()
- return true, nil
- }
- // Any other key switches back to editor.
- m.focus = uiFocusEditor
- return true, m.input.Focus()
- }
-
- // ── Editor focus (default) ────────────────────────────────────────
-
- // File completions popup intercepts keys while open.
- if m.completions.IsOpen() {
- switch k {
- case "esc":
- m.completions.Close()
- case "up":
- m.completions.Up()
- case "down":
- m.completions.Down()
- case "enter", "tab":
- if sel := m.completions.Selected(); sel != "" {
- query := m.completions.Query()
- val := m.input.Value()
- atIdx := strings.LastIndex(val, "@"+query)
- if atIdx >= 0 {
- m.input.SetValue(val[:atIdx] + sel + val[atIdx+len("@"+query):])
- }
- m.completions.Close()
- }
- case "backspace":
- m.completions.Backspace()
- default:
- if len(k) == 1 && k != "@" {
- m.completions.TypeChar(k)
- } else {
- m.completions.Close()
- return false, nil
- }
- }
- return true, nil
- }
-
- switch k {
- case "/":
- // Open the commands palette pre-filtered to slash commands when
- // the input is empty. If there's already text, let / go to textarea.
- if strings.TrimSpace(m.input.Value()) == "" {
- m.commands.Open("/")
- m.state = stateCommands
- return true, nil
- }
- return false, nil
-
- case "@":
- // Open completions AND let textarea receive @ to show it in the input.
- m.completions.Open(m.workspace.WorkingDir())
- // Fall through to textarea (consumed=false) so @ appears in input.
- return false, nil
-
- case "enter":
- text := strings.TrimSpace(m.input.Value())
- if text == "" || m.busy {
- return true, nil
- }
- if m.activeSession == "" {
- return true, tea.Batch(m.createSession(), func() tea.Msg {
- return pendingSubmitMsg{prompt: text}
- })
- }
- atts := m.attachments.List()
- _ = atts
- m.attachments.Reset()
- m.input.Reset()
- m.chat.AddUserMessage(text)
- m.workspace.Submit(m.ctx, text)
- return true, nil
-
- case "shift+enter", "alt+enter":
- // crush uses InsertRune('\n') directly — more reliable than Update(msg).
- m.input.InsertRune('\n')
- return true, nil
-
- case "ctrl+t":
- // Toggle thinking block collapse on the most recent assistant message.
- m.chat.ToggleThinking()
- return true, nil
-
- case "ctrl+u":
- // Copy last user message to clipboard.
- text := m.chat.GetLastUserText()
- if text != "" {
- return true, m.copyToClipboard(text, "Message copied")
- }
- return true, nil
-
- case "ctrl+a":
- return true, nil
-
- case "pgup":
- m.chat.PageUp()
- return true, nil
- case "pgdown":
- m.chat.PageDown()
- return true, nil
- case "home":
- m.chat.GotoTop()
- return true, nil
- case "end":
- m.chat.GotoBottom()
- return true, nil
- }
- }
-
- // Key was not handled — forward to the textarea.
- return false, nil
-}
-
-// pendingSubmitMsg is used to queue a prompt while session creation is pending.
-type pendingSubmitMsg struct{ prompt string }
-
-// clearCopyNoticeMsg clears the transient "Copied!" footer message.
-type clearCopyNoticeMsg struct{}
-
-// cfgSaveResultMsg is sent after attempting to save a provider credential.
-type cfgSaveResultMsg struct{ err error }
-
-// providerConfigLoadedMsg carries a refreshed provider list.
-type providerConfigLoadedMsg struct{ providers []tui.ProviderStatus }
-
-// ─── Views ────────────────────────────────────────────────────────────────────
-
-func (m Model) viewWelcome() string {
- // Braille logo rendered in orange primary colour.
- logoArt := lipgloss.NewStyle().Foreground(colorPrimary).Render(nexusLogo)
-
- wordmark := m.styles.Logo.Render("◉ NEXUS")
- tagline := m.styles.HeaderModel.Render("One runtime. Any LLM. Any language.")
-
- hint := strings.Join([]string{
- m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new session"),
- m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"),
- m.styles.Key.Render("ctrl+q") + " " + m.styles.Desc.Render("quit"),
- }, " ")
-
- body := lipgloss.NewStyle().
- Width(m.width).
- Height(m.height-2).
- Align(lipgloss.Center, lipgloss.Center).
- Render(logoArt + "\n" + wordmark + "\n\n" + tagline + "\n\n" + hint)
-
- return m.header() + "\n" + body
-}
-
-func (m Model) viewChat() string {
- inputView := m.inputView()
- chatH := m.height - headerHeight - footerHeight - lipgloss.Height(inputView)
- m.chat.SetSize(m.width, max(1, chatH))
- chatView := m.chat.View()
-
- base := strings.Join([]string{
- m.header(),
- chatView,
- inputView,
- m.footer(),
- }, "\n")
-
- if m.state == statePermission && m.permission.HasPending() {
- overlay := m.permission.View()
- return overlayOn(base, overlay, m.width, m.height)
- }
- return base
-}
-
-func (m Model) viewSessions() string {
- m.sessions.SetSize(m.width, m.height)
- overlay := m.sessions.centred()
- var backdrop string
- if m.activeSession != "" {
- backdrop = m.viewChat()
- } else {
- backdrop = m.viewWelcome()
- }
- return overlayOn(backdrop, overlay, m.width, m.height)
-}
-
-func (m Model) viewModelSelect() string {
- m.modelSelect.SetSize(m.width, m.height)
- overlay := m.modelSelect.centred()
- var backdrop string
- if m.activeSession != "" {
- backdrop = m.viewChat()
- } else {
- backdrop = m.viewWelcome()
- }
- return overlayOn(backdrop, overlay, m.width, m.height)
-}
-
-func (m Model) viewCommands() string {
- m.commands.SetSize(m.width, m.height)
- overlay := m.commands.centred()
- var backdrop string
- if m.activeSession != "" {
- backdrop = m.viewChat()
- } else {
- backdrop = m.viewWelcome()
- }
- return overlayOn(backdrop, overlay, m.width, m.height)
-}
-
-func (m Model) viewProviderConfig() string {
- m.configPanel.SetSize(m.width, m.height)
- overlay := m.configPanel.centred()
- var backdrop string
- if m.activeSession != "" {
- backdrop = m.viewChat()
- } else {
- backdrop = m.viewWelcome()
- }
- return overlayOn(backdrop, overlay, m.width, m.height)
-}
-
-func (m Model) header() string {
- logo := m.styles.Logo.Render("◉ NEXUS")
- sep := m.styles.HeaderSep.Render(" │ ")
- model := m.styles.HeaderModel.Render(m.workspace.ModelString())
-
- var status string
- if m.busy {
- status = m.spinner.View() + " " + m.styles.HeaderBusy.Render("working")
- } else if m.focus == uiFocusMain && m.state == stateChat {
- status = m.styles.HeaderBusy.Render("↕ scroll") + " " + m.styles.HeaderID.Render("tab: back to input")
- } else if m.activeSession != "" {
- status = m.styles.HeaderReady.Render("●") + " " + m.styles.HeaderID.Render(shortID(m.activeSession))
- } else {
- status = m.styles.HeaderReady.Render("ready")
- }
-
- left := logo + sep + model
- gap := m.width - lipgloss.Width(left) - lipgloss.Width(status) - 2
- if gap < 1 {
- gap = 1
- }
- return left + strings.Repeat(" ", gap) + status
-}
-
-func (m Model) footer() string {
- // Select mode banner takes priority.
- if m.selectMode {
- return lipgloss.NewStyle().
- Foreground(colorPrimary).Bold(true).
- Render("SELECT MODE") +
- " " +
- m.styles.Desc.Render("select text with mouse · copy with ctrl+c ·") +
- " " +
- m.styles.Key.Render("ctrl+e") +
- " " +
- m.styles.Desc.Render("exit")
- }
-
- // Transient copy notice takes over the footer briefly.
- if m.copyNotice != "" {
- return m.styles.ToolDone.Render("✓ " + m.copyNotice)
- }
-
- var items []string
- if m.focus == uiFocusMain && m.state == stateChat {
- items = []string{
- m.styles.Key.Render("↑↓") + " " + m.styles.Desc.Render("scroll"),
- m.styles.Key.Render("ctrl+e") + " " + m.styles.Desc.Render("select"),
- m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("back to input"),
- m.styles.Key.Render("ctrl+c") + " " + m.styles.Desc.Render("quit"),
- }
- } else {
- items = []string{
- m.styles.Key.Render("ctrl+p") + " " + m.styles.Desc.Render("commands"),
- m.styles.Key.Render("ctrl+n") + " " + m.styles.Desc.Render("new"),
- m.styles.Key.Render("ctrl+s") + " " + m.styles.Desc.Render("sessions"),
- m.styles.Key.Render("ctrl+e") + " " + m.styles.Desc.Render("select"),
- m.styles.Key.Render("tab") + " " + m.styles.Desc.Render("scroll"),
- m.styles.Key.Render("ctrl+c") + " " + m.styles.Desc.Render("cancel/quit"),
- }
- }
- return m.styles.Footer.Render(strings.Join(items, " "))
-}
-
-func (m Model) inputView() string {
- inner := m.input.View()
-
- // Attachments strip above the textarea.
- if attView := m.attachments.View(m.width - 4); attView != "" {
- inner = attView + "\n" + inner
- }
-
- box := m.styles.InputBorder.Width(m.width - 2).Render(inner)
-
- // File completions popup rendered directly above the input box.
- if m.completions.IsOpen() {
- popup := m.completions.View(m.width - 4)
- return popup + "\n" + box
- }
- return box
-}
-
-// ─── Layout ───────────────────────────────────────────────────────────────────
-
-func (m Model) relayout() Model {
- inputW := m.width - 4
- if inputW < 10 {
- inputW = 10
- }
- m.input.SetWidth(inputW)
- m.sessions.SetSize(m.width, m.height)
- m.permission.SetSize(m.width, m.height)
- m.modelSelect.SetSize(m.width, m.height)
- m.commands.SetSize(m.width, m.height)
- m.configPanel.SetSize(m.width, m.height)
- m.chat.SetSize(m.width, max(1, m.height-headerHeight-footerHeight-inputMinH-inputPadding))
- return m
-}
-
-func (m Model) resizeInput() Model {
- lines := strings.Count(m.input.Value(), "\n") + 1
- h := clamp(lines, inputMinH, inputMaxH)
- m.input.SetHeight(h)
- return m
-}
-
-func (m Model) prevChatState() uiState {
- if m.activeSession != "" {
- return stateChat
- }
- return stateWelcome
-}
-
-// ─── Clipboard ───────────────────────────────────────────────────────────────
-
-// copyToClipboard copies text using OSC 52 (tea.SetClipboard) and the native
-// clipboard (atotto/clipboard), then shows a transient notice in the footer.
-// This mirrors crush's CopyToClipboard approach for maximum terminal compat.
-func (m *Model) copyToClipboard(text, notice string) tea.Cmd {
- m.copyNotice = notice
- return tea.Sequence(
- tea.SetClipboard(text),
- func() tea.Msg {
- _ = clipboard.WriteAll(text)
- return nil
- },
- tea.Tick(2*time.Second, func(time.Time) tea.Msg {
- return clearCopyNoticeMsg{}
- }),
- )
-}
-
-// ─── Workspace commands ───────────────────────────────────────────────────────
-
-func (m Model) loadSessions() tea.Cmd {
- return func() tea.Msg { m.workspace.ListSessions(m.ctx); return nil }
-}
-
-func (m Model) listModels() tea.Cmd {
- return func() tea.Msg { m.workspace.ListModels(m.ctx); return nil }
-}
-
-func (m Model) createSession() tea.Cmd {
- return func() tea.Msg { m.workspace.CreateSession(m.ctx); return nil }
-}
-
-func (m Model) loadSession(id string) tea.Cmd {
- return func() tea.Msg { m.workspace.LoadSession(m.ctx, id); return nil }
-}
-
-func (m Model) loadProviderConfig() tea.Cmd {
- return func() tea.Msg {
- providers := m.workspace.LoadProviderConfig(m.ctx)
- return providerConfigLoadedMsg{providers: providers}
- }
-}
-
-func (m Model) deleteSession(id string) tea.Cmd {
- return func() tea.Msg {
- _ = m.workspace.DeleteSession(m.ctx, id)
- m.workspace.ListSessions(m.ctx)
- return nil
- }
-}
-
-// ─── Overlay compositor ───────────────────────────────────────────────────────
-
-func overlayOn(base, overlay string, width, height int) string {
- if overlay == "" {
- return base
- }
- baseLines := strings.Split(base, "\n")
- overlayLines := strings.Split(overlay, "\n")
- overlayH := len(overlayLines)
- for len(baseLines) < height {
- baseLines = append(baseLines, strings.Repeat(" ", width))
- }
- // Centre the overlay vertically over the base.
- topOffset := max(0, (height-overlayH)/2)
- dim := lipgloss.NewStyle().Faint(true)
- for i, line := range baseLines {
- overlayRow := i - topOffset
- if overlayRow >= 0 && overlayRow < overlayH {
- ol := overlayLines[overlayRow]
- if ol == "" {
- // Transparent row: show dimmed base behind it.
- baseLines[i] = dim.Render(line)
- } else {
- baseLines[i] = ol
- }
- } else {
- baseLines[i] = dim.Render(line)
- }
- }
- return strings.Join(baseLines, "\n")
-}
-
-// ─── Utilities ────────────────────────────────────────────────────────────────
-
-func shortID(id string) string {
- if len(id) > 8 {
- return id[:8]
- }
- return id
-}
-
-func clamp(v, lo, hi int) int {
- if v < lo {
- return lo
- }
- if v > hi {
- return hi
- }
- return v
-}
-
-// Run starts the BubbleTea program and blocks until it exits.
-func Run(ws tui.Workspace, ctx context.Context) error {
- m := New(ws, ctx)
- p := tea.NewProgram(m, tea.WithContext(ctx))
- ws.Subscribe(p)
- _, err := p.Run()
- return err
-}
-
-var _ = fmt.Sprintf
diff --git a/internal/tui/workspace.go b/internal/tui/workspace.go
index bd8bf83..92a5c9e 100644
--- a/internal/tui/workspace.go
+++ b/internal/tui/workspace.go
@@ -25,6 +25,7 @@ type ToolProgressMsg struct {
ToolName string
Status string // "pending" | "running" | "completed" | "failed"
Label string // human-readable status label
+ Metadata map[string]any
SessionID string
}
@@ -143,6 +144,29 @@ type SessionInfo struct {
Tokens int
}
+// ToolInfo is the TUI's lightweight view of one registered tool.
+type ToolInfo struct {
+ Name string
+ Description string
+ Category string
+}
+
+// MCPServerInfo is the TUI's summary view of one MCP server integration.
+type MCPServerInfo struct {
+ Name string
+ ToolsRegistered int
+ Status string
+ Error string
+}
+
+// SkillInfo is the TUI's summary view of one available slash skill.
+type SkillInfo struct {
+ Name string
+ Description string
+ WhenToUse string
+ Source string
+}
+
// ─── Workspace interface ──────────────────────────────────────────────────────
// Workspace is the contract between the TUI model and the nexus engine.
@@ -189,6 +213,15 @@ type Workspace interface {
// credential status read from the credentials DB.
LoadProviderConfig(ctx context.Context) []ProviderStatus
+ // LoadToolCatalog returns the current registered tool surface.
+ LoadToolCatalog(ctx context.Context) []ToolInfo
+
+ // LoadMCPServers returns the current MCP integration status.
+ LoadMCPServers(ctx context.Context) []MCPServerInfo
+
+ // LoadSkills returns user-invocable skills available in the current repo.
+ LoadSkills(ctx context.Context) []SkillInfo
+
// SaveProviderField persists a credential field for a provider.
// fieldKey is the DB key (e.g. "api_key", "provider_base_url").
// Stored under the scoped key "fieldKey:providerID" so each provider