Files
Ed Zynda 9f125f3400 refactor(ui): standardize all popups on shared PopupList
- Extend PopupList with FullScreen mode, RenderItem callback, and
  external-state setters (SetItems/SetCursor/SetSearch) so any popup
  can reuse the same chrome (border, title, search, scroll, footer).
- Rewrite TreeSelector and SessionSelector as thin PopupList wrappers,
  dropping ~500 lines of duplicated rendering. Selector-specific keys
  (filter cycle, scope/named toggles, delete-confirm) are pre-handled;
  everything else delegates to PopupList.
- Migrate the / and @ autocomplete popups in InputComponent to render
  through PopupList, replacing the bespoke renderer.
- Fix /tree and /fork overflow with deep trees: measure tree-art
  prefix width via lipgloss.Width (handles multi-byte box drawing),
  truncate the prefix from the left with an ellipsis when it would
  push text off the row, and collapse multi-line message content to
  a single line so rows never wrap.
- Fix broken selection highlight in /tree, /fork, /sessions: emit a
  plain string from RenderItem for the cursor row so the outer row
  style paints one continuous fg+bg span instead of being shredded
  by mid-row ANSI resets from inner Render calls.
- Center the cursor in the visible window so context is always shown
  above and below the selection.
2026-06-07 17:45:06 +03:00

