Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/tui/internal/commands/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -210,6 +211,11 @@ func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command)
Keybindings: parseBindings("<leader>x"),
Trigger: []string{"export"},
},
{
Name: HistorySearchCommand,
Description: "search history",
Keybindings: parseBindings("ctrl+r"),
},
{
Name: SessionNewCommand,
Description: "new session",
Expand Down
60 changes: 60 additions & 0 deletions packages/tui/internal/completions/history.go
Original file line number Diff line number Diff line change
@@ -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}
}
22 changes: 21 additions & 1 deletion packages/tui/internal/components/chat/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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...)
}
Expand Down
45 changes: 45 additions & 0 deletions packages/tui/internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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)
Expand All @@ -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(),
Expand Down