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 -
Commands palette — every shortcut, one keystroke away (ctrl+p) + Settings panel +
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("red plain", style) + if !strings.Contains(got, ""+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, "", ""+prefix) + out = strings.ReplaceAll(out, "", ""+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