diff --git a/internal/tui/app.go b/internal/tui/app.go index 75abcad..8b19d2e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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] diff --git a/internal/tui/rendering.go b/internal/tui/rendering.go index b03b0e9..6c676b8 100644 --- a/internal/tui/rendering.go +++ b/internal/tui/rendering.go @@ -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.