971 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package ui
import (
"fmt"
"image/color"
"sort"
"strings"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/mark3labs/kit/internal/clipboard"
"github.com/mark3labs/kit/internal/ui/commands"
"github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/imagepreview"
"github.com/mark3labs/kit/internal/ui/style"
)
// InputComponent is the interactive text input field for the parent AppModel.
// It wraps the slash command autocomplete popup and delegates slash command
// execution to the AppController. On submit it returns a submitMsg tea.Cmd
// instead of tea.Quit — lifecycle is entirely managed by the parent.
//
// Slash commands handled locally (not forwarded to app layer):
// - /quit, /q, /exit → tea.Quit
// - /clear, /cls, /c → appCtrl.ClearMessages() then clear the textarea
//
// /clear-queue is forwarded to the parent via submitMsg so the parent can
// update queueCount directly (calling ClearQueue from within Update would
// require prog.Send which deadlocks).
//
// All other input is returned via submitMsg for the parent to forward to
// app.Run().
type InputComponent struct {
textarea textarea.Model
commands []commands.SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
width int
lastValue string
popupHeight int
submitNext bool // defer submit one tick so popup dismisses cleanly
// popup is the shared PopupList used to render the / and @ autocomplete
// dropdowns. State (items, cursor, visible search-driven filter) is
// driven externally by InputComponent — we only use PopupList for the
// rendering chrome so all popups in the app look identical.
popup *PopupList
// Argument completion state. When the user types "/cmd " followed by
// a partial argument and the command has a Complete function, the popup
// switches to argument-completion mode showing suggestions from Complete.
argMode bool // true when showing arg completions
argCommand string // command prefix for arg mode (e.g. "/bookmark")
argSynthCmds []commands.SlashCommand // backing storage for synthetic arg entries
// File completion state. When the user types @ followed by a partial
// file path, the popup shows file/directory suggestions from the cwd.
fileMode bool // true when showing @file completions
filePrefix string // current text after @ being matched
fileAtStartIdx int // byte offset of @ (or path start in /edit mode) in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []commands.SlashCommand // synthetic commands.SlashCommands wrapping file entries
// fileEditMode is true when fileMode was activated by the /edit slash
// command rather than an @ trigger. Selecting a file submits the line
// (running $EDITOR on it); selecting a directory drills further like @
// does. MCP resources are excluded in this mode.
fileEditMode bool
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
cwd string
// mcpResources is a callback that returns available MCP resources for
// the @ autocomplete popup. Set by the parent via SetMCPResourceProvider.
mcpResources func() []FileSuggestion
// appCtrl is used for slash commands that mutate app state.
// May be nil in tests; nil-safe.
appCtrl AppController
// hideHint suppresses the "enter submit · ctrl+j..." hint text.
hideHint bool
// agentBusy indicates the agent is currently working. When true, the
// hint text shows steering shortcut (Ctrl+X s) instead of submit.
agentBusy bool
// pendingImages holds clipboard images attached to the next submission.
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []core.ImageAttachment
// imageThumbs caches the rendered half-block thumbnail for each entry in
// pendingImages (1:1 index correspondence). Thumbnails are rendered
// asynchronously off the Bubble Tea event loop (decode + resample is too
// slow to run inside Update), so an entry starts as the empty string
// placeholder and is filled in when the matching thumbnailReadyMsg
// arrives. An entry stays empty when the terminal cannot display a
// half-block preview, in which case the text pill is shown alone.
// See internal/ui/imagepreview.
imageThumbs []string
// imageGen is a monotonic generation counter incremented whenever the
// pending image set is cleared. Async thumbnail results carry the
// generation they were enqueued under and are discarded if it no longer
// matches, preventing a stale thumbnail from landing on the wrong slot
// after a clear + re-attach.
imageGen int
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
// skipped. Empty strings are never stored.
history []string
// historyIndex is the current position when browsing history.
// When not browsing, historyIndex == len(history).
historyIndex int
// savedInput holds the user's in-progress text before they started
// browsing history, so it can be restored when they press down past
// the end of history.
savedInput string
// browsingHistory is true when the user is navigating history with
// up/down arrows. Set to false when they type a character or submit.
browsingHistory bool
}
// maxHistory is the maximum number of prompt entries kept in history.
const maxHistory = 100
// clipboardImageMsg is the result of an async clipboard image read.
type clipboardImageMsg struct {
image *core.ImageAttachment
err error
}
// thumbnailReadyMsg carries the result of an async thumbnail render back to
// the Update loop. gen and index identify the pendingImages slot the
// thumbnail belongs to; the result is dropped if the generation no longer
// matches (the pending set was cleared) or the index is out of range.
type thumbnailReadyMsg struct {
gen int
index int
thumb string
}
// NewInputComponent creates a new InputComponent with the given width and
// optional AppController. If appCtrl is nil the component still works but
// /clear and /clear-queue are no-ops.
func NewInputComponent(width int, appCtrl AppController) *InputComponent {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 0
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(4) // 4 lines for comfortable multi-line input
ta.Focus()
// Override InsertNewline so only ctrl+j and shift+enter insert newlines.
// Enter always submits the input.
ta.KeyMap.InsertNewline = key.NewBinding(
key.WithKeys("ctrl+j", "shift+enter"),
key.WithHelp("ctrl+j", "insert newline"),
)
// Style the textarea using theme colors.
theme := style.GetTheme()
styles := ta.Styles()
styles.Focused.Base = lipgloss.NewStyle()
styles.Focused.Placeholder = lipgloss.NewStyle().Foreground(theme.VeryMuted)
styles.Focused.Text = lipgloss.NewStyle().Foreground(theme.Text)
styles.Focused.Prompt = lipgloss.NewStyle()
styles.Focused.CursorLine = lipgloss.NewStyle()
ta.SetStyles(styles)
ic := &InputComponent{
textarea: ta,
commands: commands.SlashCommands,
width: width,
popupHeight: 7,
appCtrl: appCtrl,
hideHint: true,
}
ic.popup = NewPopupList("", nil, width, 0)
ic.popup.ShowSearch = false
ic.popup.HideCount = true
ic.popup.MaxVisible = ic.popupHeight
ic.popup.FooterHint = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
return ic
}
// SetCwd sets the working directory used for @file autocomplete suggestions
// and path resolution. Should be called by the parent after construction.
func (s *InputComponent) SetCwd(cwd string) {
s.cwd = cwd
}
// SetMCPResourceProvider sets a callback that returns MCP resource suggestions
// for the @ autocomplete popup. Called by the parent after construction.
func (s *InputComponent) SetMCPResourceProvider(fn func() []FileSuggestion) {
s.mcpResources = fn
}
// Init implements tea.Model. Starts the cursor blink animation.
func (s *InputComponent) Init() tea.Cmd {
return textarea.Blink
}
// Update implements tea.Model. Handles keyboard input, popup navigation, and
// slash command execution. Returns submitMsg via a tea.Cmd when the user
// submits text — it does NOT return tea.Quit (parent owns lifecycle).
func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
// If submitNext is set, the previous update wanted to submit but needed one
// more frame so the popup dismisses cleanly first.
if s.submitNext {
s.submitNext = false
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.showPopup = false
s.lastValue = ""
return s, s.handleSubmit(value)
}
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.width = msg.Width
s.textarea.SetWidth(msg.Width - 8)
return s, nil
case clipboardImageMsg:
if msg.err != nil {
// Silently ignore — no image on clipboard or tool unavailable.
return s, nil
}
if msg.image != nil {
img := *msg.image
index := len(s.pendingImages)
s.pendingImages = append(s.pendingImages, img)
// Reserve a placeholder; the async render fills it in via
// thumbnailReadyMsg so Update never blocks on decode/resample.
s.imageThumbs = append(s.imageThumbs, "")
cols := s.thumbCols()
if cols < 1 {
return s, nil
}
return s, renderThumbnailCmd(img, cols, thumbMaxRows, style.GetTheme().Background, s.imageGen, index)
}
return s, nil
case thumbnailReadyMsg:
if msg.gen == s.imageGen && msg.index >= 0 && msg.index < len(s.imageThumbs) {
s.imageThumbs[msg.index] = msg.thumb
}
return s, nil
case tea.KeyPressMsg:
if !s.showPopup {
switch msg.String() {
case "enter":
value := s.textarea.Value()
s.pushHistory(value)
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.lastValue = ""
return s, s.handleSubmit(value)
case "up":
// Navigate prompt history backward (older entries).
if len(s.history) > 0 {
if !s.browsingHistory {
// Start browsing — save current input.
s.savedInput = s.textarea.Value()
s.browsingHistory = true
s.historyIndex = len(s.history)
}
if s.historyIndex > 0 {
s.historyIndex--
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
}
return s, nil
}
case "down":
// Navigate prompt history forward (newer entries).
if s.browsingHistory {
if s.historyIndex < len(s.history)-1 {
s.historyIndex++
s.textarea.SetValue(s.history[s.historyIndex])
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
} else {
// Past the end — restore saved input.
s.historyIndex = len(s.history)
s.browsingHistory = false
s.textarea.SetValue(s.savedInput)
s.textarea.CursorEnd()
s.lastValue = s.textarea.Value()
s.savedInput = ""
}
return s, nil
}
case "ctrl+v":
// Try to read an image from the clipboard asynchronously.
return s, readClipboardImageCmd()
case "ctrl+u":
// Clear all pending image attachments.
if len(s.pendingImages) > 0 {
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return s, nil
}
}
}
// Handle popup navigation
if s.showPopup {
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up"))):
if s.selected > 0 {
s.selected--
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down"))):
if s.selected < len(s.filtered)-1 {
s.selected++
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
if s.selected < len(s.filtered) {
if s.fileMode {
s.applyFileCompletion(s.selected)
} else if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
s.showPopup = false
s.selected = 0
}
s.textarea.CursorEnd()
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
if s.selected < len(s.filtered) {
if s.fileMode {
// Apply file completion but don't submit.
s.applyFileCompletion(s.selected)
s.textarea.CursorEnd()
return s, nil
}
selectedCmd := s.filtered[s.selected].Command
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + selectedCmd.Name)
} else {
s.textarea.SetValue(selectedCmd.Name)
}
s.textarea.CursorEnd()
s.showPopup = false
s.selected = 0
// If the selected command expects arguments, populate
// the input with the command + trailing space so the
// user can type args, instead of auto-submitting.
if !s.argMode && selectedCmd.HasArgs {
s.textarea.SetValue(selectedCmd.Name + " ")
s.textarea.CursorEnd()
} else {
s.submitNext = true
}
return s, nil
}
return s, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
s.showPopup = false
s.selected = 0
return s, nil
}
}
// Pass the key to the textarea.
s.textarea, cmd = s.textarea.Update(msg)
// Update autocomplete popup state.
value := s.textarea.Value()
if value != s.lastValue {
s.lastValue = value
// User typed something — exit history browsing mode.
if s.browsingHistory {
s.browsingHistory = false
s.savedInput = ""
}
lines := strings.Split(value, "\n")
line := lines[len(lines)-1] // current line (last line for multi-line)
// Check for @file trigger first.
cursorCol := len(line) // approximate: cursor is at end after typing
if hasAt, prefix, atIdx := ExtractAtPrefix(line, cursorCol); hasAt {
var suggestions []FileSuggestion
// Local file suggestions (only if cwd is set).
if s.cwd != "" {
suggestions = GetFileSuggestions(prefix, s.cwd)
}
// MCP resource suggestions — merge with file suggestions.
if s.mcpResources != nil {
mcpSuggestions := s.mcpResources()
if prefix != "" {
// Fuzzy-filter MCP resources against the typed prefix.
queryLower := strings.ToLower(prefix)
var filtered []FileSuggestion
for _, r := range mcpSuggestions {
score := scoreFilePath(queryLower, r.RelPath)
if score <= 0 {
// Also try matching against the resource name without prefix.
score = scoreFilePath(queryLower, r.MCPServerName+"/"+r.RelPath)
}
if score > 0 {
r.Score = score
filtered = append(filtered, r)
}
}
mcpSuggestions = filtered
}
suggestions = append(suggestions, mcpSuggestions...)
}
if len(suggestions) > 0 {
// Sort by score descending, cap at maxFileSuggestions.
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].Score > suggestions[j].Score
})
if len(suggestions) > maxFileSuggestions {
suggestions = suggestions[:maxFileSuggestions]
}
s.showPopup = true
s.fileMode = true
s.argMode = false
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
desc := ""
if fs.IsDir {
desc = "directory"
} else if fs.IsMCPResource {
desc = "mcp:" + fs.MCPServerName
}
s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
} else {
s.showPopup = false
s.fileMode = false
s.fileEditMode = false
}
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
s.fileMode = false
s.fileEditMode = false
if cmdLen, pathPrefix, isEdit := ExtractEditPrefix(lines[0]); isEdit {
// /edit fuzzy-file picker. Behaves like @ except
// MCP resources are excluded and selecting a file
// submits the line (running $EDITOR).
s.updateEditFilePopup(cmdLen, pathPrefix)
} else if !strings.Contains(lines[0], " ") {
// Command name completion.
s.showPopup = true
s.argMode = false
s.filtered = FuzzyMatchCommands(lines[0], s.commands)
s.selected = 0
} else if suggestions := s.completeArgs(lines[0]); len(suggestions) > 0 {
// Argument completion for a command with a Complete function.
s.showPopup = true
// s.argMode, s.argCommand, s.argSynthCmds, s.filtered
// are set by completeArgs.
s.selected = 0
} else {
s.showPopup = false
s.argMode = false
}
} else {
s.showPopup = false
s.argMode = false
s.fileMode = false
s.fileEditMode = false
}
}
return s, cmd
default:
s.textarea, cmd = s.textarea.Update(msg)
return s, cmd
}
}
// handleSubmit processes the submitted text. Slash commands that affect app
// state are executed here; /quit returns tea.Quit; everything else returns a
// submitMsg tea.Cmd for the parent to forward to app.Run().
//
// Shell command prefixes (matching pi's behavior):
// - !cmd → execute shell command, output INCLUDED in LLM context
// - !!cmd → execute shell command, output EXCLUDED from LLM context
func (s *InputComponent) handleSubmit(value string) tea.Cmd {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
// Check for shell command prefixes before slash commands. Test !! first
// (more specific) to avoid matching the single-! case for double-bang.
if strings.HasPrefix(trimmed, "!!") {
cmd := strings.TrimSpace(trimmed[2:])
if cmd != "" {
return func() tea.Msg {
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: true}
}
}
} else if strings.HasPrefix(trimmed, "!") {
cmd := strings.TrimSpace(trimmed[1:])
if cmd != "" {
return func() tea.Msg {
return core.ShellCommandMsg{Command: cmd, ExcludeFromContext: false}
}
}
}
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit is handled locally — all other slash commands (including
// /clear and /clear-queue) are forwarded to the parent model via
// submitMsg so the parent can update its own state (ScrollList, queue
// counts, etc.) in one place.
if sc := commands.GetCommandByName(trimmed); sc != nil {
switch sc.Name {
case "/quit":
return tea.Quit
}
}
// For all other input (including unrecognised slash commands and regular
// prompts) hand off to the parent via submitMsg. Attach any pending
// images and clear them.
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return func() tea.Msg {
return core.SubmitMsg{Text: trimmed, Images: images}
}
}
// pushHistory adds a prompt to the history ring buffer. Empty strings and
// consecutive duplicates of the last entry are skipped. When the buffer
// exceeds maxHistory, the oldest entry is dropped.
func (s *InputComponent) pushHistory(value string) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return
}
// Skip consecutive duplicates.
if len(s.history) > 0 && s.history[len(s.history)-1] == trimmed {
s.resetHistoryBrowsing()
return
}
s.history = append(s.history, trimmed)
if len(s.history) > maxHistory {
s.history = s.history[len(s.history)-maxHistory:]
}
s.resetHistoryBrowsing()
}
// resetHistoryBrowsing resets the history browsing state so the index
// points past the end (ready for new input).
func (s *InputComponent) resetHistoryBrowsing() {
s.historyIndex = len(s.history)
s.browsingHistory = false
s.savedInput = ""
}
// thumbMaxCols and thumbMaxRows cap the size, in terminal cells, of pending
// image previews. Kept small for the low-res look and to keep scrollback
// light.
const (
thumbMaxCols = 40
thumbMaxRows = 12
)
// thumbCols returns the thumbnail width in terminal cells given the current
// input width, or 0 when there is no room to render a preview.
func (s *InputComponent) thumbCols() int {
if s.width <= 6 {
return 0
}
cols := min(thumbMaxCols, s.width-6)
if cols < 1 {
return 0
}
return cols
}
// renderThumbnailCmd returns a tea.Cmd that renders a half-block ANSI preview
// off the Bubble Tea event loop. The decode + resample work runs in the Cmd
// goroutine, and the result is delivered as a thumbnailReadyMsg tagged with
// the generation and slot index it was enqueued for. An empty thumbnail
// (terminal unsupported or render error) leaves the text pill in place.
func renderThumbnailCmd(img core.ImageAttachment, cols, rows int, bg color.Color, gen, index int) tea.Cmd {
return func() tea.Msg {
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, rows, bg)
if err != nil {
thumb = ""
}
return thumbnailReadyMsg{gen: gen, index: index, thumb: thumb}
}
}
// View implements tea.Model. Renders the textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := style.GetTheme()
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(theme.Primary).
MarginTop(1).
MarginBottom(1).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
var view strings.Builder
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
// Popup is now rendered as a centered overlay in AppModel.View()
// instead of inline here to prevent bottom overflow
// Show image attachment previews when images are pending. A cached
// half-block thumbnail is rendered when the terminal supports it;
// otherwise the text pill alone is shown.
if len(s.pendingImages) > 0 {
imgStyle := lipgloss.NewStyle().
Foreground(theme.Secondary).
PaddingLeft(3)
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
view.WriteString("\n")
view.WriteString(imgStyle.Render(label))
thumbStyle := lipgloss.NewStyle().PaddingLeft(3)
for i := range s.pendingImages {
if i < len(s.imageThumbs) && s.imageThumbs[i] != "" {
view.WriteString("\n")
view.WriteString(thumbStyle.Render(s.imageThumbs[i]))
}
}
}
if !s.hideHint {
helpStyle := lipgloss.NewStyle().
Foreground(theme.VeryMuted).
MarginTop(1).
PaddingLeft(3)
// Adapt hint text to available width (accounting for left padding of 3).
var hint string
availableHintWidth := s.width - 3
if s.agentBusy {
// When the agent is working, show steering shortcut.
if availableHintWidth >= 60 {
hint = "enter queue • ctrl+x s steer • esc esc cancel"
} else if availableHintWidth >= 40 {
hint = "↵ queue • ^X s steer • esc×2 cancel"
} else {
hint = "^X s steer"
}
} else if availableHintWidth >= 80 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+x e editor • ctrl+v paste image"
} else if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j new line • ctrl+x e editor • ctrl+v image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ^X e editor"
} else if availableHintWidth >= 20 {
hint = "↵ submit • ^X e editor"
} else {
hint = "↵ submit"
}
view.WriteString("\n")
view.WriteString(helpStyle.Render(hint))
}
return tea.NewView(containerStyle.Render(view.String()))
}
// RenderPopupCentered renders the autocomplete popup for / or @ as a
// centered overlay. Returns "" when the popup is not currently shown.
// The actual filtering / selection state lives on InputComponent — this
// method merely converts the filtered FuzzyMatch list into PopupItems
// and asks the shared PopupList to draw it. As a result the / popup, the
// @ popup, the model picker, the tree selector and the session selector
// all share identical chrome.
func (s *InputComponent) RenderPopupCentered(termWidth, termHeight int) string {
if !s.showPopup || len(s.filtered) == 0 {
return ""
}
items := make([]PopupItem, len(s.filtered))
for i, m := range s.filtered {
desc := ""
if m.Command != nil {
desc = m.Command.Description
}
name := ""
if m.Command != nil {
name = m.Command.Name
}
items[i] = PopupItem{
Label: name,
Description: desc,
}
}
s.popup.SetSize(termWidth, termHeight)
s.popup.SetItems(items)
s.popup.SetCursor(s.selected)
return s.popup.RenderCentered(termWidth, termHeight)
}
// completeArgs checks whether the input line matches a command with a Complete
// function, calls it, and populates the arg-mode state on success. Returns the
// list of suggestions (empty means no completions available).
func (s *InputComponent) completeArgs(line string) []FuzzyMatch {
parts := strings.SplitN(line, " ", 2)
cmdName := parts[0]
argPrefix := ""
if len(parts) > 1 {
argPrefix = parts[1]
}
cmd := s.findCommandWithComplete(cmdName)
if cmd == nil {
return nil
}
suggestions := cmd.Complete(argPrefix)
if len(suggestions) == 0 {
s.argMode = false
return nil
}
s.argMode = true
s.argCommand = cmdName
s.argSynthCmds = make([]commands.SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, sug := range suggestions {
s.argSynthCmds[i] = commands.SlashCommand{Name: sug}
s.filtered[i] = FuzzyMatch{Command: &s.argSynthCmds[i]}
}
return s.filtered
}
// findCommandWithComplete looks up a command by name that has a non-nil
// Complete function.
func (s *InputComponent) findCommandWithComplete(name string) *commands.SlashCommand {
for i := range s.commands {
if s.commands[i].Name == name && s.commands[i].Complete != nil {
return &s.commands[i]
}
}
return nil
}
// readClipboardImageCmd returns a tea.Cmd that reads an image from the system
// clipboard. The result is delivered as a clipboardImageMsg.
func readClipboardImageCmd() tea.Cmd {
return func() tea.Msg {
img, err := clipboard.ReadImage()
if err != nil {
return clipboardImageMsg{err: err}
}
return clipboardImageMsg{
image: &core.ImageAttachment{
Data: img.Data,
MediaType: img.MediaType,
},
}
}
}
// ClearPendingImages removes all pending image attachments and returns them.
// Used by the parent model when consuming images for submission.
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return images
}
// PendingImageCount returns the number of images currently attached.
func (s *InputComponent) PendingImageCount() int {
return len(s.pendingImages)
}
// Clear clears the textarea content and resets related state. Returns true if
// there was content to clear, false if the input was already empty.
func (s *InputComponent) Clear() bool {
hadContent := s.textarea.Value() != ""
s.textarea.SetValue("")
s.textarea.CursorEnd()
s.lastValue = ""
s.showPopup = false
s.argMode = false
s.fileMode = false
s.fileEditMode = false
s.browsingHistory = false
s.savedInput = ""
return hadContent
}
// applyFileCompletion replaces the @prefix in the textarea with the selected
// file or MCP resource suggestion. For directories, it keeps the popup open
// for further drilling. For files and resources, it closes the popup and adds
// a trailing space.
//
// When fileEditMode is active the same path-replacement happens against the
// /edit (or alias) command prefix instead of an @ trigger. Selecting a file
// also arms submitNext so the next tick runs $EDITOR on it; selecting a
// directory keeps the popup open for drill-down.
func (s *InputComponent) applyFileCompletion(idx int) {
if idx >= len(s.fileSuggestions) {
return
}
suggestion := s.fileSuggestions[idx]
value := s.textarea.Value()
// Build the replacement text. The @ and everything after it up to the
// cursor should be replaced with @<selected path>.
// Find the current line's contribution.
lines := strings.Split(value, "\n")
lastLine := lines[len(lines)-1]
// Reconstruct: everything before the @ on the last line + @<path>
beforeAt := lastLine[:s.fileAtStartIdx]
var replacement string
switch {
case s.fileEditMode:
// /edit path mode — no @ prefix; the path is the bare argument.
// MCP resources are excluded upstream, so only file/dir entries reach here.
needsQuote := strings.Contains(suggestion.RelPath, " ")
if needsQuote {
replacement = `"` + suggestion.RelPath + `"`
} else {
replacement = suggestion.RelPath
}
case suggestion.IsMCPResource:
// MCP resources use @mcp:server:uri format.
// Quote if the URI contains spaces.
ref := "mcp:" + suggestion.MCPServerName + ":" + suggestion.MCPResourceURI
if strings.Contains(ref, " ") {
replacement = `@"` + ref + `"`
} else {
replacement = "@" + ref
}
replacement += " "
default:
needsQuote := strings.Contains(suggestion.RelPath, " ")
if needsQuote {
replacement = `@"` + suggestion.RelPath + `"`
} else {
replacement = "@" + suggestion.RelPath
}
// For files, add a trailing space. For directories, don't — allow
// continued drilling into the directory.
if !suggestion.IsDir {
replacement += " "
}
}
newLastLine := beforeAt + replacement
// Reconstruct the full value with the updated last line.
lines[len(lines)-1] = newLastLine
newValue := strings.Join(lines, "\n")
s.textarea.SetValue(newValue)
s.textarea.CursorEnd()
if suggestion.IsDir && !suggestion.IsMCPResource {
// Keep popup open — trigger a refresh for the new directory.
s.lastValue = "" // force re-evaluation on next update tick
return
}
s.showPopup = false
s.fileMode = false
s.selected = 0
if s.fileEditMode {
// A file was selected via /edit — submit on the next tick so the
// popup dismisses cleanly before $EDITOR takes the terminal.
s.fileEditMode = false
s.submitNext = true
}
}
// updateEditFilePopup queries the file-suggestion engine for the /edit path
// prefix and populates the popup state. cmdLen is the byte offset of the path
// argument within the current line (i.e. length of "/edit " or "/ed ").
// Directories are kept so the user can drill down; MCP resources are skipped.
func (s *InputComponent) updateEditFilePopup(cmdLen int, pathPrefix string) {
var suggestions []FileSuggestion
if s.cwd != "" {
suggestions = GetFileSuggestions(pathPrefix, s.cwd)
}
if len(suggestions) == 0 {
s.showPopup = false
s.fileMode = false
s.fileEditMode = false
return
}
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].Score > suggestions[j].Score
})
if len(suggestions) > maxFileSuggestions {
suggestions = suggestions[:maxFileSuggestions]
}
s.showPopup = true
s.fileMode = true
s.fileEditMode = true
s.argMode = false
s.filePrefix = pathPrefix
s.fileAtStartIdx = cmdLen
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]commands.SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, fs := range suggestions {
name := fs.RelPath
desc := ""
if fs.IsDir {
desc = "directory"
}
s.fileSynthCmds[i] = commands.SlashCommand{Name: name, Description: desc}
s.filtered[i] = FuzzyMatch{Command: &s.fileSynthCmds[i], Score: fs.Score}
}
s.selected = 0
}