feat(tui): suggestion box above input, input mode indicator, ! execute

- Suggestion dropdown now renders between separator and input (not in
  chat area) — no more box at the top of an empty chat
- Ghost text suppressed when dropdown is visible (eliminates the
  'fig' / trailing text on the right)
- Bottom separator shows purple 'cmd' label when typing '/' and
  yellow 'exec' label when typing '!'
- '! <cmd>' prefix executes a raw shell command inline and shows
  output in the chat (same as /shell but one-shot)
This commit is contained in:
2026-05-07 17:35:45 +02:00
parent d2139c6f0c
commit befcbdcfef
2 changed files with 72 additions and 20 deletions
+25
View File
@@ -738,6 +738,9 @@ func (m Model) submitInput(input string) (tea.Model, tea.Cmd) {
if strings.HasPrefix(input, "/") {
return m.handleCommand(input)
}
if strings.HasPrefix(input, "!") {
return m.handleBangCommand(strings.TrimPrefix(input, "!"))
}
m.messages = append(m.messages, chatMessage{role: "user", content: input})
m.streaming = true
@@ -754,6 +757,28 @@ func (m Model) submitInput(input string) (tea.Model, tea.Cmd) {
return m, m.listenForEvents()
}
// handleBangCommand runs a raw shell command and shows the output inline.
func (m Model) handleBangCommand(cmd string) (tea.Model, tea.Cmd) {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return m, nil
}
m.messages = append(m.messages, chatMessage{role: "user", content: "! " + cmd})
out, err := exec.Command(shellExe(), "-c", cmd).CombinedOutput()
output := strings.TrimRight(string(out), "\n")
if err != nil {
m.messages = append(m.messages, chatMessage{role: "error",
content: fmt.Sprintf("exit: %v\n%s", err, output)})
} else {
if output == "" {
output = "(no output)"
}
m.messages = append(m.messages, chatMessage{role: "toolresult", content: output})
}
m.scrollOffset = 0
return m, nil
}
func (m Model) handleCommand(cmd string) (tea.Model, tea.Cmd) {
parts := strings.Fields(cmd)
command := parts[0]
+47 -20
View File
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
"image/color"
"os"
"path/filepath"
"strings"
@@ -24,10 +25,17 @@ func (m Model) View() tea.View {
input := m.renderInput()
topLine, bottomLine := m.renderSeparators()
// Fixed: status bar + separator + input + separator = bottom area
// Suggestion dropdown — rendered between topLine and input.
suggestions := ""
if len(m.suggestions) > 0 {
suggestions = m.renderSuggestions()
}
suggestH := lipgloss.Height(suggestions)
// Fixed: status bar + separator + input + separator + suggestions = bottom area
statusH := lipgloss.Height(status)
inputH := lipgloss.Height(input)
chatH := m.height - statusH - inputH - 2
chatH := m.height - statusH - inputH - 2 - suggestH
chat := m.renderChat(chatH)
@@ -37,13 +45,13 @@ func (m Model) View() tea.View {
topLine = indicator + topLine[len(indicator):]
}
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left,
chat,
topLine,
input,
bottomLine,
status,
))
parts := []string{chat, topLine}
if suggestions != "" {
parts = append(parts, suggestions)
}
parts = append(parts, input, bottomLine, status)
v := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, parts...))
if m.copyMode {
v.MouseMode = tea.MouseModeNone
} else {
@@ -108,11 +116,6 @@ func (m Model) renderChat(height int) string {
lines = append(lines, m.renderConfigPanel(m.width)...)
}
// Slash-command suggestion dropdown
if len(m.suggestions) > 0 {
lines = append(lines, m.renderSuggestions()...)
}
// Transient: session resume picker
if m.resumePending && len(m.resumeSessions) > 0 {
lines = append(lines, "")
@@ -503,16 +506,40 @@ func (m Model) renderSeparators() (string, string) {
labelStyle.Render(label) +
lineStyle.Render(strings.Repeat("─", rightW))
// Bottom line: plain colored line
bottomLine := lineStyle.Render(strings.Repeat("─", m.width))
// Bottom line: show input mode indicator when typing / or !
inputVal := m.input.Value()
var inputModeLabel string
var inputModeColor color.Color
switch {
case strings.HasPrefix(inputVal, "/"):
inputModeLabel = " cmd "
inputModeColor = cPurple
case strings.HasPrefix(inputVal, "!"):
inputModeLabel = " exec "
inputModeColor = cYellow
}
var bottomLine string
if inputModeLabel != "" {
imStyle := lipgloss.NewStyle().Foreground(inputModeColor).Bold(true)
imW := lipgloss.Width(imStyle.Render(inputModeLabel))
fillW := m.width - imW
if fillW < 0 {
fillW = 0
}
bottomLine = lipgloss.NewStyle().Foreground(cSurface).Render(strings.Repeat("─", fillW)) +
imStyle.Render(inputModeLabel)
} else {
bottomLine = lineStyle.Render(strings.Repeat("─", m.width))
}
return topLine, bottomLine
}
func (m Model) renderInput() string {
view := m.input.View()
if m.suggestion != "" {
// Show the untyped remainder as dim ghost text.
// Ghost text only when there's no dropdown (dropdown handles completion when visible)
if m.suggestion != "" && len(m.suggestions) == 0 {
rest := strings.TrimPrefix(m.suggestion, m.input.Value())
if rest != "" {
ghost := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(rest + " (tab)")
@@ -624,7 +651,7 @@ func formatTurnUsage(u message.Usage) string {
}
// renderSuggestions renders the slash-command autocomplete dropdown.
func (m Model) renderSuggestions() []string {
func (m Model) renderSuggestions() string {
const maxVisible = 6
sCmd := lipgloss.NewStyle().Foreground(cPurple).Bold(true)
@@ -699,7 +726,7 @@ func (m Model) renderSuggestions() []string {
Width(innerW + 2).
Render(strings.Join(bodyLines, "\n"))
return []string{box}
return box
}
// renderConfigPanel renders the interactive /config settings overlay.