diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index d552b78ece4..ba350ea8147 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -132,6 +132,7 @@ const ( SessionInterruptCommand CommandName = "session_interrupt" SessionCompactCommand CommandName = "session_compact" SessionExportCommand CommandName = "session_export" + HistorySearchCommand CommandName = "history_search" ToolDetailsCommand CommandName = "tool_details" ThinkingBlocksCommand CommandName = "thinking_blocks" ModelListCommand CommandName = "model_list" @@ -210,6 +211,11 @@ func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) Keybindings: parseBindings("x"), Trigger: []string{"export"}, }, + { + Name: HistorySearchCommand, + Description: "search history", + Keybindings: parseBindings("ctrl+r"), + }, { Name: SessionNewCommand, Description: "new session", diff --git a/packages/tui/internal/completions/history.go b/packages/tui/internal/completions/history.go new file mode 100644 index 00000000000..c9177ee0275 --- /dev/null +++ b/packages/tui/internal/completions/history.go @@ -0,0 +1,60 @@ +package completions + +import ( + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/styles" + "strings" +) + +type historyCompletionProvider struct { + app *app.App +} + +func (h *historyCompletionProvider) GetId() string { + return "history" +} + +func (h *historyCompletionProvider) GetEmptyMessage() string { + return "no matching history" +} + +func (h *historyCompletionProvider) GetChildEntries(query string) ([]CompletionSuggestion, error) { + return h.GetFilteredChildEntries(query, nil) +} + +func (h *historyCompletionProvider) GetFilteredChildEntries(query string, filter func(string) bool) ([]CompletionSuggestion, error) { + items := make([]CompletionSuggestion, 0) + query = strings.ToLower(strings.TrimSpace(query)) + seen := make(map[string]struct{}) + for _, prompt := range h.app.State.MessageHistory { + text := prompt.Text + + // Apply filter if provided + if filter != nil && !filter(text) { + continue + } + + if query != "" && !strings.Contains(strings.ToLower(text), query) { + continue + } + if _, exists := seen[text]; exists { + continue // Skip duplicate + } + seen[text] = struct{}{} + displayFunc := func(s styles.Style) string { + return s.Render(text) + } + item := CompletionSuggestion{ + Display: displayFunc, + Value: text, + ProviderID: h.GetId(), + RawData: prompt, + } + items = append(items, item) + } + return items, nil +} + +func NewHistoryCompletionProvider(app *app.App) CompletionProvider { + return &historyCompletionProvider{app: app} +} diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 0c52ca84ca9..9511aca5a85 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -321,7 +321,16 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") return m, nil - + case "history": + // For history items, check if it's a shell command + value := msg.Item.Value + if strings.HasPrefix(value, "! ") { + // Switch to bash mode and strip the prefix + m.app.IsBashMode = true + value = strings.TrimPrefix(value, "! ") + } + m.SetValue(value) + return m, nil default: slog.Debug("Unknown provider", "provider", msg.Item.ProviderID) return m, nil @@ -523,10 +532,21 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) { command := m.textarea.Value() + + // Don't submit empty commands + if command == "" { + return m, nil + } + + // Add the shell command to message history + prompt := app.Prompt{Text: "! " + command} + m.app.State.AddPromptToHistory(prompt) + var cmds []tea.Cmd updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) + cmds = append(cmds, m.app.SaveState()) cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command})) return m, tea.Batch(cmds...) } diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 71e5b9f717c..cf280f03445 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -71,6 +71,7 @@ type Model struct { fileProvider completions.CompletionProvider symbolsProvider completions.CompletionProvider agentsProvider completions.CompletionProvider + historyProvider completions.CompletionProvider showCompletionDialog bool leaderBinding *key.Binding toastManager *toast.ToastManager @@ -214,6 +215,33 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Sequence(cmds...) } + // Handle history search trigger + if historyCommand := a.app.Commands[commands.HistorySearchCommand]; historyCommand.Matches(msg, a.app.IsLeaderSequence) && !a.showCompletionDialog { + a.showCompletionDialog = true + + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + + // Set history provider for history completion + // Use the first keybinding as the trigger display + triggerKey := "ctrl+r" // fallback + if len(historyCommand.Keybindings) > 0 { + triggerKey = historyCommand.Keybindings[0].Key + } + + // Create a filtered history provider based on mode + var provider completions.CompletionProvider + // Use the regular history provider for both bash and regular modes + provider = a.historyProvider + a.completions = dialog.NewCompletionDialogComponent(triggerKey, provider) + updated, cmd = a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + + return a, tea.Sequence(cmds...) + } + // Handle file completions trigger if keyString == "@" && !a.showCompletionDialog && @@ -455,6 +483,21 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.app.Messages = []app.Message{} case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false + case dialog.CompletionSelectedMsg: + // If this was a history search, set the editor value + if msg.Item.ProviderID == "history" { + value := msg.Item.Value + if strings.HasPrefix(value, "! ") { + // Switch to bash mode and strip the prefix + a.app.IsBashMode = true + value = strings.TrimPrefix(value, "! ") + } + a.editor.SetValueWithAttachments(value) + updated, cmd := a.editor.Focus() + a.editor = updated.(chat.EditorComponent) + a.showCompletionDialog = false + return a, cmd + } case opencode.EventListResponseEventInstallationUpdated: return a, toast.NewSuccessToast( "opencode updated to "+msg.Properties.Version+", restart to apply.", @@ -1482,6 +1525,7 @@ func NewModel(app *app.App) tea.Model { fileProvider := completions.NewFileContextGroup(app) symbolsProvider := completions.NewSymbolsContextGroup(app) agentsProvider := completions.NewAgentsContextGroup(app) + historyProvider := completions.NewHistoryCompletionProvider(app) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) @@ -1503,6 +1547,7 @@ func NewModel(app *app.App) tea.Model { fileProvider: fileProvider, symbolsProvider: symbolsProvider, agentsProvider: agentsProvider, + historyProvider: historyProvider, leaderBinding: leaderBinding, showCompletionDialog: false, toastManager: toast.NewToastManager(),