Files
kit/internal/ui/input.go
T
2026-03-26 12:10:14 +03:00

788 lines
24 KiB
Go
Raw 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"
"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"
)
// 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 []SlashCommand
showPopup bool
filtered []FuzzyMatch
selected int
width int
lastValue string
popupHeight int
title string
submitNext bool // defer submit one tick so popup dismisses cleanly
// 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 []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 @ in the textarea value
fileSuggestions []FileSuggestion // backing storage for file entries
fileSynthCmds []SlashCommand // synthetic SlashCommands wrapping file entries
// cwd is the working directory used for @file path resolution and
// autocomplete suggestions. Set by the parent via SetCwd.
cwd string
// 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+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 []ImageAttachment
// 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 *ImageAttachment
err error
}
// NewInputComponent creates a new InputComponent with the given width, title,
// and optional AppController. If appCtrl is nil the component still works but
// /clear and /clear-queue are no-ops.
func NewInputComponent(width int, title string, appCtrl AppController) *InputComponent {
ta := textarea.New()
ta.Placeholder = "Type your message..."
ta.ShowLineNumbers = false
ta.Prompt = ""
ta.CharLimit = 5000
ta.SetWidth(width - 8) // Account for container padding, border and internal padding
ta.SetHeight(3) // Default to 3 lines like huh
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 := 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)
return &InputComponent{
textarea: ta,
commands: SlashCommands,
width: width,
popupHeight: 7,
title: title,
appCtrl: appCtrl,
}
}
// 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
}
// 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 {
s.pendingImages = append(s.pendingImages, *msg.image)
}
return s, nil
case tea.KeyPressMsg:
if !s.showPopup {
switch msg.String() {
case "ctrl+d", "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
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
}
// Populate textarea with selected item and submit on next tick.
if s.argMode {
s.textarea.SetValue(s.argCommand + " " + s.filtered[s.selected].Command.Name)
} else {
s.textarea.SetValue(s.filtered[s.selected].Command.Name)
}
s.textarea.CursorEnd()
s.showPopup = false
s.selected = 0
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 && s.cwd != "" {
suggestions := GetFileSuggestions(prefix, s.cwd)
if len(suggestions) > 0 {
s.showPopup = true
s.fileMode = true
s.argMode = false
s.filePrefix = prefix
s.fileAtStartIdx = atIdx
s.fileSuggestions = suggestions
s.fileSynthCmds = make([]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] = 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
}
} else if len(lines) == 1 && strings.HasPrefix(lines[0], "/") {
s.fileMode = false
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
}
}
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 shellCommandMsg{Command: cmd, ExcludeFromContext: true}
}
}
} else if strings.HasPrefix(trimmed, "!") {
cmd := strings.TrimSpace(trimmed[1:])
if cmd != "" {
return func() tea.Msg {
return shellCommandMsg{Command: cmd, ExcludeFromContext: false}
}
}
}
// Resolve via canonical command lookup so aliases are handled uniformly.
// Only /quit and /clear are handled locally — /clear-queue must go
// through the parent model so it can update queueCount directly
// (calling ClearQueue here would skip the UI state update since we
// can't send events from within Update without deadlocking).
if sc := GetCommandByName(trimmed); sc != nil {
switch sc.Name {
case "/quit":
return tea.Quit
case "/clear":
if s.appCtrl != nil {
s.appCtrl.ClearMessages()
}
// Don't forward to app.Run(); just clear silently.
return nil
}
}
// 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
return func() tea.Msg {
return 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 = ""
}
// View implements tea.Model. Renders the title, textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
containerStyle := lipgloss.NewStyle()
theme := GetTheme()
// PaddingLeft(3) aligns with message content: border(1) + paddingLeft(2).
titleStyle := lipgloss.NewStyle().
Foreground(theme.Text).
MarginBottom(1).
PaddingLeft(3)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.ThickBorder()).
BorderLeft(true).
BorderRight(false).
BorderTop(false).
BorderBottom(false).
BorderForeground(theme.Primary).
PaddingLeft(2). // match message block paddingLeft
Width(s.width - 1) // full width minus left border
var view strings.Builder
view.WriteString(titleStyle.Render(s.title))
view.WriteString("\n")
view.WriteString(inputBoxStyle.Render(s.textarea.View()))
if s.showPopup && len(s.filtered) > 0 {
view.WriteString("\n")
view.WriteString(s.renderPopup())
}
// Show image attachment indicator when images are pending.
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))
}
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 >= 55 {
hint = "enter queue • ctrl+s steer • esc esc cancel"
} else if availableHintWidth >= 35 {
hint = "↵ queue • ^S steer • esc×2 cancel"
} else {
hint = "^S steer"
}
} else if availableHintWidth >= 67 {
hint = "enter submit • ctrl+j / shift+enter new line • ctrl+v paste image"
} else if availableHintWidth >= 40 {
hint = "↵ submit • ctrl+j newline • ctrl+v image"
} else if availableHintWidth >= 20 {
hint = "↵ submit • ctrl+j"
} else {
hint = "↵ submit"
}
view.WriteString("\n")
view.WriteString(helpStyle.Render(hint))
}
return tea.NewView(containerStyle.Render(view.String()))
}
// renderPopup renders the autocomplete popup for slash command suggestions.
func (s *InputComponent) renderPopup() string {
theme := GetTheme()
popupWidth := max(s.width-4, 20)
popupStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.MutedBorder).
Padding(1, 2).
Width(popupWidth).
MarginLeft(0)
// Inner content width: popup minus border (2) and horizontal padding (4).
innerWidth := max(popupWidth-6, 10)
var items []string
visibleItems := min(len(s.filtered), s.popupHeight)
startIdx := 0
if s.selected >= s.popupHeight {
startIdx = s.selected - s.popupHeight + 1
}
endIdx := min(startIdx+visibleItems, len(s.filtered))
for i := startIdx; i < endIdx; i++ {
match := s.filtered[i]
sc := match.Command
var indicator string
if i == s.selected {
indicator = lipgloss.NewStyle().Foreground(theme.Primary).Render("> ")
} else {
indicator = " "
}
nameStyle := lipgloss.NewStyle().Foreground(theme.Secondary).Bold(true)
descStyle := lipgloss.NewStyle().Foreground(theme.Muted)
if i == s.selected {
nameStyle = nameStyle.Foreground(theme.Primary)
descStyle = descStyle.Foreground(theme.Text)
}
if s.fileMode {
// File mode: use full width for the path, show description
// (e.g. "directory") inline after a gap.
maxNameLen := max(innerWidth-16, 8)
displayName := sc.Name
if len(displayName) > maxNameLen && maxNameLen > 3 {
displayName = displayName[:maxNameLen-3] + "..."
}
name := nameStyle.Render(displayName)
if sc.Description != "" && innerWidth > 30 {
items = append(items, indicator+name+" "+descStyle.Render(sc.Description))
} else {
items = append(items, indicator+name)
}
} else {
// Line layout: indicator(2) + name(nameWidth-2 visual) + desc.
if innerWidth < 20 {
// Very narrow: show truncated name only, no fixed column.
displayName := sc.Name
maxName := max(innerWidth-2, 3)
if len(displayName) > maxName {
displayName = displayName[:maxName-1] + "…"
}
items = append(items, indicator+nameStyle.Render(displayName))
} else {
nameWidth := 15
if innerWidth < 25 {
nameWidth = max(innerWidth*2/5+1, 8)
}
maxNameChars := nameWidth - 2
displayName := sc.Name
if len(displayName) > maxNameChars {
displayName = displayName[:maxNameChars-1] + "…"
}
name := nameStyle.Width(maxNameChars).Render(displayName)
// Description gets remaining space.
maxDescLen := max(innerWidth-nameWidth, 0)
desc := sc.Description
if maxDescLen < 4 {
items = append(items, indicator+name)
} else {
if len(desc) > maxDescLen {
desc = desc[:maxDescLen-3] + "..."
}
items = append(items, indicator+name+descStyle.Render(desc))
}
}
}
}
if startIdx > 0 {
items = append([]string{lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↑ more above")}, items...)
}
if endIdx < len(s.filtered) {
items = append(items, lipgloss.NewStyle().Foreground(theme.VeryMuted).Render(" ↓ more below"))
}
content := strings.Join(items, "\n")
// Adapt footer text to available width.
var footerText string
if innerWidth >= 50 {
footerText = "↑↓ navigate • tab complete • ↵ select • esc dismiss"
} else if innerWidth >= 30 {
footerText = "↑↓ nav • tab • ↵ select • esc"
} else {
footerText = "↑↓ tab ↵ esc"
}
footer := lipgloss.NewStyle().Foreground(theme.VeryMuted).Italic(true).
Render(footerText)
return popupStyle.Render(content + "\n\n" + footer)
}
// 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([]SlashCommand, len(suggestions))
s.filtered = make([]FuzzyMatch, len(suggestions))
for i, sug := range suggestions {
s.argSynthCmds[i] = 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) *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: &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() []ImageAttachment {
images := s.pendingImages
s.pendingImages = nil
return images
}
// PendingImageCount returns the number of images currently attached.
func (s *InputComponent) PendingImageCount() int {
return len(s.pendingImages)
}
// applyFileCompletion replaces the @prefix in the textarea with the selected
// file suggestion. For directories, it keeps the popup open for further
// drilling. For files, it closes the popup and adds a trailing space.
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]
needsQuote := strings.Contains(suggestion.RelPath, " ")
var replacement string
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 {
// Keep popup open — trigger a refresh for the new directory.
s.lastValue = "" // force re-evaluation on next update tick
} else {
s.showPopup = false
s.fileMode = false
s.selected = 0
}
}