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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user