mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-19 15:54:54 +01:00
This release has a bunch of minor breaking changes if you are using opencode plugins or sdk 1. storage events have been removed (we might bring this back but had some issues) 2. concept of `app` is gone - there is a new concept called `project` and endpoints to list projects and get the current project 3. plugin receives `directory` which is cwd and `worktree` which is where the root of the project is if it's a git repo 4. the session.chat function has been renamed to session.prompt in sdk. it no longer requires model to be passed in (model is now an object) 5. every endpoint takes an optional `directory` parameter to operate as though opencode is running in that directory
1558 lines
47 KiB
Go
1558 lines
47 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/v2/key"
|
|
tea "github.com/charmbracelet/bubbletea/v2"
|
|
"github.com/charmbracelet/lipgloss/v2"
|
|
|
|
"github.com/sst/opencode-sdk-go"
|
|
"github.com/sst/opencode/internal/api"
|
|
"github.com/sst/opencode/internal/app"
|
|
"github.com/sst/opencode/internal/commands"
|
|
"github.com/sst/opencode/internal/completions"
|
|
"github.com/sst/opencode/internal/components/chat"
|
|
cmdcomp "github.com/sst/opencode/internal/components/commands"
|
|
"github.com/sst/opencode/internal/components/dialog"
|
|
"github.com/sst/opencode/internal/components/modal"
|
|
"github.com/sst/opencode/internal/components/status"
|
|
"github.com/sst/opencode/internal/components/toast"
|
|
"github.com/sst/opencode/internal/layout"
|
|
"github.com/sst/opencode/internal/styles"
|
|
"github.com/sst/opencode/internal/theme"
|
|
"github.com/sst/opencode/internal/util"
|
|
)
|
|
|
|
// InterruptDebounceTimeoutMsg is sent when the interrupt key debounce timeout expires
|
|
type InterruptDebounceTimeoutMsg struct{}
|
|
|
|
// ExitDebounceTimeoutMsg is sent when the exit key debounce timeout expires
|
|
type ExitDebounceTimeoutMsg struct{}
|
|
|
|
// InterruptKeyState tracks the state of interrupt key presses for debouncing
|
|
type InterruptKeyState int
|
|
|
|
// ExitKeyState tracks the state of exit key presses for debouncing
|
|
type ExitKeyState int
|
|
|
|
const (
|
|
InterruptKeyIdle InterruptKeyState = iota
|
|
InterruptKeyFirstPress
|
|
)
|
|
|
|
const (
|
|
ExitKeyIdle ExitKeyState = iota
|
|
ExitKeyFirstPress
|
|
)
|
|
|
|
const interruptDebounceTimeout = 1 * time.Second
|
|
const exitDebounceTimeout = 1 * time.Second
|
|
|
|
type Model struct {
|
|
tea.Model
|
|
tea.CursorModel
|
|
width, height int
|
|
app *app.App
|
|
modal layout.Modal
|
|
status status.StatusComponent
|
|
editor chat.EditorComponent
|
|
messages chat.MessagesComponent
|
|
completions dialog.CompletionDialog
|
|
commandProvider completions.CompletionProvider
|
|
fileProvider completions.CompletionProvider
|
|
symbolsProvider completions.CompletionProvider
|
|
agentsProvider completions.CompletionProvider
|
|
showCompletionDialog bool
|
|
leaderBinding *key.Binding
|
|
toastManager *toast.ToastManager
|
|
interruptKeyState InterruptKeyState
|
|
exitKeyState ExitKeyState
|
|
messagesRight bool
|
|
}
|
|
|
|
func (a Model) Init() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
// https://github.com/charmbracelet/bubbletea/issues/1440
|
|
// https://github.com/sst/opencode/issues/127
|
|
if !util.IsWsl() {
|
|
cmds = append(cmds, tea.RequestBackgroundColor)
|
|
}
|
|
cmds = append(cmds, a.app.InitializeProvider())
|
|
cmds = append(cmds, a.editor.Init())
|
|
cmds = append(cmds, a.messages.Init())
|
|
cmds = append(cmds, a.status.Init())
|
|
cmds = append(cmds, a.completions.Init())
|
|
cmds = append(cmds, a.toastManager.Init())
|
|
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyPressMsg:
|
|
keyString := msg.String()
|
|
|
|
if a.app.CurrentPermission.ID != "" {
|
|
if keyString == "enter" || keyString == "esc" || keyString == "a" {
|
|
sessionID := a.app.CurrentPermission.SessionID
|
|
permissionID := a.app.CurrentPermission.ID
|
|
a.editor.Focus()
|
|
a.app.Permissions = a.app.Permissions[1:]
|
|
if len(a.app.Permissions) > 0 {
|
|
a.app.CurrentPermission = a.app.Permissions[0]
|
|
} else {
|
|
a.app.CurrentPermission = opencode.Permission{}
|
|
}
|
|
response := opencode.SessionPermissionRespondParamsResponseOnce
|
|
switch keyString {
|
|
case "enter":
|
|
response = opencode.SessionPermissionRespondParamsResponseOnce
|
|
case "a":
|
|
response = opencode.SessionPermissionRespondParamsResponseAlways
|
|
case "esc":
|
|
response = opencode.SessionPermissionRespondParamsResponseReject
|
|
}
|
|
|
|
return a, func() tea.Msg {
|
|
resp, err := a.app.Client.Session.Permissions.Respond(
|
|
context.Background(),
|
|
sessionID,
|
|
permissionID,
|
|
opencode.SessionPermissionRespondParams{Response: opencode.F(response)},
|
|
)
|
|
if err != nil {
|
|
slog.Error("Failed to respond to permission request", "error", err)
|
|
return toast.NewErrorToast("Failed to respond to permission request")()
|
|
}
|
|
slog.Debug("Responded to permission request", "response", resp)
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if a.app.IsBashMode {
|
|
if keyString == "backspace" && a.editor.Length() == 0 {
|
|
a.app.IsBashMode = false
|
|
return a, nil
|
|
}
|
|
|
|
if keyString == "enter" || keyString == "esc" || keyString == "ctrl+c" {
|
|
a.app.IsBashMode = false
|
|
if keyString == "enter" {
|
|
updated, cmd := a.editor.SubmitBash()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
}
|
|
|
|
// 1. Handle active modal
|
|
if a.modal != nil {
|
|
switch keyString {
|
|
// Escape always closes current modal
|
|
case "esc":
|
|
cmd := a.modal.Close()
|
|
a.modal = nil
|
|
return a, cmd
|
|
case "ctrl+c":
|
|
// give the modal a chance to handle the ctrl+c
|
|
updatedModal, cmd := a.modal.Update(msg)
|
|
a.modal = updatedModal.(layout.Modal)
|
|
if cmd != nil {
|
|
return a, cmd
|
|
}
|
|
cmd = a.modal.Close()
|
|
a.modal = nil
|
|
return a, cmd
|
|
}
|
|
|
|
// Pass all other key presses to the modal
|
|
updatedModal, cmd := a.modal.Update(msg)
|
|
a.modal = updatedModal.(layout.Modal)
|
|
return a, cmd
|
|
}
|
|
|
|
// 2. Check for commands that require leader
|
|
if a.app.IsLeaderSequence {
|
|
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
|
|
a.app.IsLeaderSequence = false
|
|
if len(matches) > 0 {
|
|
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
|
}
|
|
}
|
|
|
|
// 3. Handle completions trigger
|
|
if keyString == "/" &&
|
|
!a.showCompletionDialog &&
|
|
a.editor.Value() == "" &&
|
|
!a.app.IsBashMode {
|
|
a.showCompletionDialog = true
|
|
|
|
updated, cmd := a.editor.Update(msg)
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// Set command provider for command completion
|
|
a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
|
|
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 &&
|
|
!a.app.IsBashMode {
|
|
a.showCompletionDialog = true
|
|
|
|
updated, cmd := a.editor.Update(msg)
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
// Set file, symbols, and agents providers for @ completion
|
|
a.completions = dialog.NewCompletionDialogComponent("@", a.agentsProvider, a.fileProvider, a.symbolsProvider)
|
|
updated, cmd = a.completions.Update(msg)
|
|
a.completions = updated.(dialog.CompletionDialog)
|
|
cmds = append(cmds, cmd)
|
|
|
|
return a, tea.Sequence(cmds...)
|
|
}
|
|
|
|
if keyString == "!" && a.editor.Value() == "" {
|
|
a.app.IsBashMode = true
|
|
return a, nil
|
|
}
|
|
|
|
if a.showCompletionDialog {
|
|
switch keyString {
|
|
case "tab", "enter", "esc", "ctrl+c", "up", "down", "ctrl+p", "ctrl+n":
|
|
updated, cmd := a.completions.Update(msg)
|
|
a.completions = updated.(dialog.CompletionDialog)
|
|
cmds = append(cmds, cmd)
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
updated, cmd := a.editor.Update(msg)
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
updated, cmd = a.completions.Update(msg)
|
|
a.completions = updated.(dialog.CompletionDialog)
|
|
cmds = append(cmds, cmd)
|
|
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
// 4. Maximize editor responsiveness for printable characters
|
|
if msg.Text != "" {
|
|
updated, cmd := a.editor.Update(msg)
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
// 5. Check for leader key activation
|
|
if a.leaderBinding != nil &&
|
|
!a.app.IsLeaderSequence &&
|
|
key.Matches(msg, *a.leaderBinding) {
|
|
a.app.IsLeaderSequence = true
|
|
return a, nil
|
|
}
|
|
|
|
// 6 Handle input clear command
|
|
inputClearCommand := a.app.Commands[commands.InputClearCommand]
|
|
if inputClearCommand.Matches(msg, a.app.IsLeaderSequence) && a.editor.Length() > 0 {
|
|
return a, util.CmdHandler(commands.ExecuteCommandMsg(inputClearCommand))
|
|
}
|
|
|
|
// 7. Handle interrupt key debounce for session interrupt
|
|
interruptCommand := a.app.Commands[commands.SessionInterruptCommand]
|
|
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() {
|
|
switch a.interruptKeyState {
|
|
case InterruptKeyIdle:
|
|
// First interrupt key press - start debounce timer
|
|
a.interruptKeyState = InterruptKeyFirstPress
|
|
a.editor.SetInterruptKeyInDebounce(true)
|
|
return a, tea.Tick(interruptDebounceTimeout, func(t time.Time) tea.Msg {
|
|
return InterruptDebounceTimeoutMsg{}
|
|
})
|
|
case InterruptKeyFirstPress:
|
|
// Second interrupt key press within timeout - actually interrupt
|
|
a.interruptKeyState = InterruptKeyIdle
|
|
a.editor.SetInterruptKeyInDebounce(false)
|
|
return a, util.CmdHandler(commands.ExecuteCommandMsg(interruptCommand))
|
|
}
|
|
}
|
|
|
|
// 8. Handle exit key debounce for app exit when using non-leader command
|
|
exitCommand := a.app.Commands[commands.AppExitCommand]
|
|
if exitCommand.Matches(msg, a.app.IsLeaderSequence) {
|
|
switch a.exitKeyState {
|
|
case ExitKeyIdle:
|
|
// First exit key press - start debounce timer
|
|
a.exitKeyState = ExitKeyFirstPress
|
|
a.editor.SetExitKeyInDebounce(true)
|
|
return a, tea.Tick(exitDebounceTimeout, func(t time.Time) tea.Msg {
|
|
return ExitDebounceTimeoutMsg{}
|
|
})
|
|
case ExitKeyFirstPress:
|
|
// Second exit key press within timeout - actually exit
|
|
a.exitKeyState = ExitKeyIdle
|
|
a.editor.SetExitKeyInDebounce(false)
|
|
return a, util.CmdHandler(commands.ExecuteCommandMsg(exitCommand))
|
|
}
|
|
}
|
|
|
|
// 9. Check again for commands that don't require leader (excluding interrupt when busy and exit when in debounce)
|
|
matches := a.app.Commands.Matches(msg, a.app.IsLeaderSequence)
|
|
if len(matches) > 0 {
|
|
// Skip interrupt key if we're in debounce mode and app is busy
|
|
if interruptCommand.Matches(msg, a.app.IsLeaderSequence) && a.app.IsBusy() && a.interruptKeyState != InterruptKeyIdle {
|
|
return a, nil
|
|
}
|
|
return a, util.CmdHandler(commands.ExecuteCommandsMsg(matches))
|
|
}
|
|
|
|
// Fallback: suspend if ctrl+z is pressed and no user keybind matched
|
|
if keyString == "ctrl+z" {
|
|
return a, tea.Suspend
|
|
}
|
|
|
|
// 10. Fallback to editor. This is for other characters like backspace, tab, etc.
|
|
updatedEditor, cmd := a.editor.Update(msg)
|
|
a.editor = updatedEditor.(chat.EditorComponent)
|
|
return a, cmd
|
|
case tea.MouseWheelMsg:
|
|
if a.modal != nil {
|
|
u, cmd := a.modal.Update(msg)
|
|
a.modal = u.(layout.Modal)
|
|
cmds = append(cmds, cmd)
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
updated, cmd := a.messages.Update(msg)
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
return a, tea.Batch(cmds...)
|
|
case tea.BackgroundColorMsg:
|
|
styles.Terminal = &styles.TerminalInfo{
|
|
Background: msg.Color,
|
|
BackgroundIsDark: msg.IsDark(),
|
|
}
|
|
slog.Debug("Background color", "color", msg.String(), "isDark", msg.IsDark())
|
|
return a, func() tea.Msg {
|
|
theme.UpdateSystemTheme(
|
|
styles.Terminal.Background,
|
|
styles.Terminal.BackgroundIsDark,
|
|
)
|
|
return dialog.ThemeSelectedMsg{
|
|
ThemeName: theme.CurrentThemeName(),
|
|
}
|
|
}
|
|
case modal.CloseModalMsg:
|
|
a.editor.Focus()
|
|
var cmd tea.Cmd
|
|
if a.modal != nil {
|
|
cmd = a.modal.Close()
|
|
}
|
|
a.modal = nil
|
|
return a, cmd
|
|
case dialog.ReopenSessionModalMsg:
|
|
// Reopen the session modal (used when exiting rename mode)
|
|
sessionDialog := dialog.NewSessionDialog(a.app)
|
|
a.modal = sessionDialog
|
|
return a, nil
|
|
case commands.ExecuteCommandMsg:
|
|
updated, cmd := a.executeCommand(commands.Command(msg))
|
|
return updated, cmd
|
|
case commands.ExecuteCommandsMsg:
|
|
for _, command := range msg {
|
|
updated, cmd := a.executeCommand(command)
|
|
if cmd != nil {
|
|
return updated, cmd
|
|
}
|
|
}
|
|
case error:
|
|
return a, toast.NewErrorToast(msg.Error())
|
|
case app.SendPrompt:
|
|
a.showCompletionDialog = false
|
|
// If we're in a child session, switch back to parent before sending prompt
|
|
if a.app.Session.ParentID != "" {
|
|
parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get parent session", "error", err)
|
|
return a, toast.NewErrorToast("Failed to get parent session")
|
|
}
|
|
a.app.Session = parentSession
|
|
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
|
|
cmds = append(cmds, tea.Sequence(
|
|
util.CmdHandler(app.SessionSelectedMsg(parentSession)),
|
|
cmd,
|
|
))
|
|
} else {
|
|
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
case app.SendCommand:
|
|
// If we're in a child session, switch back to parent before sending prompt
|
|
if a.app.Session.ParentID != "" {
|
|
parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get parent session", "error", err)
|
|
return a, toast.NewErrorToast("Failed to get parent session")
|
|
}
|
|
a.app.Session = parentSession
|
|
a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
|
|
cmds = append(cmds, tea.Sequence(
|
|
util.CmdHandler(app.SessionSelectedMsg(parentSession)),
|
|
cmd,
|
|
))
|
|
} else {
|
|
a.app, cmd = a.app.SendCommand(context.Background(), msg.Command, msg.Args)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
case app.SendShell:
|
|
// If we're in a child session, switch back to parent before sending prompt
|
|
if a.app.Session.ParentID != "" {
|
|
parentSession, err := a.app.Client.Session.Get(context.Background(), a.app.Session.ParentID, opencode.SessionGetParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get parent session", "error", err)
|
|
return a, toast.NewErrorToast("Failed to get parent session")
|
|
}
|
|
a.app.Session = parentSession
|
|
a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
|
|
cmds = append(cmds, tea.Sequence(
|
|
util.CmdHandler(app.SessionSelectedMsg(parentSession)),
|
|
cmd,
|
|
))
|
|
} else {
|
|
a.app, cmd = a.app.SendShell(context.Background(), msg.Command)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
case app.SetEditorContentMsg:
|
|
// Set the editor content without sending
|
|
a.editor.SetValueWithAttachments(msg.Text)
|
|
updated, cmd := a.editor.Focus()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case app.SessionClearedMsg:
|
|
a.app.Session = &opencode.Session{}
|
|
a.app.Messages = []app.Message{}
|
|
case dialog.CompletionDialogCloseMsg:
|
|
a.showCompletionDialog = false
|
|
case opencode.EventListResponseEventInstallationUpdated:
|
|
return a, toast.NewSuccessToast(
|
|
"opencode updated to "+msg.Properties.Version+", restart to apply.",
|
|
toast.WithTitle("New version installed"),
|
|
)
|
|
/*
|
|
case opencode.EventListResponseEventIdeInstalled:
|
|
return a, toast.NewSuccessToast(
|
|
"Installed the opencode extension in "+msg.Properties.Ide,
|
|
toast.WithTitle(msg.Properties.Ide+" extension installed"),
|
|
)
|
|
*/
|
|
case opencode.EventListResponseEventSessionDeleted:
|
|
if a.app.Session != nil && msg.Properties.Info.ID == a.app.Session.ID {
|
|
a.app.Session = &opencode.Session{}
|
|
a.app.Messages = []app.Message{}
|
|
}
|
|
return a, toast.NewSuccessToast("Session deleted successfully")
|
|
case opencode.EventListResponseEventSessionUpdated:
|
|
if msg.Properties.Info.ID == a.app.Session.ID {
|
|
a.app.Session = &msg.Properties.Info
|
|
}
|
|
case opencode.EventListResponseEventMessagePartUpdated:
|
|
slog.Debug("message part updated", "message", msg.Properties.Part.MessageID, "part", msg.Properties.Part.ID)
|
|
if msg.Properties.Part.SessionID == a.app.Session.ID {
|
|
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
|
|
switch casted := m.Info.(type) {
|
|
case opencode.UserMessage:
|
|
return casted.ID == msg.Properties.Part.MessageID
|
|
case opencode.AssistantMessage:
|
|
return casted.ID == msg.Properties.Part.MessageID
|
|
}
|
|
return false
|
|
})
|
|
if messageIndex > -1 {
|
|
message := a.app.Messages[messageIndex]
|
|
partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
|
|
switch casted := p.(type) {
|
|
case opencode.TextPart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
case opencode.ReasoningPart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
case opencode.FilePart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
case opencode.ToolPart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
case opencode.StepStartPart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
case opencode.StepFinishPart:
|
|
return casted.ID == msg.Properties.Part.ID
|
|
}
|
|
return false
|
|
})
|
|
if partIndex > -1 {
|
|
message.Parts[partIndex] = msg.Properties.Part.AsUnion()
|
|
}
|
|
if partIndex == -1 {
|
|
message.Parts = append(message.Parts, msg.Properties.Part.AsUnion())
|
|
}
|
|
a.app.Messages[messageIndex] = message
|
|
}
|
|
}
|
|
case opencode.EventListResponseEventMessagePartRemoved:
|
|
slog.Debug("message part removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID, "part", msg.Properties.PartID)
|
|
if msg.Properties.SessionID == a.app.Session.ID {
|
|
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
|
|
switch casted := m.Info.(type) {
|
|
case opencode.UserMessage:
|
|
return casted.ID == msg.Properties.MessageID
|
|
case opencode.AssistantMessage:
|
|
return casted.ID == msg.Properties.MessageID
|
|
}
|
|
return false
|
|
})
|
|
if messageIndex > -1 {
|
|
message := a.app.Messages[messageIndex]
|
|
partIndex := slices.IndexFunc(message.Parts, func(p opencode.PartUnion) bool {
|
|
switch casted := p.(type) {
|
|
case opencode.TextPart:
|
|
return casted.ID == msg.Properties.PartID
|
|
case opencode.ReasoningPart:
|
|
return casted.ID == msg.Properties.PartID
|
|
case opencode.FilePart:
|
|
return casted.ID == msg.Properties.PartID
|
|
case opencode.ToolPart:
|
|
return casted.ID == msg.Properties.PartID
|
|
case opencode.StepStartPart:
|
|
return casted.ID == msg.Properties.PartID
|
|
case opencode.StepFinishPart:
|
|
return casted.ID == msg.Properties.PartID
|
|
}
|
|
return false
|
|
})
|
|
if partIndex > -1 {
|
|
// Remove the part at partIndex
|
|
message.Parts = append(message.Parts[:partIndex], message.Parts[partIndex+1:]...)
|
|
a.app.Messages[messageIndex] = message
|
|
}
|
|
}
|
|
}
|
|
case opencode.EventListResponseEventMessageRemoved:
|
|
slog.Debug("message removed", "session", msg.Properties.SessionID, "message", msg.Properties.MessageID)
|
|
if msg.Properties.SessionID == a.app.Session.ID {
|
|
messageIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
|
|
switch casted := m.Info.(type) {
|
|
case opencode.UserMessage:
|
|
return casted.ID == msg.Properties.MessageID
|
|
case opencode.AssistantMessage:
|
|
return casted.ID == msg.Properties.MessageID
|
|
}
|
|
return false
|
|
})
|
|
if messageIndex > -1 {
|
|
a.app.Messages = append(a.app.Messages[:messageIndex], a.app.Messages[messageIndex+1:]...)
|
|
}
|
|
}
|
|
case opencode.EventListResponseEventMessageUpdated:
|
|
if msg.Properties.Info.SessionID == a.app.Session.ID {
|
|
matchIndex := slices.IndexFunc(a.app.Messages, func(m app.Message) bool {
|
|
switch casted := m.Info.(type) {
|
|
case opencode.UserMessage:
|
|
return casted.ID == msg.Properties.Info.ID
|
|
case opencode.AssistantMessage:
|
|
return casted.ID == msg.Properties.Info.ID
|
|
}
|
|
return false
|
|
})
|
|
|
|
if matchIndex > -1 {
|
|
match := a.app.Messages[matchIndex]
|
|
a.app.Messages[matchIndex] = app.Message{
|
|
Info: msg.Properties.Info.AsUnion(),
|
|
Parts: match.Parts,
|
|
}
|
|
}
|
|
|
|
if matchIndex == -1 {
|
|
a.app.Messages = append(a.app.Messages, app.Message{
|
|
Info: msg.Properties.Info.AsUnion(),
|
|
Parts: []opencode.PartUnion{},
|
|
})
|
|
}
|
|
}
|
|
case opencode.EventListResponseEventPermissionUpdated:
|
|
slog.Debug("permission updated", "session", msg.Properties.SessionID, "permission", msg.Properties.ID)
|
|
a.app.Permissions = append(a.app.Permissions, msg.Properties)
|
|
a.app.CurrentPermission = a.app.Permissions[0]
|
|
a.editor.Blur()
|
|
case opencode.EventListResponseEventPermissionReplied:
|
|
index := slices.IndexFunc(a.app.Permissions, func(p opencode.Permission) bool {
|
|
return p.ID == msg.Properties.PermissionID
|
|
})
|
|
if index > -1 {
|
|
a.app.Permissions = append(a.app.Permissions[:index], a.app.Permissions[index+1:]...)
|
|
}
|
|
if a.app.CurrentPermission.ID == msg.Properties.PermissionID {
|
|
if len(a.app.Permissions) > 0 {
|
|
a.app.CurrentPermission = a.app.Permissions[0]
|
|
} else {
|
|
a.app.CurrentPermission = opencode.Permission{}
|
|
}
|
|
}
|
|
case opencode.EventListResponseEventSessionError:
|
|
switch err := msg.Properties.Error.AsUnion().(type) {
|
|
case nil:
|
|
case opencode.ProviderAuthError:
|
|
slog.Error("Failed to authenticate with provider", "error", err.Data.Message)
|
|
return a, toast.NewErrorToast("Provider error: " + err.Data.Message)
|
|
case opencode.UnknownError:
|
|
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
|
|
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
msg.Height -= 2 // Make space for the status bar
|
|
a.width, a.height = msg.Width, msg.Height
|
|
container := min(a.width, 86)
|
|
layout.Current = &layout.LayoutInfo{
|
|
Viewport: layout.Dimensions{
|
|
Width: a.width,
|
|
Height: a.height,
|
|
},
|
|
Container: layout.Dimensions{
|
|
Width: container,
|
|
},
|
|
}
|
|
case app.SessionSelectedMsg:
|
|
updated, cmd := a.messages.Update(msg)
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
messages, err := a.app.ListMessages(context.Background(), msg.ID)
|
|
if err != nil {
|
|
slog.Error("Failed to list messages", "error", err.Error())
|
|
return a, toast.NewErrorToast("Failed to open session")
|
|
}
|
|
a.app.Session = msg
|
|
a.app.Messages = messages
|
|
cmds = append(cmds, util.CmdHandler(app.SessionLoadedMsg{}))
|
|
return a, tea.Batch(cmds...)
|
|
case app.SessionCreatedMsg:
|
|
a.app.Session = msg.Session
|
|
case dialog.ScrollToMessageMsg:
|
|
updated, cmd := a.messages.ScrollToMessage(msg.MessageID)
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case dialog.RestoreToMessageMsg:
|
|
cmd := func() tea.Msg {
|
|
// Find next user message after target
|
|
var nextMessageID string
|
|
for i := msg.Index + 1; i < len(a.app.Messages); i++ {
|
|
if userMsg, ok := a.app.Messages[i].Info.(opencode.UserMessage); ok {
|
|
nextMessageID = userMsg.ID
|
|
break
|
|
}
|
|
}
|
|
|
|
var response *opencode.Session
|
|
var err error
|
|
|
|
if nextMessageID == "" {
|
|
// Last message - use unrevert to restore full conversation
|
|
response, err = a.app.Client.Session.Unrevert(context.Background(), a.app.Session.ID, opencode.SessionUnrevertParams{})
|
|
} else {
|
|
// Revert to next message to make target the last visible
|
|
response, err = a.app.Client.Session.Revert(context.Background(), a.app.Session.ID,
|
|
opencode.SessionRevertParams{MessageID: opencode.F(nextMessageID)})
|
|
}
|
|
|
|
if err != nil || response == nil {
|
|
return toast.NewErrorToast("Failed to restore to message")
|
|
}
|
|
return app.MessageRevertedMsg{Session: *response, Message: app.Message{}}
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
case app.MessageRevertedMsg:
|
|
if msg.Session.ID == a.app.Session.ID {
|
|
a.app.Session = &msg.Session
|
|
}
|
|
case app.ModelSelectedMsg:
|
|
a.app.Provider = &msg.Provider
|
|
a.app.Model = &msg.Model
|
|
a.app.State.AgentModel[a.app.Agent().Name] = app.AgentModel{
|
|
ProviderID: msg.Provider.ID,
|
|
ModelID: msg.Model.ID,
|
|
}
|
|
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
|
|
cmds = append(cmds, a.app.SaveState())
|
|
case app.AgentSelectedMsg:
|
|
updated, cmd := a.app.SwitchToAgent(msg.AgentName)
|
|
a.app = updated
|
|
cmds = append(cmds, cmd)
|
|
case dialog.ThemeSelectedMsg:
|
|
a.app.State.Theme = msg.ThemeName
|
|
cmds = append(cmds, a.app.SaveState())
|
|
case toast.ShowToastMsg:
|
|
tm, cmd := a.toastManager.Update(msg)
|
|
a.toastManager = tm
|
|
cmds = append(cmds, cmd)
|
|
case toast.DismissToastMsg:
|
|
tm, cmd := a.toastManager.Update(msg)
|
|
a.toastManager = tm
|
|
cmds = append(cmds, cmd)
|
|
case InterruptDebounceTimeoutMsg:
|
|
// Reset interrupt key state after timeout
|
|
a.interruptKeyState = InterruptKeyIdle
|
|
a.editor.SetInterruptKeyInDebounce(false)
|
|
case ExitDebounceTimeoutMsg:
|
|
// Reset exit key state after timeout
|
|
a.exitKeyState = ExitKeyIdle
|
|
a.editor.SetExitKeyInDebounce(false)
|
|
case tea.PasteMsg, tea.ClipboardMsg:
|
|
// Paste events: prioritize modal if active, otherwise editor
|
|
if a.modal != nil {
|
|
updatedModal, cmd := a.modal.Update(msg)
|
|
a.modal = updatedModal.(layout.Modal)
|
|
return a, cmd
|
|
} else {
|
|
updatedEditor, cmd := a.editor.Update(msg)
|
|
a.editor = updatedEditor.(chat.EditorComponent)
|
|
return a, cmd
|
|
}
|
|
|
|
// API
|
|
case api.Request:
|
|
slog.Info("api", "path", msg.Path)
|
|
var response any = true
|
|
switch msg.Path {
|
|
case "/tui/open-help":
|
|
helpDialog := dialog.NewHelpDialog(a.app)
|
|
a.modal = helpDialog
|
|
case "/tui/open-sessions":
|
|
sessionDialog := dialog.NewSessionDialog(a.app)
|
|
a.modal = sessionDialog
|
|
case "/tui/open-timeline":
|
|
navigationDialog := dialog.NewTimelineDialog(a.app)
|
|
a.modal = navigationDialog
|
|
case "/tui/open-themes":
|
|
themeDialog := dialog.NewThemeDialog()
|
|
a.modal = themeDialog
|
|
case "/tui/open-models":
|
|
modelDialog := dialog.NewModelDialog(a.app)
|
|
a.modal = modelDialog
|
|
case "/tui/append-prompt":
|
|
var body struct {
|
|
Text string `json:"text"`
|
|
}
|
|
json.Unmarshal((msg.Body), &body)
|
|
existing := a.editor.Value()
|
|
text := body.Text
|
|
if existing != "" && !strings.HasSuffix(existing, " ") {
|
|
text = " " + text
|
|
}
|
|
a.editor.SetValueWithAttachments(existing + text + " ")
|
|
case "/tui/submit-prompt":
|
|
updated, cmd := a.editor.Submit()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case "/tui/clear-prompt":
|
|
updated, cmd := a.editor.Clear()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case "/tui/execute-command":
|
|
var body struct {
|
|
Command string `json:"command"`
|
|
}
|
|
json.Unmarshal((msg.Body), &body)
|
|
command := commands.Command{}
|
|
for _, cmd := range a.app.Commands {
|
|
if string(cmd.Name) == body.Command {
|
|
command = cmd
|
|
break
|
|
}
|
|
}
|
|
if command.Name == "" {
|
|
slog.Error("Invalid command passed to /tui/execute-command", "command", body.Command)
|
|
return a, nil
|
|
}
|
|
updated, cmd := a.executeCommand(commands.Command(command))
|
|
a = updated.(Model)
|
|
cmds = append(cmds, cmd)
|
|
case "/tui/show-toast":
|
|
var body struct {
|
|
Title string `json:"title,omitempty"`
|
|
Message string `json:"message"`
|
|
Variant string `json:"variant"`
|
|
}
|
|
json.Unmarshal((msg.Body), &body)
|
|
|
|
var toastCmd tea.Cmd
|
|
switch body.Variant {
|
|
case "info":
|
|
if body.Title != "" {
|
|
toastCmd = toast.NewInfoToast(body.Message, toast.WithTitle(body.Title))
|
|
} else {
|
|
toastCmd = toast.NewInfoToast(body.Message)
|
|
}
|
|
case "success":
|
|
if body.Title != "" {
|
|
toastCmd = toast.NewSuccessToast(body.Message, toast.WithTitle(body.Title))
|
|
} else {
|
|
toastCmd = toast.NewSuccessToast(body.Message)
|
|
}
|
|
case "warning":
|
|
if body.Title != "" {
|
|
toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
|
|
} else {
|
|
toastCmd = toast.NewErrorToast(body.Message)
|
|
}
|
|
case "error":
|
|
if body.Title != "" {
|
|
toastCmd = toast.NewErrorToast(body.Message, toast.WithTitle(body.Title))
|
|
} else {
|
|
toastCmd = toast.NewErrorToast(body.Message)
|
|
}
|
|
default:
|
|
slog.Error("Invalid toast variant", "variant", body.Variant)
|
|
return a, nil
|
|
}
|
|
cmds = append(cmds, toastCmd)
|
|
|
|
default:
|
|
break
|
|
}
|
|
cmds = append(cmds, api.Reply(context.Background(), a.app.Client, response))
|
|
}
|
|
|
|
s, cmd := a.status.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
a.status = s.(status.StatusComponent)
|
|
|
|
updatedEditor, cmd := a.editor.Update(msg)
|
|
a.editor = updatedEditor.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
updatedMessages, cmd := a.messages.Update(msg)
|
|
a.messages = updatedMessages.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
if a.modal != nil {
|
|
updatedModal, cmd := a.modal.Update(msg)
|
|
a.modal = updatedModal.(layout.Modal)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
if a.showCompletionDialog {
|
|
u, cmd := a.completions.Update(msg)
|
|
a.completions = u.(dialog.CompletionDialog)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (a Model) View() (string, *tea.Cursor) {
|
|
t := theme.CurrentTheme()
|
|
|
|
var mainLayout string
|
|
|
|
var editorX int
|
|
var editorY int
|
|
if a.app.Session.ID == "" {
|
|
mainLayout, editorX, editorY = a.home()
|
|
} else {
|
|
mainLayout, editorX, editorY = a.chat()
|
|
}
|
|
mainLayout = styles.NewStyle().
|
|
Background(t.Background()).
|
|
Padding(0, 2).
|
|
Render(mainLayout)
|
|
mainLayout = lipgloss.PlaceHorizontal(
|
|
a.width,
|
|
lipgloss.Center,
|
|
mainLayout,
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
mainStyle := styles.NewStyle().Background(t.Background())
|
|
mainLayout = mainStyle.Render(mainLayout)
|
|
|
|
if a.modal != nil {
|
|
mainLayout = a.modal.Render(mainLayout)
|
|
}
|
|
mainLayout = a.toastManager.RenderOverlay(mainLayout)
|
|
|
|
if theme.CurrentThemeUsesAnsiColors() {
|
|
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
|
|
}
|
|
|
|
cursor := a.editor.Cursor()
|
|
cursor.Position.X += editorX
|
|
cursor.Position.Y += editorY
|
|
|
|
return mainLayout + "\n" + a.status.View(), cursor
|
|
}
|
|
|
|
func (a Model) Cleanup() {
|
|
a.status.Cleanup()
|
|
}
|
|
|
|
func (a Model) home() (string, int, int) {
|
|
t := theme.CurrentTheme()
|
|
effectiveWidth := a.width - 4
|
|
baseStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
|
|
base := baseStyle.Render
|
|
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
|
highlight := styles.NewStyle().Foreground(t.Accent()).Background(t.Background()).Render
|
|
|
|
open := `
|
|
█▀▀█ █▀▀█ █▀▀ █▀▀▄
|
|
█░░█ █░░█ █▀▀ █░░█
|
|
▀▀▀▀ █▀▀▀ ▀▀▀ ▀ ▀ `
|
|
code := `
|
|
█▀▀ █▀▀█ █▀▀▄ █▀▀
|
|
█░░ █░░█ █░░█ █▀▀
|
|
▀▀▀ ▀▀▀▀ ▀▀▀ ▀▀▀`
|
|
|
|
logo := lipgloss.JoinHorizontal(
|
|
lipgloss.Top,
|
|
muted(open),
|
|
base(code),
|
|
)
|
|
// cwd := app.Info.Path.Cwd
|
|
// config := app.Info.Path.Config
|
|
|
|
versionStyle := styles.NewStyle().
|
|
Foreground(t.TextMuted()).
|
|
Background(t.Background()).
|
|
Width(lipgloss.Width(logo)).
|
|
Align(lipgloss.Right)
|
|
version := versionStyle.Render(a.app.Version)
|
|
|
|
logoAndVersion := strings.Join([]string{logo, version}, "\n")
|
|
logoAndVersion = lipgloss.PlaceHorizontal(
|
|
effectiveWidth,
|
|
lipgloss.Center,
|
|
logoAndVersion,
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
// Use limit of 4 for vscode, 6 for others
|
|
limit := 4
|
|
if util.IsVSCode() {
|
|
limit = 2
|
|
}
|
|
|
|
showVscode := util.IsVSCode()
|
|
commandsView := cmdcomp.New(
|
|
a.app,
|
|
cmdcomp.WithBackground(t.Background()),
|
|
cmdcomp.WithLimit(limit),
|
|
cmdcomp.WithVscode(showVscode),
|
|
)
|
|
cmds := lipgloss.PlaceHorizontal(
|
|
effectiveWidth,
|
|
lipgloss.Center,
|
|
commandsView.View(),
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
grok := highlight("Grok Code is free for a limited time")
|
|
grok = lipgloss.PlaceHorizontal(
|
|
effectiveWidth,
|
|
lipgloss.Center,
|
|
grok,
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
lines := []string{}
|
|
lines = append(lines, "")
|
|
lines = append(lines, logoAndVersion)
|
|
lines = append(lines, "")
|
|
lines = append(lines, cmds)
|
|
lines = append(lines, "")
|
|
lines = append(lines, "")
|
|
lines = append(lines, grok)
|
|
lines = append(lines, "")
|
|
|
|
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))
|
|
|
|
editorView := a.editor.View()
|
|
editorWidth := lipgloss.Width(editorView)
|
|
editorView = lipgloss.PlaceHorizontal(
|
|
effectiveWidth,
|
|
lipgloss.Center,
|
|
editorView,
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
lines = append(lines, editorView)
|
|
|
|
editorLines := a.editor.Lines()
|
|
|
|
mainLayout := lipgloss.Place(
|
|
effectiveWidth,
|
|
a.height,
|
|
lipgloss.Center,
|
|
lipgloss.Center,
|
|
baseStyle.Render(strings.Join(lines, "\n")),
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
editorX := max(0, (effectiveWidth-editorWidth)/2)
|
|
editorY := (a.height / 2) + (mainHeight / 2) - 2
|
|
|
|
if editorLines > 1 {
|
|
content := a.editor.Content()
|
|
editorHeight := lipgloss.Height(content)
|
|
|
|
if editorY+editorHeight > a.height {
|
|
difference := (editorY + editorHeight) - a.height
|
|
editorY -= difference
|
|
}
|
|
mainLayout = layout.PlaceOverlay(
|
|
editorX,
|
|
editorY,
|
|
content,
|
|
mainLayout,
|
|
)
|
|
}
|
|
|
|
if a.showCompletionDialog {
|
|
a.completions.SetWidth(editorWidth)
|
|
overlay := a.completions.View()
|
|
overlayHeight := lipgloss.Height(overlay)
|
|
|
|
mainLayout = layout.PlaceOverlay(
|
|
editorX,
|
|
editorY-overlayHeight+1,
|
|
overlay,
|
|
mainLayout,
|
|
)
|
|
}
|
|
|
|
return mainLayout, editorX + 5, editorY + 2
|
|
}
|
|
|
|
func (a Model) chat() (string, int, int) {
|
|
effectiveWidth := a.width - 4
|
|
t := theme.CurrentTheme()
|
|
editorView := a.editor.View()
|
|
lines := a.editor.Lines()
|
|
messagesView := a.messages.View()
|
|
|
|
editorWidth := lipgloss.Width(editorView)
|
|
editorHeight := max(lines, 5)
|
|
editorView = lipgloss.PlaceHorizontal(
|
|
effectiveWidth,
|
|
lipgloss.Center,
|
|
editorView,
|
|
styles.WhitespaceStyle(t.Background()),
|
|
)
|
|
|
|
mainLayout := messagesView + "\n" + editorView
|
|
editorX := max(0, (effectiveWidth-editorWidth)/2)
|
|
editorY := a.height - editorHeight
|
|
|
|
if lines > 1 {
|
|
content := a.editor.Content()
|
|
editorHeight := lipgloss.Height(content)
|
|
if editorY+editorHeight > a.height {
|
|
difference := (editorY + editorHeight) - a.height
|
|
editorY -= difference
|
|
}
|
|
mainLayout = layout.PlaceOverlay(
|
|
editorX,
|
|
editorY,
|
|
content,
|
|
mainLayout,
|
|
)
|
|
}
|
|
|
|
if a.showCompletionDialog {
|
|
a.completions.SetWidth(editorWidth)
|
|
overlay := a.completions.View()
|
|
overlayHeight := lipgloss.Height(overlay)
|
|
editorY := a.height - editorHeight + 1
|
|
|
|
mainLayout = layout.PlaceOverlay(
|
|
editorX,
|
|
editorY-overlayHeight,
|
|
overlay,
|
|
mainLayout,
|
|
)
|
|
}
|
|
|
|
return mainLayout, editorX + 5, editorY + 2
|
|
}
|
|
|
|
func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
cmds := []tea.Cmd{
|
|
util.CmdHandler(commands.CommandExecutedMsg(command)),
|
|
}
|
|
switch command.Name {
|
|
case commands.AppHelpCommand:
|
|
helpDialog := dialog.NewHelpDialog(a.app)
|
|
a.modal = helpDialog
|
|
case commands.AgentCycleCommand:
|
|
updated, cmd := a.app.SwitchAgent()
|
|
a.app = updated
|
|
cmds = append(cmds, cmd)
|
|
case commands.AgentCycleReverseCommand:
|
|
updated, cmd := a.app.SwitchAgentReverse()
|
|
a.app = updated
|
|
cmds = append(cmds, cmd)
|
|
case commands.EditorOpenCommand:
|
|
if a.app.IsBusy() {
|
|
// status.Warn("Agent is working, please wait...")
|
|
return a, nil
|
|
}
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
|
|
}
|
|
|
|
value := a.editor.Value()
|
|
updated, cmd := a.editor.Clear()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
|
|
tmpfile, err := os.CreateTemp("", "msg_*.md")
|
|
tmpfile.WriteString(value)
|
|
if err != nil {
|
|
slog.Error("Failed to create temp file", "error", err)
|
|
return a, toast.NewErrorToast("Something went wrong, couldn't open editor")
|
|
}
|
|
tmpfile.Close()
|
|
parts := strings.Fields(editor)
|
|
c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
|
|
if err != nil {
|
|
slog.Error("Failed to open editor", "error", err)
|
|
return nil
|
|
}
|
|
content, err := os.ReadFile(tmpfile.Name())
|
|
if err != nil {
|
|
slog.Error("Failed to read file", "error", err)
|
|
return nil
|
|
}
|
|
if len(content) == 0 {
|
|
slog.Warn("Message is empty")
|
|
return nil
|
|
}
|
|
os.Remove(tmpfile.Name())
|
|
return app.SetEditorContentMsg{
|
|
Text: string(content),
|
|
}
|
|
})
|
|
cmds = append(cmds, cmd)
|
|
case commands.SessionNewCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
cmds = append(cmds, util.CmdHandler(app.SessionClearedMsg{}))
|
|
|
|
case commands.SessionListCommand:
|
|
sessionDialog := dialog.NewSessionDialog(a.app)
|
|
a.modal = sessionDialog
|
|
case commands.SessionTimelineCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, toast.NewErrorToast("No active session")
|
|
}
|
|
navigationDialog := dialog.NewTimelineDialog(a.app)
|
|
a.modal = navigationDialog
|
|
case commands.SessionShareCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
response, err := a.app.Client.Session.Share(context.Background(), a.app.Session.ID, opencode.SessionShareParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to share session", "error", err)
|
|
return a, toast.NewErrorToast("Failed to share session")
|
|
}
|
|
shareUrl := response.Share.URL
|
|
cmds = append(cmds, app.SetClipboard(shareUrl))
|
|
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
|
|
case commands.SessionUnshareCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
_, err := a.app.Client.Session.Unshare(context.Background(), a.app.Session.ID, opencode.SessionUnshareParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to unshare session", "error", err)
|
|
return a, toast.NewErrorToast("Failed to unshare session")
|
|
}
|
|
a.app.Session.Share.URL = ""
|
|
cmds = append(cmds, toast.NewSuccessToast("Session unshared successfully"))
|
|
case commands.SessionInterruptCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
a.app.Cancel(context.Background(), a.app.Session.ID)
|
|
return a, nil
|
|
case commands.SessionCompactCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
// TODO: block until compaction is complete
|
|
a.app.CompactSession(context.Background())
|
|
case commands.SessionChildCycleCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
cmds = append(cmds, func() tea.Msg {
|
|
parentSessionID := a.app.Session.ID
|
|
var parentSession *opencode.Session
|
|
if a.app.Session.ParentID != "" {
|
|
parentSessionID = a.app.Session.ParentID
|
|
session, err := a.app.Client.Session.Get(context.Background(), parentSessionID, opencode.SessionGetParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get parent session", "error", err)
|
|
return toast.NewErrorToast("Failed to get parent session")
|
|
}
|
|
parentSession = session
|
|
} else {
|
|
parentSession = a.app.Session
|
|
}
|
|
|
|
children, err := a.app.Client.Session.Children(context.Background(), parentSessionID, opencode.SessionChildrenParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get session children", "error", err)
|
|
return toast.NewErrorToast("Failed to get session children")
|
|
}
|
|
|
|
// Reverse sort the children (newest first)
|
|
slices.Reverse(*children)
|
|
|
|
// Create combined array: [parent, child1, child2, ...]
|
|
sessions := []*opencode.Session{parentSession}
|
|
for i := range *children {
|
|
sessions = append(sessions, &(*children)[i])
|
|
}
|
|
|
|
if len(sessions) == 1 {
|
|
return toast.NewInfoToast("No child sessions available")
|
|
}
|
|
|
|
// Find current session index in combined array
|
|
currentIndex := -1
|
|
for i, session := range sessions {
|
|
if session.ID == a.app.Session.ID {
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// If session not found, default to parent (shouldn't happen)
|
|
if currentIndex == -1 {
|
|
currentIndex = 0
|
|
}
|
|
|
|
// Cycle to next session (parent or child)
|
|
nextIndex := (currentIndex + 1) % len(sessions)
|
|
nextSession := sessions[nextIndex]
|
|
|
|
return app.SessionSelectedMsg(nextSession)
|
|
})
|
|
case commands.SessionChildCycleReverseCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, nil
|
|
}
|
|
cmds = append(cmds, func() tea.Msg {
|
|
parentSessionID := a.app.Session.ID
|
|
var parentSession *opencode.Session
|
|
if a.app.Session.ParentID != "" {
|
|
parentSessionID = a.app.Session.ParentID
|
|
session, err := a.app.Client.Session.Get(context.Background(), parentSessionID, opencode.SessionGetParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get parent session", "error", err)
|
|
return toast.NewErrorToast("Failed to get parent session")
|
|
}
|
|
parentSession = session
|
|
} else {
|
|
parentSession = a.app.Session
|
|
}
|
|
|
|
children, err := a.app.Client.Session.Children(context.Background(), parentSessionID, opencode.SessionChildrenParams{})
|
|
if err != nil {
|
|
slog.Error("Failed to get session children", "error", err)
|
|
return toast.NewErrorToast("Failed to get session children")
|
|
}
|
|
|
|
// Reverse sort the children (newest first)
|
|
slices.Reverse(*children)
|
|
|
|
// Create combined array: [parent, child1, child2, ...]
|
|
sessions := []*opencode.Session{parentSession}
|
|
for i := range *children {
|
|
sessions = append(sessions, &(*children)[i])
|
|
}
|
|
|
|
if len(sessions) == 1 {
|
|
return toast.NewInfoToast("No child sessions available")
|
|
}
|
|
|
|
// Find current session index in combined array
|
|
currentIndex := -1
|
|
for i, session := range sessions {
|
|
if session.ID == a.app.Session.ID {
|
|
currentIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// If session not found, default to parent (shouldn't happen)
|
|
if currentIndex == -1 {
|
|
currentIndex = 0
|
|
}
|
|
|
|
// Cycle to previous session (parent or child)
|
|
nextIndex := (currentIndex - 1 + len(sessions)) % len(sessions)
|
|
nextSession := sessions[nextIndex]
|
|
|
|
return app.SessionSelectedMsg(nextSession)
|
|
})
|
|
case commands.SessionExportCommand:
|
|
if a.app.Session.ID == "" {
|
|
return a, toast.NewErrorToast("No active session to export.")
|
|
}
|
|
|
|
// Use current conversation history
|
|
messages := a.app.Messages
|
|
if len(messages) == 0 {
|
|
return a, toast.NewInfoToast("No messages to export.")
|
|
}
|
|
|
|
// Format to Markdown
|
|
markdownContent := formatConversationToMarkdown(messages)
|
|
|
|
// Check if EDITOR is set
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
return a, toast.NewErrorToast("No EDITOR set, can't open editor")
|
|
}
|
|
|
|
// Create and write to temp file
|
|
tmpfile, err := os.CreateTemp("", "conversation-*.md")
|
|
if err != nil {
|
|
slog.Error("Failed to create temp file", "error", err)
|
|
return a, toast.NewErrorToast("Failed to create temporary file.")
|
|
}
|
|
|
|
_, err = tmpfile.WriteString(markdownContent)
|
|
if err != nil {
|
|
slog.Error("Failed to write to temp file", "error", err)
|
|
tmpfile.Close()
|
|
os.Remove(tmpfile.Name())
|
|
return a, toast.NewErrorToast("Failed to write conversation to file.")
|
|
}
|
|
tmpfile.Close()
|
|
|
|
// Open in editor
|
|
parts := strings.Fields(editor)
|
|
c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
|
|
c.Stdin = os.Stdin
|
|
c.Stdout = os.Stdout
|
|
c.Stderr = os.Stderr
|
|
cmd = tea.ExecProcess(c, func(err error) tea.Msg {
|
|
if err != nil {
|
|
slog.Error("Failed to open editor for conversation", "error", err)
|
|
}
|
|
// Clean up the file after editor closes
|
|
os.Remove(tmpfile.Name())
|
|
return nil
|
|
})
|
|
cmds = append(cmds, cmd)
|
|
case commands.ToolDetailsCommand:
|
|
message := "Tool details are now visible"
|
|
if a.messages.ToolDetailsVisible() {
|
|
message = "Tool details are now hidden"
|
|
}
|
|
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
|
|
cmds = append(cmds, toast.NewInfoToast(message))
|
|
case commands.ThinkingBlocksCommand:
|
|
message := "Thinking blocks are now visible"
|
|
if a.messages.ThinkingBlocksVisible() {
|
|
message = "Thinking blocks are now hidden"
|
|
}
|
|
cmds = append(cmds, util.CmdHandler(chat.ToggleThinkingBlocksMsg{}))
|
|
cmds = append(cmds, toast.NewInfoToast(message))
|
|
case commands.ModelListCommand:
|
|
modelDialog := dialog.NewModelDialog(a.app)
|
|
a.modal = modelDialog
|
|
|
|
case commands.AgentListCommand:
|
|
agentDialog := dialog.NewAgentDialog(a.app)
|
|
a.modal = agentDialog
|
|
case commands.ModelCycleRecentCommand:
|
|
slog.Debug("ModelCycleRecentCommand triggered")
|
|
updated, cmd := a.app.CycleRecentModel()
|
|
a.app = updated
|
|
cmds = append(cmds, cmd)
|
|
case commands.ModelCycleRecentReverseCommand:
|
|
updated, cmd := a.app.CycleRecentModelReverse()
|
|
a.app = updated
|
|
cmds = append(cmds, cmd)
|
|
case commands.ThemeListCommand:
|
|
themeDialog := dialog.NewThemeDialog()
|
|
a.modal = themeDialog
|
|
case commands.ProjectInitCommand:
|
|
cmds = append(cmds, a.app.InitializeProject(context.Background()))
|
|
case commands.InputClearCommand:
|
|
if a.editor.Value() == "" {
|
|
return a, nil
|
|
}
|
|
updated, cmd := a.editor.Clear()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.InputPasteCommand:
|
|
updated, cmd := a.editor.Paste()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.InputSubmitCommand:
|
|
updated, cmd := a.editor.Submit()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.InputNewlineCommand:
|
|
updated, cmd := a.editor.Newline()
|
|
a.editor = updated.(chat.EditorComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesFirstCommand:
|
|
updated, cmd := a.messages.GotoTop()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesLastCommand:
|
|
updated, cmd := a.messages.GotoBottom()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesPageUpCommand:
|
|
updated, cmd := a.messages.PageUp()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesPageDownCommand:
|
|
updated, cmd := a.messages.PageDown()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesHalfPageUpCommand:
|
|
updated, cmd := a.messages.HalfPageUp()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesHalfPageDownCommand:
|
|
updated, cmd := a.messages.HalfPageDown()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesCopyCommand:
|
|
updated, cmd := a.messages.CopyLastMessage()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesUndoCommand:
|
|
updated, cmd := a.messages.UndoLastMessage()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.MessagesRedoCommand:
|
|
updated, cmd := a.messages.RedoLastMessage()
|
|
a.messages = updated.(chat.MessagesComponent)
|
|
cmds = append(cmds, cmd)
|
|
case commands.AppExitCommand:
|
|
return a, tea.Quit
|
|
}
|
|
return a, tea.Batch(cmds...)
|
|
}
|
|
|
|
func NewModel(app *app.App) tea.Model {
|
|
commandProvider := completions.NewCommandCompletionProvider(app)
|
|
fileProvider := completions.NewFileContextGroup(app)
|
|
symbolsProvider := completions.NewSymbolsContextGroup(app)
|
|
agentsProvider := completions.NewAgentsContextGroup(app)
|
|
|
|
messages := chat.NewMessagesComponent(app)
|
|
editor := chat.NewEditorComponent(app)
|
|
completions := dialog.NewCompletionDialogComponent("/", commandProvider)
|
|
|
|
var leaderBinding *key.Binding
|
|
if app.Config.Keybinds.Leader != "" {
|
|
binding := key.NewBinding(key.WithKeys(app.Config.Keybinds.Leader))
|
|
leaderBinding = &binding
|
|
}
|
|
|
|
model := &Model{
|
|
status: status.NewStatusCmp(app),
|
|
app: app,
|
|
editor: editor,
|
|
messages: messages,
|
|
completions: completions,
|
|
commandProvider: commandProvider,
|
|
fileProvider: fileProvider,
|
|
symbolsProvider: symbolsProvider,
|
|
agentsProvider: agentsProvider,
|
|
leaderBinding: leaderBinding,
|
|
showCompletionDialog: false,
|
|
toastManager: toast.NewToastManager(),
|
|
interruptKeyState: InterruptKeyIdle,
|
|
exitKeyState: ExitKeyIdle,
|
|
}
|
|
|
|
return model
|
|
}
|
|
|
|
func formatConversationToMarkdown(messages []app.Message) string {
|
|
var builder strings.Builder
|
|
|
|
builder.WriteString("# Conversation History\n\n")
|
|
|
|
for _, msg := range messages {
|
|
builder.WriteString("---\n\n")
|
|
|
|
var role string
|
|
var timestamp time.Time
|
|
|
|
switch info := msg.Info.(type) {
|
|
case opencode.UserMessage:
|
|
role = "User"
|
|
timestamp = time.UnixMilli(int64(info.Time.Created))
|
|
case opencode.AssistantMessage:
|
|
role = "Assistant"
|
|
timestamp = time.UnixMilli(int64(info.Time.Created))
|
|
default:
|
|
continue
|
|
}
|
|
|
|
builder.WriteString(
|
|
fmt.Sprintf("**%s** (*%s*)\n\n", role, timestamp.Format("2006-01-02 15:04:05")),
|
|
)
|
|
|
|
for _, part := range msg.Parts {
|
|
switch p := part.(type) {
|
|
case opencode.TextPart:
|
|
builder.WriteString(p.Text + "\n\n")
|
|
case opencode.FilePart:
|
|
builder.WriteString(fmt.Sprintf("[File: %s]\n\n", p.Filename))
|
|
case opencode.ToolPart:
|
|
builder.WriteString(fmt.Sprintf("[Tool: %s]\n\n", p.Tool))
|
|
}
|
|
}
|
|
}
|
|
|
|
return builder.String()
|
|
}
|