feat(tui): codex-style input, history, bang commands, single-line status bar
- Arrow-key input history (up/down with multiline awareness) - Bang shell commands via ! prefix (requires --yolo) - Codex-style input bar with mode indicator (R/Y/⌘) - Command palette virtual scrolling - Multiline input viewport fix - Merged model pills + footer into single status line - Removed shift+enter (wait for TUI library support) - Removed default footer hints
This commit is contained in:
+421
-95
@@ -50,6 +50,7 @@ type ModelManager interface {
|
||||
Rename(name string) (string, error)
|
||||
Sessions() (string, error)
|
||||
NewSession() (string, error)
|
||||
AvailableModels() []string
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, assistant *agent.Agent, opts Options) error {
|
||||
@@ -90,12 +91,13 @@ type model struct {
|
||||
width int
|
||||
height int
|
||||
|
||||
messages []message
|
||||
status string
|
||||
contextUsage string
|
||||
inputHistory []string
|
||||
historyIndex int
|
||||
draftInput string
|
||||
messages []message
|
||||
status string
|
||||
contextUsage string
|
||||
inputHistory []string
|
||||
historyIndex int
|
||||
draftInput string
|
||||
commandCursor int
|
||||
|
||||
running bool
|
||||
cancel context.CancelFunc
|
||||
@@ -133,6 +135,13 @@ type commandMatch struct {
|
||||
score int
|
||||
}
|
||||
|
||||
type commandSuggestion struct {
|
||||
label string
|
||||
description string
|
||||
value string
|
||||
executable bool
|
||||
}
|
||||
|
||||
var slashCommands = []slashCommand{
|
||||
{Name: "/clear", Description: "reset context"},
|
||||
{Name: "/compact", Description: "compress context"},
|
||||
@@ -157,7 +166,7 @@ func newModel(ctx context.Context, assistant *agent.Agent, opts Options) model {
|
||||
st := th.styles()
|
||||
|
||||
input := textarea.New()
|
||||
input.Placeholder = "Message agentu..."
|
||||
input.Placeholder = ""
|
||||
input.Prompt = ""
|
||||
input.ShowLineNumbers = false
|
||||
input.MaxHeight = maxInputLines
|
||||
@@ -197,8 +206,6 @@ func (m model) Init() tea.Cmd {
|
||||
m.syncMessages()
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
// syncMessages rebuilds the TUI display messages from the agent's conversation history.
|
||||
func (m *model) syncMessages() {
|
||||
if m.agent == nil {
|
||||
return
|
||||
@@ -246,6 +253,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.picking {
|
||||
return m.updatePicker(msg)
|
||||
}
|
||||
if handled, next, cmd := m.updateCommandSuggestions(msg); handled {
|
||||
return next, cmd
|
||||
}
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
if m.cancel != nil {
|
||||
@@ -263,6 +273,29 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m.submit()
|
||||
case "up":
|
||||
if m.running {
|
||||
return m, nil
|
||||
}
|
||||
if m.input.Line() == 0 {
|
||||
m.previousInputHistory()
|
||||
m.afterInputChanged()
|
||||
return m, nil
|
||||
}
|
||||
case "down":
|
||||
if m.running {
|
||||
return m, nil
|
||||
}
|
||||
value := m.input.Value()
|
||||
lineCount := 1
|
||||
if value != "" {
|
||||
lineCount = len(strings.Split(value, "\n"))
|
||||
}
|
||||
if m.input.Line() >= lineCount-1 {
|
||||
m.nextInputHistory()
|
||||
m.afterInputChanged()
|
||||
return m, nil
|
||||
}
|
||||
case "ctrl+p":
|
||||
if m.running {
|
||||
return m, nil
|
||||
@@ -277,7 +310,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.nextInputHistory()
|
||||
m.afterInputChanged()
|
||||
return m, nil
|
||||
case "alt+enter", "ctrl+j":
|
||||
case "ctrl+j":
|
||||
return m.insertInputNewline()
|
||||
}
|
||||
|
||||
@@ -338,7 +371,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if !m.running {
|
||||
nextInput, cmd := m.input.Update(msg)
|
||||
m.input = nextInput
|
||||
m.afterInputChanged()
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
@@ -356,7 +388,7 @@ func (m model) View() string {
|
||||
|
||||
if m.picking {
|
||||
parts := []string{m.viewport.View()}
|
||||
parts = append(parts, m.pickerView(), m.modelMetaView(), m.footerView())
|
||||
parts = append(parts, m.pickerView(), m.footerView())
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
return m.styles.App.Width(m.width).Height(m.height).Render(body)
|
||||
}
|
||||
@@ -365,7 +397,7 @@ func (m model) View() string {
|
||||
if activity := m.activityView(); activity != "" {
|
||||
parts = append(parts, activity)
|
||||
}
|
||||
parts = append(parts, m.inputView(), m.modelMetaView(), m.footerView())
|
||||
parts = append(parts, m.inputView(), m.footerView())
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, parts...)
|
||||
return m.styles.App.Width(m.width).Height(m.height).Render(body)
|
||||
}
|
||||
@@ -393,6 +425,10 @@ func (m *model) submit() (tea.Model, tea.Cmd) {
|
||||
return m.runLocalCommand(input)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "!") {
|
||||
return m.runLocalCommand(input)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "/") {
|
||||
return m.runLocalCommand(input)
|
||||
}
|
||||
@@ -438,6 +474,16 @@ func (m *model) insertInputNewline() (tea.Model, tea.Cmd) {
|
||||
m.historyIndex = -1
|
||||
m.draftInput = ""
|
||||
m.afterInputChanged()
|
||||
// The textarea's repositionView was called inside Update with the old
|
||||
// viewport height, so the YOffset may be stale after syncInputHeight
|
||||
// changed the height. Send up then down to force repositionView to
|
||||
// recalculate with the new height.
|
||||
up := tea.KeyMsg{Type: tea.KeyUp}
|
||||
vp, _ := m.input.Update(up)
|
||||
m.input = vp
|
||||
down := tea.KeyMsg{Type: tea.KeyDown}
|
||||
vp, _ = m.input.Update(down)
|
||||
m.input = vp
|
||||
return *m, cmd
|
||||
}
|
||||
|
||||
@@ -467,7 +513,7 @@ func (m *model) appendAssistantChunk(chunk string) {
|
||||
|
||||
func (m *model) resize() {
|
||||
width := max(40, m.width)
|
||||
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize())
|
||||
inputWidth := max(20, width-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1)
|
||||
m.input.SetWidth(inputWidth)
|
||||
m.syncInputHeight()
|
||||
|
||||
@@ -668,72 +714,25 @@ type metaPill struct {
|
||||
accent bool
|
||||
}
|
||||
|
||||
func (m model) renderMetaPills(parts []metaPill) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
func (m model) inputModeView() string {
|
||||
if strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!") {
|
||||
return m.styles.InputModeCommand.Render(iconCommand)
|
||||
}
|
||||
|
||||
plain := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
plain[i] = part.text
|
||||
if m.yolo {
|
||||
return m.styles.InputModeYolo.Render("Y")
|
||||
}
|
||||
|
||||
width := max(1, m.width-2)
|
||||
visible := len(plain)
|
||||
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
visible--
|
||||
}
|
||||
if lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
return m.styles.PillAccent.Render(fitLine(plain[0], width))
|
||||
}
|
||||
|
||||
rendered := make([]string, 0, visible)
|
||||
for i := 0; i < visible; i++ {
|
||||
style := m.styles.Pill
|
||||
if parts[i].accent {
|
||||
style = m.styles.PillAccent
|
||||
}
|
||||
rendered = append(rendered, style.Render(parts[i].text))
|
||||
}
|
||||
return strings.Join(rendered, " ")
|
||||
return m.styles.InputModeReadonly.Render("R")
|
||||
}
|
||||
|
||||
func (m model) modelMetaView() string {
|
||||
mode := "read-only tools"
|
||||
if m.yolo {
|
||||
mode = "yolo tools"
|
||||
}
|
||||
|
||||
modelName := m.modelName
|
||||
if m.models != nil {
|
||||
if currentModel := m.models.CurrentModel(); currentModel != "" {
|
||||
modelName = currentModel
|
||||
}
|
||||
}
|
||||
|
||||
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
|
||||
if m.models != nil {
|
||||
if provider := m.models.CurrentProvider(); provider != "" {
|
||||
parts = append(parts, metaPill{text: "provider " + provider})
|
||||
}
|
||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||
parts = append(parts, metaPill{text: "thinking " + currentThinking})
|
||||
}
|
||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
|
||||
}
|
||||
}
|
||||
if m.contextUsage != "" {
|
||||
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
|
||||
}
|
||||
parts = append(parts, metaPill{text: mode}, metaPill{text: "theme " + string(m.theme.Mode)})
|
||||
|
||||
return m.styles.ModelMeta.Width(max(1, m.width)).Render(m.renderMetaPills(parts))
|
||||
func (m model) inputModeWidth() int {
|
||||
return lipgloss.Width(m.inputModeView())
|
||||
}
|
||||
|
||||
func (m model) inputView() string {
|
||||
palette := m.commandPaletteView()
|
||||
box := m.styles.InputBox.Width(max(20, m.width-2)).Render(m.input.View())
|
||||
gap := lipgloss.NewStyle().Background(m.styles.InputBox.GetBackground()).Render(" ")
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, m.inputModeView(), gap, m.input.View())
|
||||
box := m.styles.InputBox.Width(max(20, m.width)).Render(content)
|
||||
if palette != "" {
|
||||
return lipgloss.JoinVertical(lipgloss.Left, palette, box)
|
||||
}
|
||||
@@ -751,13 +750,85 @@ func (m model) activityView() string {
|
||||
line := m.styles.StatusInfo.Render(iconWorking) + " Working " + m.spinner.View() + " " + status + " · esc cancels"
|
||||
return m.styles.Activity.Render(fitLine(line, max(1, m.width-2)))
|
||||
}
|
||||
|
||||
func (m model) footerView() string {
|
||||
hint := "enter send · ctrl+j newline · alt+enter newline · ctrl+p/ctrl+n history · / commands"
|
||||
hint := ""
|
||||
if m.running {
|
||||
hint = "esc cancel · ctrl+c quit"
|
||||
}
|
||||
return m.styles.Footer.Width(max(1, m.width)).Render(fitLine(hint, max(1, m.width-2)))
|
||||
if m.showCommandPalette() {
|
||||
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
|
||||
hint = "↑/↓ select · enter apply · tab fill"
|
||||
}
|
||||
}
|
||||
|
||||
inner := max(1, m.width-2) // Footer padding(0,1)
|
||||
pills := m.modelMetaPillsClamped(inner / 3)
|
||||
pillsW := lipgloss.Width(pills)
|
||||
|
||||
var line string
|
||||
if pills != "" {
|
||||
remain := max(1, inner-pillsW-1)
|
||||
line = pills + " " + fitLine(hint, remain)
|
||||
} else {
|
||||
line = fitLine(hint, inner)
|
||||
}
|
||||
return m.styles.Footer.Width(max(1, m.width)).Render(line)
|
||||
}
|
||||
|
||||
func (m model) modelMetaPillsClamped(maxW int) string {
|
||||
modelName := m.modelName
|
||||
if m.models != nil {
|
||||
if currentModel := m.models.CurrentModel(); currentModel != "" {
|
||||
modelName = currentModel
|
||||
}
|
||||
}
|
||||
|
||||
parts := []metaPill{{text: iconModel + " " + modelName, accent: true}}
|
||||
if m.models != nil {
|
||||
if provider := m.models.CurrentProvider(); provider != "" {
|
||||
parts = append(parts, metaPill{text: provider})
|
||||
}
|
||||
if currentThinking := m.models.CurrentThinking(); currentThinking != "" && currentThinking != "unset" {
|
||||
parts = append(parts, metaPill{text: currentThinking})
|
||||
}
|
||||
if sessionName := m.models.CurrentSessionName(); sessionName != "" {
|
||||
parts = append(parts, metaPill{text: iconSession + " " + sessionName})
|
||||
}
|
||||
}
|
||||
if m.contextUsage != "" {
|
||||
parts = append(parts, metaPill{text: iconContext + " " + m.contextUsage})
|
||||
}
|
||||
|
||||
return m.renderMetaPillsClamped(parts, maxW)
|
||||
}
|
||||
|
||||
func (m model) renderMetaPillsClamped(parts []metaPill, width int) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
plain := make([]string, len(parts))
|
||||
for i, part := range parts {
|
||||
plain[i] = part.text
|
||||
}
|
||||
|
||||
visible := len(plain)
|
||||
for visible > 1 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
visible--
|
||||
}
|
||||
if visible > 0 && lipgloss.Width(strings.Join(plain[:visible], " ")) > width {
|
||||
return ""
|
||||
}
|
||||
|
||||
rendered := make([]string, 0, visible)
|
||||
for i := 0; i < visible; i++ {
|
||||
style := m.styles.Pill
|
||||
if parts[i].accent {
|
||||
style = m.styles.PillAccent
|
||||
}
|
||||
rendered = append(rendered, style.Render(parts[i].text))
|
||||
}
|
||||
return strings.Join(rendered, " ")
|
||||
}
|
||||
|
||||
type eventWriter struct {
|
||||
@@ -801,6 +872,7 @@ func isViewportKey(key string) bool {
|
||||
}
|
||||
|
||||
func (m *model) afterInputChanged() {
|
||||
m.commandCursor = 0
|
||||
m.syncInputHeight()
|
||||
if m.width > 0 && m.height > 0 {
|
||||
m.resize()
|
||||
@@ -882,18 +954,37 @@ func (m model) inputBlockHeight() int {
|
||||
}
|
||||
|
||||
func (m model) commandPaletteHeight() int {
|
||||
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||
count := len(matches)
|
||||
if count > maxCommandPaletteItems {
|
||||
count = maxCommandPaletteItems
|
||||
sugs := m.commandSuggestions()
|
||||
total := len(sugs)
|
||||
if !m.showCommandPalette() {
|
||||
return 0
|
||||
}
|
||||
count := min(total, maxCommandPaletteItems)
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
return 1 + count + m.styles.Palette.GetVerticalFrameSize()
|
||||
scrollUp := 0
|
||||
scrollDown := 0
|
||||
if total > maxCommandPaletteItems {
|
||||
visible := min(total, maxCommandPaletteItems)
|
||||
scroll := max(0, m.commandCursor-visible+1)
|
||||
if m.commandCursor < scroll {
|
||||
scroll = m.commandCursor
|
||||
}
|
||||
if scroll > 0 {
|
||||
scrollUp = 1
|
||||
}
|
||||
if scroll+visible < total {
|
||||
scrollDown = 1
|
||||
}
|
||||
}
|
||||
return 1 + scrollUp + count + scrollDown + m.styles.Palette.GetVerticalFrameSize()
|
||||
}
|
||||
|
||||
func (m model) showCommandPalette() bool {
|
||||
if len(m.commandSuggestions()) > 0 {
|
||||
return true
|
||||
}
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
return strings.HasPrefix(value, "/") && !strings.Contains(value, " ")
|
||||
}
|
||||
@@ -902,18 +993,42 @@ func (m model) commandPaletteView() string {
|
||||
if !m.showCommandPalette() {
|
||||
return ""
|
||||
}
|
||||
matches := commandMatches(commandPaletteQuery(m.input.Value()))
|
||||
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
|
||||
if len(matches) == 0 {
|
||||
lines = append(lines, m.styles.Muted.Render("No matching commands"))
|
||||
} else {
|
||||
for i, match := range matches {
|
||||
if i >= maxCommandPaletteItems {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s %s", m.styles.PaletteMatch.Render(match.command.Name), m.styles.Muted.Render(match.command.Description)))
|
||||
sugs := m.commandSuggestions()
|
||||
if len(sugs) > 0 {
|
||||
header := iconCommand + " Commands"
|
||||
if sugs[0].executable {
|
||||
header = iconCommand + " Model candidates"
|
||||
}
|
||||
lines := []string{m.styles.Header.Render(header)}
|
||||
m.clampCommandCursor()
|
||||
total := len(sugs)
|
||||
visible := min(total, maxCommandPaletteItems)
|
||||
scroll := max(0, m.commandCursor-visible+1)
|
||||
if m.commandCursor < scroll {
|
||||
scroll = m.commandCursor
|
||||
}
|
||||
if scroll > 0 {
|
||||
lines = append(lines, m.styles.Muted.Render(" ↑ more"))
|
||||
}
|
||||
for i := scroll; i < scroll+visible && i < total; i++ {
|
||||
s := sugs[i]
|
||||
prefix := " "
|
||||
if i == m.commandCursor {
|
||||
prefix = "▸ "
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s%s %s", prefix, m.styles.PaletteMatch.Render(s.label), m.styles.Muted.Render(s.description)))
|
||||
}
|
||||
if scroll+visible < total {
|
||||
lines = append(lines, m.styles.Muted.Render(" ↓ more"))
|
||||
}
|
||||
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
value := strings.TrimSpace(m.input.Value())
|
||||
if strings.Contains(value, " ") {
|
||||
return ""
|
||||
}
|
||||
lines := []string{m.styles.Header.Render(iconCommand + " Commands")}
|
||||
lines = append(lines, m.styles.Muted.Render("No matching commands"))
|
||||
return m.styles.Palette.Width(max(20, m.width-2)).Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
@@ -991,6 +1106,162 @@ func subsequenceScore(candidate string, query string, base int) (int, bool) {
|
||||
return base + gaps, true
|
||||
}
|
||||
|
||||
func (m model) commandSuggestions() []commandSuggestion {
|
||||
input := m.input.Value()
|
||||
if sugs := m.modelCommandSuggestions(input); len(sugs) > 0 {
|
||||
return sugs
|
||||
}
|
||||
value := strings.TrimSpace(input)
|
||||
if strings.HasPrefix(value, "/") && !strings.Contains(value, " ") {
|
||||
return slashCommandSuggestions(commandPaletteQuery(input))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slashCommandSuggestions(query string) []commandSuggestion {
|
||||
matches := commandMatches(query)
|
||||
sugs := make([]commandSuggestion, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
sugs = append(sugs, commandSuggestion{
|
||||
label: m.command.Name,
|
||||
description: m.command.Description,
|
||||
value: m.command.Name,
|
||||
})
|
||||
}
|
||||
return sugs
|
||||
}
|
||||
|
||||
func splitModelCommandTail(input string) (query string, ok bool) {
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) == 0 || fields[0] != "/model" {
|
||||
return "", false
|
||||
}
|
||||
hasTrailingSpace := strings.TrimRight(input, " \t") != input
|
||||
switch len(fields) {
|
||||
case 1:
|
||||
if !hasTrailingSpace {
|
||||
return "", false
|
||||
}
|
||||
return "", true
|
||||
case 2:
|
||||
if fields[1] == "provider" || fields[1] == "thinking" {
|
||||
return "", false
|
||||
}
|
||||
if fields[1] == "model" {
|
||||
if !hasTrailingSpace {
|
||||
return "", false
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
if hasTrailingSpace {
|
||||
return fields[1], true
|
||||
}
|
||||
return fields[1], true
|
||||
case 3:
|
||||
if fields[1] != "model" {
|
||||
return "", false
|
||||
}
|
||||
if hasTrailingSpace {
|
||||
return fields[2], true
|
||||
}
|
||||
return fields[2], true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) modelCommandSuggestions(input string) []commandSuggestion {
|
||||
if m.models == nil {
|
||||
return nil
|
||||
}
|
||||
query, ok := splitModelCommandTail(input)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
available := m.models.AvailableModels()
|
||||
if len(available) == 0 {
|
||||
return nil
|
||||
}
|
||||
current := m.models.CurrentModel()
|
||||
candidates := make([]commandSuggestion, 0, len(available))
|
||||
for _, id := range available {
|
||||
desc := "switch model"
|
||||
if id == current {
|
||||
desc = "current model"
|
||||
}
|
||||
candidates = append(candidates, commandSuggestion{
|
||||
label: id,
|
||||
description: desc,
|
||||
value: "/model model " + id,
|
||||
executable: true,
|
||||
})
|
||||
}
|
||||
return scoredCommandSuggestions(candidates, query)
|
||||
}
|
||||
|
||||
func scoredCommandSuggestions(candidates []commandSuggestion, query string) []commandSuggestion {
|
||||
if query == "" {
|
||||
return candidates
|
||||
}
|
||||
type scored struct {
|
||||
sug commandSuggestion
|
||||
score int
|
||||
idx int
|
||||
}
|
||||
var kept []scored
|
||||
for i, c := range candidates {
|
||||
if s, ok := scoreCommandSuggestion(c.label, query); ok {
|
||||
kept = append(kept, scored{sug: c, score: s, idx: i})
|
||||
}
|
||||
}
|
||||
sort.Slice(kept, func(i, j int) bool {
|
||||
if kept[i].score != kept[j].score {
|
||||
return kept[i].score < kept[j].score
|
||||
}
|
||||
return kept[i].idx < kept[j].idx
|
||||
})
|
||||
out := make([]commandSuggestion, 0, len(kept))
|
||||
for _, s := range kept {
|
||||
out = append(out, s.sug)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreCommandSuggestion(label string, query string) (int, bool) {
|
||||
query = strings.ToLower(strings.TrimSpace(query))
|
||||
name := strings.ToLower(label)
|
||||
if query == "" || query == name {
|
||||
return 0, true
|
||||
}
|
||||
if strings.HasPrefix(name, query) {
|
||||
return 100 + len(name) - len(query), true
|
||||
}
|
||||
return subsequenceScore(name, query, 200)
|
||||
}
|
||||
|
||||
func (m model) selectedCommandSuggestion() (commandSuggestion, bool) {
|
||||
sugs := m.commandSuggestions()
|
||||
m.clampCommandCursor()
|
||||
if m.commandCursor >= 0 && m.commandCursor < len(sugs) {
|
||||
return sugs[m.commandCursor], true
|
||||
}
|
||||
return commandSuggestion{}, false
|
||||
}
|
||||
|
||||
func (m *model) clampCommandCursor() {
|
||||
sugs := m.commandSuggestions()
|
||||
if len(sugs) == 0 {
|
||||
m.commandCursor = 0
|
||||
return
|
||||
}
|
||||
if m.commandCursor < 0 {
|
||||
m.commandCursor = 0
|
||||
}
|
||||
if m.commandCursor >= len(sugs) {
|
||||
m.commandCursor = len(sugs) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) slashSuggestionCount() int {
|
||||
return m.commandPaletteHeight()
|
||||
}
|
||||
@@ -1007,11 +1278,7 @@ func (m model) modelInfo() string {
|
||||
if m.models != nil {
|
||||
return m.models.Info()
|
||||
}
|
||||
mode := "read-only tools"
|
||||
if m.yolo {
|
||||
mode = "yolo tools"
|
||||
}
|
||||
return fmt.Sprintf("model: %s\nmode: %s\ntheme: %s", m.modelName, mode, m.theme.Mode)
|
||||
return m.modelName
|
||||
}
|
||||
|
||||
func (m *model) refreshContextUsage() {
|
||||
@@ -1146,7 +1413,31 @@ func (m *model) handleThemeCommand(input string) (tea.Model, tea.Cmd) {
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m model) handleBangCommand(input string) (string, error) {
|
||||
command := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), "!"))
|
||||
if command == "" {
|
||||
return "", fmt.Errorf("usage: ! <command>")
|
||||
}
|
||||
if !m.yolo {
|
||||
return "", fmt.Errorf("shell command input requires --yolo")
|
||||
}
|
||||
out, err := m.runShellCommand(command)
|
||||
if err != nil {
|
||||
if strings.TrimSpace(out) != "" {
|
||||
return "", fmt.Errorf("%s\n%w", strings.TrimRight(out, "\n"), err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return fmt.Sprintf("$ %s\n(no output)", command), nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m model) handleLocalCommand(input string) (string, error) {
|
||||
if strings.HasPrefix(strings.TrimSpace(input), "!") {
|
||||
return m.handleBangCommand(input)
|
||||
}
|
||||
fields := strings.Fields(input)
|
||||
if len(fields) == 0 {
|
||||
return "", fmt.Errorf("empty command")
|
||||
@@ -1342,6 +1633,41 @@ func (m *model) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *model) updateCommandSuggestions(msg tea.KeyMsg) (bool, tea.Model, tea.Cmd) {
|
||||
if m.running || len(m.commandSuggestions()) == 0 {
|
||||
return false, *m, nil
|
||||
}
|
||||
m.clampCommandCursor()
|
||||
switch msg.String() {
|
||||
case "up":
|
||||
if m.commandCursor > 0 {
|
||||
m.commandCursor--
|
||||
}
|
||||
return true, *m, nil
|
||||
case "down":
|
||||
if m.commandCursor < len(m.commandSuggestions())-1 {
|
||||
m.commandCursor++
|
||||
}
|
||||
return true, *m, nil
|
||||
case "tab":
|
||||
if sug, ok := m.selectedCommandSuggestion(); ok {
|
||||
m.input.SetValue(sug.value)
|
||||
m.afterInputChanged()
|
||||
}
|
||||
return true, *m, nil
|
||||
case "enter":
|
||||
if sug, ok := m.selectedCommandSuggestion(); ok && sug.executable {
|
||||
m.rememberInput(sug.value)
|
||||
m.input.SetValue(sug.value)
|
||||
m.afterInputChanged()
|
||||
next, cmd := m.runLocalCommand(sug.value)
|
||||
return true, next, cmd
|
||||
}
|
||||
return false, *m, nil
|
||||
}
|
||||
return false, *m, nil
|
||||
}
|
||||
|
||||
func (m model) pickerView() string {
|
||||
if !m.picking || len(m.pickerItems) == 0 {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user