Merge branch 'dev' into project

This commit is contained in:
Dax Raad
2025-08-23 16:26:45 -04:00
178 changed files with 10538 additions and 3764 deletions
+155 -13
View File
@@ -49,6 +49,8 @@ type App struct {
InitialSession *string
compactCancel context.CancelFunc
IsLeaderSequence bool
IsBashMode bool
ScrollSpeed int
}
func (a *App) Agent() *opencode.Agent {
@@ -79,6 +81,13 @@ type AgentSelectedMsg struct {
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendPrompt = Prompt
type SendShell = struct {
Command string
}
type SendCommand = struct {
Command string
Args string
}
type SetEditorContentMsg struct {
Text string
}
@@ -178,6 +187,11 @@ func New(
slog.Debug("Loaded config", "config", configInfo)
customCommands, err := httpClient.Command.List(ctx)
if err != nil {
return nil, err
}
app := &App{
Info: appInfo,
Agents: agents,
@@ -189,11 +203,12 @@ func New(
AgentIndex: agentIndex,
Session: &opencode.Session{},
Messages: []Message{},
Commands: commands.LoadFromConfig(configInfo),
Commands: commands.LoadFromConfig(configInfo, *customCommands),
InitialModel: initialModel,
InitialPrompt: initialPrompt,
InitialAgent: initialAgent,
InitialSession: initialSession,
ScrollSpeed: int(configInfo.Tui.ScrollSpeed),
}
return app, nil
@@ -201,6 +216,9 @@ func New(
func (a *App) Keybind(commandName commands.CommandName) string {
command := a.Commands[commandName]
if len(command.Keybindings) == 0 {
return ""
}
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
@@ -286,7 +304,7 @@ func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
return a.cycleMode(false)
}
func (a *App) CycleRecentModel() (*App, tea.Cmd) {
func (a *App) cycleRecentModel(forward bool) (*App, tea.Cmd) {
recentModels := a.State.RecentlyUsedModels
if len(recentModels) > 5 {
recentModels = recentModels[:5]
@@ -295,30 +313,62 @@ func (a *App) CycleRecentModel() (*App, tea.Cmd) {
return a, toast.NewInfoToast("Need at least 2 recent models to cycle")
}
nextIndex := 0
prevIndex := 0
for i, recentModel := range recentModels {
if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID && recentModel.ModelID == a.Model.ID {
if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID &&
recentModel.ModelID == a.Model.ID {
nextIndex = (i + 1) % len(recentModels)
prevIndex = (i - 1 + len(recentModels)) % len(recentModels)
break
}
}
targetIndex := nextIndex
if !forward {
targetIndex = prevIndex
}
for range recentModels {
currentRecentModel := recentModels[nextIndex%len(recentModels)]
provider, model := findModelByProviderAndModelID(a.Providers, currentRecentModel.ProviderID, currentRecentModel.ModelID)
currentRecentModel := recentModels[targetIndex%len(recentModels)]
provider, model := findModelByProviderAndModelID(
a.Providers,
currentRecentModel.ProviderID,
currentRecentModel.ModelID,
)
if provider != nil && model != nil {
a.Provider, a.Model = provider, model
a.State.AgentModel[a.Agent().Name] = AgentModel{ProviderID: provider.ID, ModelID: model.ID}
return a, tea.Sequence(a.SaveState(), toast.NewSuccessToast(fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name)))
a.State.AgentModel[a.Agent().Name] = AgentModel{
ProviderID: provider.ID,
ModelID: model.ID,
}
return a, tea.Sequence(
a.SaveState(),
toast.NewSuccessToast(
fmt.Sprintf("Switched to %s (%s)", model.Name, provider.Name),
),
)
}
recentModels = append(recentModels[:nextIndex%len(recentModels)], recentModels[nextIndex%len(recentModels)+1:]...)
recentModels = append(
recentModels[:targetIndex%len(recentModels)],
recentModels[targetIndex%len(recentModels)+1:]...)
if len(recentModels) < 2 {
a.State.RecentlyUsedModels = recentModels
return a, tea.Sequence(a.SaveState(), toast.NewInfoToast("Not enough valid recent models to cycle"))
return a, tea.Sequence(
a.SaveState(),
toast.NewInfoToast("Not enough valid recent models to cycle"),
)
}
}
a.State.RecentlyUsedModels = recentModels
return a, toast.NewErrorToast("Recent model not found")
}
func (a *App) CycleRecentModel() (*App, tea.Cmd) {
return a.cycleRecentModel(true)
}
func (a *App) CycleRecentModelReverse() (*App, tea.Cmd) {
return a.cycleRecentModel(false)
}
func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
// Find the agent index by name
for i, agent := range a.Agents {
@@ -464,10 +514,19 @@ func (a *App) InitializeProvider() tea.Cmd {
// Priority 3: Current agent's preferred model
if selectedProvider == nil && a.Agent().Model.ModelID != "" {
if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil && model != nil {
if provider, model := findModelByProviderAndModelID(providers, a.Agent().Model.ProviderID, a.Agent().Model.ModelID); provider != nil &&
model != nil {
selectedProvider = provider
selectedModel = model
slog.Debug("Selected model from current agent", "provider", provider.ID, "model", model.ID, "agent", a.Agent().Name)
slog.Debug(
"Selected model from current agent",
"provider",
provider.ID,
"model",
model.ID,
"agent",
a.Agent().Name,
)
} else {
slog.Debug("Agent model not found", "provider", a.Agent().Model.ProviderID, "model", a.Agent().Model.ModelID, "agent", a.Agent().Name)
}
@@ -600,7 +659,26 @@ func (a *App) IsBusy() bool {
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return true
return false
}
func (a *App) HasAnimatingWork() bool {
for _, msg := range a.Messages {
switch casted := msg.Info.(type) {
case opencode.AssistantMessage:
if casted.Time.Completed == 0 {
return true
}
}
for _, p := range msg.Parts {
if tp, ok := p.(opencode.ToolPart); ok {
if tp.State.Status == opencode.ToolPartStateStatusPending {
return true
}
}
}
}
return false
}
func (a *App) SaveState() tea.Cmd {
@@ -680,7 +758,7 @@ func (a *App) MarkProjectInitialized(ctx context.Context) error {
}
func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
session, err := a.Client.Session.New(ctx)
session, err := a.Client.Session.New(ctx, opencode.SessionNewParams{})
if err != nil {
return nil, err
}
@@ -724,6 +802,70 @@ func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
return a, tea.Batch(cmds...)
}
func (a *App) SendCommand(ctx context.Context, command string, args string) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
cmds = append(cmds, func() tea.Msg {
_, err := a.Client.Session.Command(
context.Background(),
a.Session.ID,
opencode.SessionCommandParams{
Command: opencode.F(command),
Arguments: opencode.F(args),
},
)
if err != nil {
slog.Error("Failed to execute command", "error", err)
return toast.NewErrorToast("Failed to execute command")
}
return nil
})
// The actual response will come through SSE
// For now, just return success
return a, tea.Batch(cmds...)
}
func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
if err != nil {
return a, toast.NewErrorToast(err.Error())
}
a.Session = session
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
cmds = append(cmds, func() tea.Msg {
_, err := a.Client.Session.Shell(
context.Background(),
a.Session.ID,
opencode.SessionShellParams{
Agent: opencode.F(a.Agent().Name),
Command: opencode.F(command),
},
)
if err != nil {
slog.Error("Failed to submit shell command", "error", err)
return toast.NewErrorToast("Failed to submit shell command")()
}
return nil
})
// The actual response will come through SSE
// For now, just return success
return a, tea.Batch(cmds...)
}
func (a *App) Cancel(ctx context.Context, sessionID string) error {
// Cancel any running compact operation
if a.compactCancel != nil {
-3
View File
@@ -28,15 +28,12 @@ type AgentModel struct {
type State struct {
Theme string `toml:"theme"`
ScrollSpeed *int `toml:"scroll_speed"`
AgentModel map[string]AgentModel `toml:"agent_model"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Agent string `toml:"agent"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
RecentlyUsedAgents []AgentUsage `toml:"recently_used_agents"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
ShowToolDetails *bool `toml:"show_tool_details"`
ShowThinkingBlocks *bool `toml:"show_thinking_blocks"`
+94 -89
View File
@@ -31,6 +31,7 @@ type Command struct {
Description string
Keybindings []Keybinding
Trigger []string
Custom bool
}
func (c Command) Keys() []string {
@@ -96,6 +97,7 @@ func (r CommandRegistry) Sorted() []Command {
})
return commands
}
func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
var matched []Command
for _, command := range r.Sorted() {
@@ -107,45 +109,51 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
}
const (
AppHelpCommand CommandName = "app_help"
SwitchAgentCommand CommandName = "switch_agent"
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export"
ToolDetailsCommand CommandName = "tool_details"
ThinkingBlocksCommand CommandName = "thinking_blocks"
ModelListCommand CommandName = "model_list"
AgentListCommand CommandName = "agent_list"
ModelCycleRecentCommand CommandName = "model_cycle_recent"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
SessionChildCycleCommand CommandName = "session_child_cycle"
SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
AgentCycleCommand CommandName = "agent_cycle"
AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
AppHelpCommand CommandName = "app_help"
SwitchAgentCommand CommandName = "switch_agent"
SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
EditorOpenCommand CommandName = "editor_open"
SessionNewCommand CommandName = "session_new"
SessionListCommand CommandName = "session_list"
SessionTimelineCommand CommandName = "session_timeline"
SessionShareCommand CommandName = "session_share"
SessionUnshareCommand CommandName = "session_unshare"
SessionInterruptCommand CommandName = "session_interrupt"
SessionCompactCommand CommandName = "session_compact"
SessionExportCommand CommandName = "session_export"
ToolDetailsCommand CommandName = "tool_details"
ThinkingBlocksCommand CommandName = "thinking_blocks"
ModelListCommand CommandName = "model_list"
AgentListCommand CommandName = "agent_list"
ModelCycleRecentCommand CommandName = "model_cycle_recent"
ThemeListCommand CommandName = "theme_list"
FileListCommand CommandName = "file_list"
FileCloseCommand CommandName = "file_close"
FileSearchCommand CommandName = "file_search"
FileDiffToggleCommand CommandName = "file_diff_toggle"
ProjectInitCommand CommandName = "project_init"
InputClearCommand CommandName = "input_clear"
InputPasteCommand CommandName = "input_paste"
InputSubmitCommand CommandName = "input_submit"
InputNewlineCommand CommandName = "input_newline"
MessagesPageUpCommand CommandName = "messages_page_up"
MessagesPageDownCommand CommandName = "messages_page_down"
MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
MessagesPreviousCommand CommandName = "messages_previous"
MessagesNextCommand CommandName = "messages_next"
MessagesFirstCommand CommandName = "messages_first"
MessagesLastCommand CommandName = "messages_last"
MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
MessagesCopyCommand CommandName = "messages_copy"
MessagesUndoCommand CommandName = "messages_undo"
MessagesRedoCommand CommandName = "messages_redo"
AppExitCommand CommandName = "app_exit"
)
func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
@@ -176,7 +184,7 @@ func parseBindings(bindings ...string) []Keybinding {
return parsedBindings
}
func LoadFromConfig(config *opencode.Config) CommandRegistry {
func LoadFromConfig(config *opencode.Config, customCommands []opencode.Command) CommandRegistry {
defaults := []Command{
{
Name: AppHelpCommand,
@@ -184,16 +192,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>h"),
Trigger: []string{"help"},
},
{
Name: SwitchAgentCommand,
Description: "next agent",
Keybindings: parseBindings("tab"),
},
{
Name: SwitchAgentReverseCommand,
Description: "previous agent",
Keybindings: parseBindings("shift+tab"),
},
{
Name: EditorOpenCommand,
Description: "open editor",
@@ -218,6 +216,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>l"),
Trigger: []string{"sessions", "resume", "continue"},
},
{
Name: SessionTimelineCommand,
Description: "show session timeline",
Keybindings: parseBindings("<leader>g"),
Trigger: []string{"timeline", "history", "goto"},
},
{
Name: SessionShareCommand,
Description: "share session",
@@ -240,6 +244,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>c"),
Trigger: []string{"compact", "summarize"},
},
{
Name: SessionChildCycleCommand,
Description: "cycle to next child session",
Keybindings: parseBindings("ctrl+right"),
},
{
Name: SessionChildCycleReverseCommand,
Description: "cycle to previous child session",
Keybindings: parseBindings("ctrl+left"),
},
{
Name: ToolDetailsCommand,
Description: "toggle tool details",
@@ -258,6 +272,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>m"),
Trigger: []string{"models"},
},
{
Name: ModelCycleRecentCommand,
Description: "next recent model",
Keybindings: parseBindings("f2"),
},
{
Name: ModelCycleRecentReverseCommand,
Description: "previous recent model",
Keybindings: parseBindings("shift+f2"),
},
{
Name: AgentListCommand,
Description: "list agents",
@@ -265,9 +289,14 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"agents"},
},
{
Name: ModelCycleRecentCommand,
Description: "cycle recent models",
Keybindings: parseBindings("f2"),
Name: AgentCycleCommand,
Description: "next agent",
Keybindings: parseBindings("tab"),
},
{
Name: AgentCycleReverseCommand,
Description: "previous agent",
Keybindings: parseBindings("shift+tab"),
},
{
Name: ThemeListCommand,
@@ -275,27 +304,6 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>t"),
Trigger: []string{"themes"},
},
// {
// Name: FileListCommand,
// Description: "list files",
// Keybindings: parseBindings("<leader>f"),
// Trigger: []string{"files"},
// },
{
Name: FileCloseCommand,
Description: "close file",
Keybindings: parseBindings("esc"),
},
{
Name: FileSearchCommand,
Description: "search file",
Keybindings: parseBindings("<leader>/"),
},
{
Name: FileDiffToggleCommand,
Description: "split/unified diff",
Keybindings: parseBindings("<leader>v"),
},
{
Name: ProjectInitCommand,
Description: "create/update AGENTS.md",
@@ -342,16 +350,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "half page down",
Keybindings: parseBindings("ctrl+alt+d"),
},
{
Name: MessagesPreviousCommand,
Description: "previous message",
Keybindings: parseBindings("ctrl+up"),
},
{
Name: MessagesNextCommand,
Description: "next message",
Keybindings: parseBindings("ctrl+down"),
},
{
Name: MessagesFirstCommand,
Description: "first message",
@@ -362,11 +361,7 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Description: "last message",
Keybindings: parseBindings("ctrl+alt+g"),
},
{
Name: MessagesLayoutToggleCommand,
Description: "toggle layout",
Keybindings: parseBindings("<leader>p"),
},
{
Name: MessagesCopyCommand,
Description: "copy message",
@@ -407,6 +402,16 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
}
registry[command.Name] = command
}
for _, command := range customCommands {
registry[CommandName(command.Name)] = Command{
Name: CommandName(command.Name),
Description: command.Description,
Trigger: []string{command.Name},
Keybindings: []Keybinding{},
Custom: true,
}
}
slog.Info("Loaded commands", "commands", registry)
return registry
}
@@ -39,6 +39,7 @@ type EditorComponent interface {
Focus() (tea.Model, tea.Cmd)
Blur()
Submit() (tea.Model, tea.Cmd)
SubmitBash() (tea.Model, tea.Cmd)
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
@@ -223,10 +224,17 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case dialog.CompletionSelectedMsg:
switch msg.Item.ProviderID {
case "commands":
commandName := strings.TrimPrefix(msg.Item.Value, "/")
command := msg.Item.RawData.(commands.Command)
if command.Custom {
m.SetValue("/" + command.PrimaryTrigger() + " ")
return m, nil
}
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
commandName := strings.TrimPrefix(msg.Item.Value, "/")
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
return m, tea.Batch(cmds...)
case "files":
@@ -338,10 +346,19 @@ func (m *editorComponent) Content() string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
promptStyle := styles.NewStyle().Foreground(t.Primary()).
Padding(0, 0, 0, 1).
Bold(true)
prompt := promptStyle.Render(">")
borderForeground := t.Border()
if m.app.IsLeaderSequence {
borderForeground = t.Accent()
}
if m.app.IsBashMode {
borderForeground = t.Secondary()
prompt = promptStyle.Render("!")
}
m.textarea.SetWidth(width - 6)
textarea := lipgloss.JoinHorizontal(
@@ -349,10 +366,6 @@ func (m *editorComponent) Content() string {
prompt,
m.textarea.View(),
)
borderForeground := t.Border()
if m.app.IsLeaderSequence {
borderForeground = t.Accent()
}
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(width).
@@ -475,6 +488,25 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
var cmds []tea.Cmd
if strings.HasPrefix(value, "/") {
value = value[1:]
commandName := strings.Split(value, " ")[0]
command := m.app.Commands[commands.CommandName(commandName)]
if command.Custom {
args := strings.TrimPrefix(value, command.PrimaryTrigger()+" ")
cmds = append(
cmds,
util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),
)
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
}
attachments := m.textarea.GetAttachments()
prompt := app.Prompt{Text: value, Attachments: attachments}
@@ -489,6 +521,16 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
command := m.textarea.Value()
var cmds []tea.Cmd
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
m.historyIndex = -1
@@ -14,6 +14,7 @@ import (
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -185,6 +186,8 @@ func renderContentBlock(
if renderer.borderRight {
style = style.BorderRightForeground(borderColor)
}
} else {
style = style.PaddingLeft(renderer.paddingLeft + 1).PaddingRight(renderer.paddingRight + 1)
}
content = style.Render(content)
@@ -211,6 +214,8 @@ func renderText(
width int,
extra string,
isThinking bool,
isQueued bool,
shimmer bool,
fileParts []opencode.FilePart,
agentParts []opencode.AgentPart,
toolCalls ...opencode.ToolPart,
@@ -232,7 +237,18 @@ func renderText(
}
content = util.ToMarkdown(text, width, backgroundColor)
if isThinking {
content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
var label string
if shimmer {
label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
} else {
label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
}
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
content = label + "\n\n" + content
} else if strings.TrimSpace(text) == "Generating..." {
label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
content = label
}
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
@@ -331,6 +347,10 @@ func renderText(
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
content = base.Width(width - 6).Render(wrappedText)
if isQueued {
queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
content = queuedStyle.Render("QUEUED") + "\n\n" + content
}
}
timestamp := ts.
@@ -400,12 +420,16 @@ func renderText(
switch message.(type) {
case opencode.UserMessage:
borderColor := t.Secondary()
if isQueued {
borderColor = t.Accent()
}
return renderContentBlock(
app,
content,
width,
WithTextColor(t.Text()),
WithBorderColor(t.Secondary()),
WithBorderColor(borderColor),
)
case opencode.AssistantMessage:
if isThinking {
@@ -470,6 +494,8 @@ func renderToolDetails(
backgroundColor := t.BackgroundPanel()
borderColor := t.BackgroundPanel()
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
permissionContent := ""
if permission.ID != "" {
@@ -554,6 +580,17 @@ func renderToolDetails(
title := renderToolTitle(toolCall, width)
title = style.Render(title)
content := title + "\n" + body
if toolCall.State.Status == opencode.ToolPartStateStatusError {
errorStyle := styles.NewStyle().
Background(backgroundColor).
Foreground(t.Error()).
Padding(1, 2).
Width(width - 4)
errorContent := errorStyle.Render(toolCall.State.Error)
content += "\n" + errorContent
}
if permissionContent != "" {
permissionContent = styles.NewStyle().
Background(backgroundColor).
@@ -582,14 +619,15 @@ func renderToolDetails(
}
}
case "bash":
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n$ %s\n", command)
output := metadata["output"]
if output != nil {
body += ansi.Strip(fmt.Sprintf("%s", output))
if command, ok := toolInputMap["command"].(string); ok {
body = fmt.Sprintf("```console\n$ %s\n", command)
output := metadata["output"]
if output != nil {
body += ansi.Strip(fmt.Sprintf("%s", output))
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
case "webfetch":
if format, ok := toolInputMap["format"].(string); ok && result != nil {
body = *result
@@ -633,6 +671,24 @@ func renderToolDetails(
steps = append(steps, step)
}
body = strings.Join(steps, "\n")
body += "\n\n"
// Build navigation hint with proper spacing
cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
var navParts []string
if cycleKeybind != "" {
navParts = append(navParts, baseStyle(cycleKeybind))
}
if cycleReverseKeybind != "" {
navParts = append(navParts, baseStyle(cycleReverseKeybind))
}
if len(navParts) > 0 {
body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
}
}
body = defaultStyle(body)
default:
@@ -652,11 +708,17 @@ func renderToolDetails(
}
if error != "" {
body = styles.NewStyle().
errorContent := styles.NewStyle().
Width(width - 6).
Foreground(t.Error()).
Background(backgroundColor).
Render(error)
if body == "" {
body = errorContent
} else {
body += "\n\n" + errorContent
}
}
if body == "" && error == "" && result != nil {
@@ -687,6 +749,8 @@ func renderToolDetails(
func renderToolName(name string) string {
switch name {
case "bash":
return "Shell"
case "webfetch":
return "Fetch"
case "invalid":
@@ -741,7 +805,9 @@ func renderToolTitle(
) string {
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
title := renderToolAction(toolCall.Tool)
return styles.NewStyle().Width(width - 6).Render(title)
t := theme.CurrentTheme()
shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
}
toolArgs := ""
@@ -857,7 +923,9 @@ func renderArgs(args *map[string]any, titleKey string) string {
continue
}
if key == "filePath" || key == "path" {
value = util.Relative(value.(string))
if strValue, ok := value.(string); ok {
value = util.Relative(strValue)
}
}
if key == titleKey {
title = fmt.Sprintf("%s", value)
+188 -111
View File
@@ -8,6 +8,7 @@ import (
"sort"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@@ -39,6 +40,7 @@ type MessagesComponent interface {
CopyLastMessage() (tea.Model, tea.Cmd)
UndoLastMessage() (tea.Model, tea.Cmd)
RedoLastMessage() (tea.Model, tea.Cmd)
ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
}
type messagesComponent struct {
@@ -57,6 +59,8 @@ type messagesComponent struct {
partCount int
lineCount int
selection *selection
messagePositions map[string]int // map message ID to line position
animating bool
}
type selection struct {
@@ -97,6 +101,7 @@ func (s selection) coords(offset int) *selection {
type ToggleToolDetailsMsg struct{}
type ToggleThinkingBlocksMsg struct{}
type shimmerTickMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init())
@@ -105,6 +110,15 @@ func (m *messagesComponent) Init() tea.Cmd {
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case shimmerTickMsg:
if !m.app.HasAnimatingWork() {
m.animating = false
return m, nil
}
return m, tea.Sequence(
m.renderView(),
tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
)
case tea.MouseClickMsg:
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
y := msg.Y + m.viewport.YOffset
@@ -132,15 +146,18 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case tea.MouseReleaseMsg:
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
if m.selection != nil {
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
if len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
return m, m.renderView()
}
case tea.WindowSizeMsg:
effectiveWidth := msg.Width - 4
@@ -157,6 +174,10 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.GotoBottom()
m.tail = true
return m, nil
case app.SendCommand:
m.viewport.GotoBottom()
m.tail = true
return m, nil
case dialog.ThemeSelectedMsg:
m.cache.Clear()
m.loading = true
@@ -169,7 +190,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.showThinkingBlocks = !m.showThinkingBlocks
m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
return m, tea.Batch(m.renderView(), m.app.SaveState())
case app.SessionLoadedMsg, app.SessionClearedMsg:
case app.SessionLoadedMsg:
m.tail = true
m.loading = true
return m, m.renderView()
case app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
m.loading = true
@@ -180,6 +205,23 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = true
return m, m.renderView()
}
case app.SessionSelectedMsg:
currentParent := m.app.Session.ParentID
if currentParent == "" {
currentParent = m.app.Session.ID
}
targetParent := msg.ParentID
if targetParent == "" {
targetParent = msg.ID
}
// Clear cache only if switching between different session families
if currentParent != targetParent {
m.cache.Clear()
}
m.viewport.GotoBottom()
case app.MessageRevertedMsg:
if msg.Session.ID == m.app.Session.ID {
m.cache.Clear()
@@ -203,6 +245,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Properties.Part.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessageRemoved:
if msg.Properties.SessionID == m.app.Session.ID {
m.cache.Clear()
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessagePartRemoved:
if msg.Properties.SessionID == m.app.Session.ID {
// Clear the cache when a part is removed to ensure proper re-rendering
@@ -221,6 +268,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rendering = false
m.clipboard = msg.clipboard
m.loading = false
m.messagePositions = msg.messagePositions
m.tail = m.viewport.AtBottom()
// Preserve scroll across reflow
@@ -238,6 +286,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.dirty {
cmds = append(cmds, m.renderView())
}
// Start shimmer ticks if any assistant/tool is in-flight
if !m.animating && m.app.HasAnimatingWork() {
m.animating = true
cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
}
}
m.tail = m.viewport.AtBottom()
@@ -249,11 +303,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
type renderCompleteMsg struct {
viewport viewport.Model
clipboard []string
header string
partCount int
lineCount int
viewport viewport.Model
clipboard []string
header string
partCount int
lineCount int
messagePositions map[string]int
}
func (m *messagesComponent) renderView() tea.Cmd {
@@ -279,11 +334,31 @@ func (m *messagesComponent) renderView() tea.Cmd {
blocks := make([]string, 0)
partCount := 0
lineCount := 0
messagePositions := make(map[string]int) // Track message ID to line position
orphanedToolCalls := make([]opencode.ToolPart, 0)
width := m.width // always use full width
// Find the last streaming ReasoningPart to only shimmer that one
lastStreamingReasoningID := ""
if m.showThinkingBlocks {
for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
continue
}
parts := m.app.Messages[mi].Parts
for pi := len(parts) - 1; pi >= 0; pi-- {
if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
lastStreamingReasoningID = rp.ID
break
}
}
}
}
}
reverted := false
revertedMessageCount := 0
revertedToolCount := 0
@@ -301,6 +376,9 @@ func (m *messagesComponent) renderView() tea.Cmd {
switch casted := message.Info.(type) {
case opencode.UserMessage:
// Track the position of this user message
messagePositions[casted.ID] = lineCount
if casted.ID == m.app.Session.Revert.MessageID {
reverted = true
revertedMessageCount = 1
@@ -368,10 +446,8 @@ func (m *messagesComponent) renderView() tea.Cmd {
)
author := m.app.Config.Username
if casted.ID > lastAssistantMessage {
author += " [queued]"
}
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
isQueued := casted.ID > lastAssistantMessage
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
@@ -383,15 +459,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
files,
false,
isQueued,
false,
fileParts,
agentParts,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
if content != "" {
@@ -464,16 +536,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
false,
false,
false,
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
@@ -486,16 +554,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
false,
false,
false,
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
partCount++
@@ -536,12 +600,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
permission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
m.cache.Set(key, content)
}
} else {
@@ -552,12 +610,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
permission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
}
if content != "" {
partCount++
@@ -574,6 +626,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
if part.Text != "" {
text := part.Text
shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
content = renderText(
m.app,
message.Info,
@@ -583,15 +636,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
true,
false,
shimmer,
[]opencode.FilePart{},
[]opencode.AgentPart{},
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
@@ -622,15 +671,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
"",
false,
false,
false,
[]opencode.FilePart{},
[]opencode.AgentPart{},
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
@@ -645,12 +690,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
width,
WithBorderColor(t.Error()),
)
error = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
error,
styles.WhitespaceStyle(t.Background()),
)
blocks = append(blocks, error)
lineCount += lipgloss.Height(error) + 1
}
@@ -736,22 +775,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
} else {
for _, part := range response.Parts {
if part.CallID == m.app.CurrentPermission.CallID {
content := renderToolDetails(
m.app,
part.AsUnion().(opencode.ToolPart),
m.app.CurrentPermission,
width,
)
content = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
content,
styles.WhitespaceStyle(t.Background()),
)
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
content := renderToolDetails(
m.app,
toolPart,
m.app.CurrentPermission,
width,
)
if content != "" {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
}
}
}
}
@@ -811,11 +846,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
return renderCompleteMsg{
header: header,
clipboard: clipboard,
viewport: viewport,
partCount: partCount,
lineCount: lineCount,
header: header,
clipboard: clipboard,
viewport: viewport,
partCount: partCount,
lineCount: lineCount,
messagePositions: messagePositions,
}
}
}
@@ -828,8 +864,17 @@ func (m *messagesComponent) renderHeader() string {
headerWidth := m.width
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
bgColor := t.Background()
borderColor := t.BackgroundElement()
isChildSession := m.app.Session.ParentID != ""
if isChildSession {
bgColor = t.BackgroundElement()
borderColor = t.Accent()
}
base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
sessionInfo := ""
tokens := float64(0)
@@ -861,20 +906,44 @@ func (m *messagesComponent) renderHeader() string {
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.Background()).
Background(bgColor).
Render(sessionInfoText)
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
navHint := ""
if isChildSession {
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
}
headerTextWidth := headerWidth
if !shareEnabled {
// +1 is to ensure there is always at least one space between header and session info
headerTextWidth -= len(sessionInfoText) + 1
if isChildSession {
headerTextWidth -= lipgloss.Width(navHint)
} else if !shareEnabled {
headerTextWidth -= lipgloss.Width(sessionInfoText)
}
headerText := util.ToMarkdown(
"# "+m.app.Session.Title,
headerTextWidth,
t.Background(),
bgColor,
)
if isChildSession {
headerText = layout.Render(
layout.FlexOptions{
Background: &bgColor,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
Width: headerTextWidth,
},
layout.FlexItem{
View: headerText,
},
layout.FlexItem{
View: navHint,
},
)
}
var items []layout.FlexItem
if shareEnabled {
@@ -887,10 +956,9 @@ func (m *messagesComponent) renderHeader() string {
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
}
background := t.Background()
headerRow := layout.Render(
layout.FlexOptions{
Background: &background,
Background: &bgColor,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
@@ -906,22 +974,16 @@ func (m *messagesComponent) renderHeader() string {
header := strings.Join(headerLines, "\n")
header = styles.NewStyle().
Background(t.Background()).
Background(bgColor).
Width(headerWidth).
PaddingLeft(2).
PaddingRight(2).
BorderLeft(true).
BorderRight(true).
BorderBackground(t.Background()).
BorderForeground(t.BackgroundElement()).
BorderForeground(borderColor).
BorderStyle(lipgloss.ThickBorder()).
Render(header)
header = lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
header,
styles.WhitespaceStyle(t.Background()),
)
return "\n" + header + "\n"
}
@@ -966,7 +1028,7 @@ func formatTokensAndCost(
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"%s/%d%% (%s)",
" %s/%d%% (%s)",
formattedTokens,
int(percentage),
formattedCost,
@@ -975,20 +1037,22 @@ func formatTokensAndCost(
func (m *messagesComponent) View() string {
t := theme.CurrentTheme()
bgColor := t.Background()
if m.loading {
return lipgloss.Place(
m.width,
m.height,
lipgloss.Center,
lipgloss.Center,
styles.NewStyle().Background(t.Background()).Render(""),
styles.WhitespaceStyle(t.Background()),
styles.NewStyle().Background(bgColor).Render(""),
styles.WhitespaceStyle(bgColor),
)
}
viewport := m.viewport.View()
return styles.NewStyle().
Background(t.Background()).
Background(bgColor).
Render(m.header + "\n" + viewport)
}
@@ -1206,14 +1270,26 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
}
}
func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
if m.messagePositions == nil {
return m, nil
}
if position, exists := m.messagePositions[messageID]; exists {
m.viewport.SetYOffset(position)
m.tail = false // Stop auto-scrolling to bottom when manually navigating
}
return m, nil
}
func NewMessagesComponent(app *app.App) MessagesComponent {
vp := viewport.New()
vp.KeyMap = viewport.KeyMap{}
if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 {
vp.MouseWheelDelta = *app.State.ScrollSpeed
if app.ScrollSpeed > 0 {
vp.MouseWheelDelta = app.ScrollSpeed
} else {
vp.MouseWheelDelta = 4
vp.MouseWheelDelta = 2
}
// Default to showing tool details, hidden thinking blocks
@@ -1234,5 +1310,6 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
showThinkingBlocks: showThinkingBlocks,
cache: NewPartCache(),
tail: true,
messagePositions: make(map[string]int),
}
}
@@ -76,20 +76,15 @@ func (a agentSelectItem) Render(
agentName := a.displayName
// For user agents and subagents, show description; for built-in, show mode
// Determine if agent is built-in or custom using the agent's builtIn field
var displayText string
if a.description != "" && (a.mode == "all" || a.mode == "subagent") {
// User agent or subagent with description
displayText = a.description
if a.agent.BuiltIn {
displayText = "(built-in)"
} else {
// Built-in without description - show mode
switch a.mode {
case "primary":
displayText = "(built-in)"
case "all":
if a.description != "" {
displayText = a.description
} else {
displayText = "(user)"
default:
displayText = ""
}
}
@@ -206,23 +201,19 @@ func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
for _, agent := range agents {
// Calculate the width needed for this item: "AgentName - Description" (visual improvement)
itemWidth := len(agent.displayName)
if agent.description != "" && (agent.mode == "all" || agent.mode == "subagent") {
// User agent or subagent - use description (capped to maxDescriptionLength)
descLength := len(agent.description)
if descLength > maxDescriptionLength {
descLength = maxDescriptionLength
}
itemWidth += descLength + 3 // " - "
if agent.agent.BuiltIn {
itemWidth += len("(built-in)") + 3 // " - "
} else {
// Built-in without description - use mode
var modeText string
switch agent.mode {
case "primary":
modeText = "(built-in)"
case "all":
modeText = "(user)"
if agent.description != "" {
descLength := len(agent.description)
if descLength > maxDescriptionLength {
descLength = maxDescriptionLength
}
itemWidth += descLength + 3 // " - "
} else {
itemWidth += len("(user)") + 3 // " - "
}
itemWidth += len(modeText) + 3 // " - "
}
if itemWidth > maxWidth {
@@ -1,236 +0,0 @@
package dialog
import (
"log/slog"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/completions"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
const (
findDialogWidth = 76
)
type FindSelectedMsg struct {
FilePath string
}
type FindDialogCloseMsg struct{}
type findInitialSuggestionsMsg struct {
suggestions []completions.CompletionSuggestion
}
type FindDialog interface {
layout.Modal
tea.Model
tea.ViewModel
SetWidth(width int)
SetHeight(height int)
IsEmpty() bool
}
// findItem is a custom list item for file suggestions
type findItem struct {
suggestion completions.CompletionSuggestion
}
func (f findItem) Render(
selected bool,
width int,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
itemStyle := baseStyle.
Background(t.BackgroundPanel()).
Foreground(t.TextMuted())
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
}
func (f findItem) Selectable() bool {
return true
}
type findDialogComponent struct {
completionProvider completions.CompletionProvider
allSuggestions []completions.CompletionSuggestion
width, height int
modal *modal.Modal
searchDialog *SearchDialog
dialogWidth int
}
func (f *findDialogComponent) Init() tea.Cmd {
return tea.Batch(
f.loadInitialSuggestions(),
f.searchDialog.Init(),
)
}
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
return func() tea.Msg {
items, err := f.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get initial completion items", "error", err)
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
}
return findInitialSuggestionsMsg{suggestions: items}
}
}
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case findInitialSuggestionsMsg:
// Handle initial suggestions setup
f.allSuggestions = msg.suggestions
// Calculate dialog width
f.dialogWidth = f.calculateDialogWidth()
// Initialize search dialog with calculated width
f.searchDialog = NewSearchDialog("Search files...", 10)
f.searchDialog.SetWidth(f.dialogWidth)
// Convert to list items
items := make([]list.Item, len(f.allSuggestions))
for i, suggestion := range f.allSuggestions {
items[i] = findItem{suggestion: suggestion}
}
f.searchDialog.SetItems(items)
// Update modal with calculated width
f.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(f.dialogWidth+4),
)
return f, f.searchDialog.Init()
case []completions.CompletionSuggestion:
// Store suggestions and convert to findItem for the search dialog
f.allSuggestions = msg
items := make([]list.Item, len(msg))
for i, suggestion := range msg {
items[i] = findItem{suggestion: suggestion}
}
f.searchDialog.SetItems(items)
return f, nil
case SearchSelectionMsg:
// Handle selection from search dialog - now we can directly access the suggestion
if item, ok := msg.Item.(findItem); ok {
return f, f.selectFile(item.suggestion)
}
return f, nil
case SearchCancelledMsg:
return f, f.Close()
case SearchQueryChangedMsg:
// Update completion items based on search query
return f, func() tea.Msg {
items, err := f.completionProvider.GetChildEntries(msg.Query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
return []completions.CompletionSuggestion{}
}
return items
}
case tea.WindowSizeMsg:
f.width = msg.Width
f.height = msg.Height
// Recalculate width based on new viewport size
oldWidth := f.dialogWidth
f.dialogWidth = f.calculateDialogWidth()
if oldWidth != f.dialogWidth {
f.searchDialog.SetWidth(f.dialogWidth)
// Update modal max width too
f.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(f.dialogWidth+4),
)
}
f.searchDialog.SetHeight(msg.Height)
}
// Forward all other messages to the search dialog
updatedDialog, cmd := f.searchDialog.Update(msg)
f.searchDialog = updatedDialog.(*SearchDialog)
return f, cmd
}
func (f *findDialogComponent) View() string {
return f.searchDialog.View()
}
func (f *findDialogComponent) calculateDialogWidth() int {
// Use fixed width unless viewport is smaller
if f.width > 0 && f.width < findDialogWidth+10 {
return f.width - 10
}
return findDialogWidth
}
func (f *findDialogComponent) SetWidth(width int) {
f.width = width
f.searchDialog.SetWidth(f.dialogWidth)
}
func (f *findDialogComponent) SetHeight(height int) {
f.height = height
}
func (f *findDialogComponent) IsEmpty() bool {
return f.searchDialog.GetQuery() == ""
}
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
return tea.Sequence(
f.Close(),
util.CmdHandler(FindSelectedMsg{
FilePath: item.Value,
}),
)
}
func (f *findDialogComponent) Render(background string) string {
return f.modal.Render(f.View(), background)
}
func (f *findDialogComponent) Close() tea.Cmd {
f.searchDialog.SetQuery("")
f.searchDialog.Blur()
return util.CmdHandler(modal.CloseModalMsg{})
}
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
component := &findDialogComponent{
completionProvider: completionProvider,
dialogWidth: findDialogWidth,
allSuggestions: []completions.CompletionSuggestion{},
}
// Create search dialog and modal with fixed width
component.searchDialog = NewSearchDialog("Search files...", 10)
component.searchDialog.SetWidth(findDialogWidth)
component.modal = modal.New(
modal.WithTitle("Find Files"),
modal.WithMaxWidth(findDialogWidth+4),
)
return component
}
@@ -1,184 +0,0 @@
package dialog
import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// InitDialogCmp is a component that asks the user if they want to initialize the project.
type InitDialogCmp struct {
width, height int
selected int
keys initDialogKeyMap
}
// NewInitDialogCmp creates a new InitDialogCmp.
func NewInitDialogCmp() InitDialogCmp {
return InitDialogCmp{
selected: 0,
keys: initDialogKeyMap{},
}
}
type initDialogKeyMap struct {
Tab key.Binding
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
Y key.Binding
N key.Binding
}
// ShortHelp implements key.Map.
func (k initDialogKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(
key.WithKeys("tab", "left", "right"),
key.WithHelp("tab/←/→", "toggle selection"),
),
key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "confirm"),
),
key.NewBinding(
key.WithKeys("esc", "q"),
key.WithHelp("esc/q", "cancel"),
),
key.NewBinding(
key.WithKeys("y", "n"),
key.WithHelp("y/n", "yes/no"),
),
}
}
// FullHelp implements key.Map.
func (k initDialogKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
// Init implements tea.Model.
func (m InitDialogCmp) Init() tea.Cmd {
return nil
}
// Update implements tea.Model.
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
m.selected = (m.selected + 1) % 2
return m, nil
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
return m, nil
}
// View implements tea.Model.
func (m InitDialogCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
// Calculate width needed for content
maxWidth := 60 // Width for explanation text
title := baseStyle.
Foreground(t.Primary()).
Bold(true).
Width(maxWidth).
Padding(0, 1).
Render("Initialize Project")
explanation := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(0, 1).
Render("Initialization generates a new AGENTS.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
question := baseStyle.
Foreground(t.Text()).
Width(maxWidth).
Padding(1, 1).
Render("Would you like to initialize this project?")
maxWidth = min(maxWidth, m.width-10)
yesStyle := baseStyle
noStyle := baseStyle
if m.selected == 0 {
yesStyle = yesStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
noStyle = noStyle.
Background(t.Background()).
Foreground(t.Primary())
} else {
noStyle = noStyle.
Background(t.Primary()).
Foreground(t.Background()).
Bold(true)
yesStyle = yesStyle.
Background(t.Background()).
Foreground(t.Primary())
}
yes := yesStyle.Padding(0, 3).Render("Yes")
no := noStyle.Padding(0, 3).Render("No")
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
buttons = baseStyle.
Width(maxWidth).
Padding(1, 0).
Render(buttons)
content := lipgloss.JoinVertical(
lipgloss.Left,
title,
baseStyle.Width(maxWidth).Render(""),
explanation,
question,
buttons,
baseStyle.Width(maxWidth).Render(""),
)
return baseStyle.Padding(1, 2).
Border(lipgloss.RoundedBorder()).
BorderBackground(t.Background()).
BorderForeground(t.TextMuted()).
Width(lipgloss.Width(content) + 4).
Render(content)
}
// SetSize sets the size of the component.
func (m *InitDialogCmp) SetSize(width, height int) {
m.width = width
m.height = height
}
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
type CloseInitDialogMsg struct {
Initialize bool
}
// ShowInitDialogMsg is a message that is sent to show the init dialog.
type ShowInitDialogMsg struct {
Show bool
}
@@ -234,7 +234,10 @@ func (s *sessionDialog) Render(background string) string {
t := theme.CurrentTheme()
renameView := s.renameInput.View()
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Render
helpText := mutedStyle("Enter to confirm, Esc to cancel")
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
@@ -245,11 +248,15 @@ func (s *sessionDialog) Render(background string) string {
listView := s.list.View()
t := theme.CurrentTheme()
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
keyStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundPanel()).
Bold(true).
Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename")
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
rightHelp := keyStyle("x/del") + mutedStyle(" delete")
bgColor := t.BackgroundPanel()
helpText := layout.Render(layout.FlexOptions{
@@ -0,0 +1,353 @@
package dialog
import (
"fmt"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
// TimelineDialog interface for the session timeline dialog
type TimelineDialog interface {
layout.Modal
}
// ScrollToMessageMsg is sent when a message should be scrolled to
type ScrollToMessageMsg struct {
MessageID string
}
// RestoreToMessageMsg is sent when conversation should be restored to a specific message
type RestoreToMessageMsg struct {
MessageID string
Index int
}
// timelineItem represents a user message in the timeline list
type timelineItem struct {
messageID string
content string
timestamp time.Time
index int // Index in the full message list
toolCount int // Number of tools used in this message
}
func (n timelineItem) Render(
selected bool,
width int,
isFirstInViewport bool,
baseStyle styles.Style,
isCurrent bool,
) string {
t := theme.CurrentTheme()
infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
// Add dot after timestamp if this is the current message - only apply color when not selected
var dot string
var dotVisualLen int
if isCurrent {
if selected {
dot = "● "
} else {
dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
}
dotVisualLen = 2 // "● " is 2 characters wide
}
// Format timestamp - only apply color when not selected
var timeStr string
var timeVisualLen int
if selected {
timeStr = n.timestamp.Format("15:04") + " " + dot
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
} else {
timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
}
// Tool count display (fixed width for alignment) - only apply color when not selected
toolInfo := ""
toolInfoVisualLen := 0
if n.toolCount > 0 {
toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
if selected {
toolInfo = toolInfoText
} else {
toolInfo = infoStyle(toolInfoText)
}
toolInfoVisualLen = lipgloss.Width(toolInfo)
}
// Calculate available space for content
// Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
contentWidth := max(width-reservedSpace, 8)
truncatedContent := truncate.StringWithTail(
strings.Split(n.content, "\n")[0],
uint(contentWidth),
"...",
)
// Apply normal text color to content for non-selected items
var styledContent string
if selected {
styledContent = truncatedContent
} else {
styledContent = textStyle(truncatedContent)
}
// Create the line with proper spacing - content left-aligned, tools right-aligned
var text string
text = timeStr + styledContent
if toolInfo != "" {
bgColor := t.BackgroundPanel()
if selected {
bgColor = t.Primary()
}
text = layout.Render(
layout.FlexOptions{
Background: &bgColor,
Direction: layout.Row,
Justify: layout.JustifySpaceBetween,
Align: layout.AlignStretch,
Width: width - 2,
},
layout.FlexItem{
View: text,
},
layout.FlexItem{
View: toolInfo,
},
)
}
var itemStyle styles.Style
if selected {
itemStyle = baseStyle.
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1)
} else {
itemStyle = baseStyle.PaddingLeft(1)
}
return itemStyle.Render(text)
}
func (n timelineItem) Selectable() bool {
return true
}
type timelineDialog struct {
width int
height int
modal *modal.Modal
list list.List[timelineItem]
app *app.App
}
func (n *timelineDialog) Init() tea.Cmd {
return nil
}
func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
n.width = msg.Width
n.height = msg.Height
n.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyPressMsg:
switch msg.String() {
case "up", "down":
// Handle navigation and immediately scroll to selected message
var cmd tea.Cmd
listModel, cmd := n.list.Update(msg)
n.list = listModel.(list.List[timelineItem])
// Get the newly selected item and scroll to it immediately
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
return n, tea.Sequence(
cmd,
util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
)
}
return n, cmd
case "r":
// Restore conversation to selected message
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
return n, tea.Sequence(
util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
util.CmdHandler(modal.CloseModalMsg{}),
)
}
case "enter":
// Keep Enter functionality for closing the modal
if _, idx := n.list.GetSelectedItem(); idx >= 0 {
return n, util.CmdHandler(modal.CloseModalMsg{})
}
}
}
var cmd tea.Cmd
listModel, cmd := n.list.Update(msg)
n.list = listModel.(list.List[timelineItem])
return n, cmd
}
func (n *timelineDialog) Render(background string) string {
listView := n.list.View()
t := theme.CurrentTheme()
keyStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundPanel()).
Bold(true).
Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
helpText := keyStyle(
"↑/↓",
) + mutedStyle(
" jump ",
) + keyStyle(
"r",
) + mutedStyle(
" restore",
)
bgColor := t.BackgroundPanel()
helpView := styles.NewStyle().
Background(bgColor).
Width(layout.Current.Container.Width - 14).
PaddingLeft(1).
PaddingTop(1).
Render(helpText)
content := strings.Join([]string{listView, helpView}, "\n")
return n.modal.Render(content, background)
}
func (n *timelineDialog) Close() tea.Cmd {
return nil
}
// extractMessagePreview extracts a preview from message parts
func extractMessagePreview(parts []opencode.PartUnion) string {
for _, part := range parts {
switch casted := part.(type) {
case opencode.TextPart:
text := strings.TrimSpace(casted.Text)
if text != "" {
return text
}
}
}
return "No text content"
}
// countToolsInResponse counts tools in the assistant's response to a user message
func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
count := 0
// Look at subsequent messages to find the assistant's response
for i := userMessageIndex + 1; i < len(messages); i++ {
message := messages[i]
// If we hit another user message, stop looking
if _, isUser := message.Info.(opencode.UserMessage); isUser {
break
}
// Count tools in this assistant message
for _, part := range message.Parts {
switch part.(type) {
case opencode.ToolPart:
count++
}
}
}
return count
}
// NewTimelineDialog creates a new session timeline dialog
func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
var items []timelineItem
// Filter to only user messages and extract relevant info
for i, message := range app.Messages {
if userMsg, ok := message.Info.(opencode.UserMessage); ok {
preview := extractMessagePreview(message.Parts)
toolCount := countToolsInResponse(app.Messages, i)
items = append(items, timelineItem{
messageID: userMsg.ID,
content: preview,
timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
index: i,
toolCount: toolCount,
})
}
}
listComponent := list.NewListComponent(
list.WithItems(items),
list.WithMaxVisibleHeight[timelineItem](12),
list.WithFallbackMessage[timelineItem]("No user messages in this session"),
list.WithAlphaNumericKeys[timelineItem](true),
list.WithRenderFunc(
func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
// Determine if this item is the current message for the session
isCurrent := false
if app.Session.Revert.MessageID != "" {
// When reverted, Session.Revert.MessageID contains the NEXT user message ID
// So we need to find the previous user message to highlight the correct one
for i, navItem := range items {
if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
// Found the next message, so the previous one is current
isCurrent = item.messageID == items[i-1].messageID
break
}
}
} else if len(app.Messages) > 0 {
// If not reverted, highlight the last user message
lastUserMsgID := ""
for i := len(app.Messages) - 1; i >= 0; i-- {
if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
lastUserMsgID = userMsg.ID
break
}
}
isCurrent = item.messageID == lastUserMsgID
}
// Only show the dot if undo/redo/restore is available
showDot := app.Session.Revert.MessageID != ""
return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
},
),
list.WithSelectableFunc(func(item timelineItem) bool {
return true
}),
)
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
return &timelineDialog{
list: listComponent,
app: app,
modal: modal.New(
modal.WithTitle("Session Timeline"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
),
}
}
@@ -1,281 +0,0 @@
package fileviewer
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
"github.com/sst/opencode/internal/viewport"
)
type DiffStyle int
const (
DiffStyleSplit DiffStyle = iota
DiffStyleUnified
)
type Model struct {
app *app.App
width, height int
viewport viewport.Model
filename *string
content *string
isDiff *bool
diffStyle DiffStyle
}
type fileRenderedMsg struct {
content string
}
func New(app *app.App) Model {
vp := viewport.New()
m := Model{
app: app,
viewport: vp,
diffStyle: DiffStyleUnified,
}
if app.State.SplitDiff {
m.diffStyle = DiffStyleSplit
}
return m
}
func (m Model) Init() tea.Cmd {
return m.viewport.Init()
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case fileRenderedMsg:
m.viewport.SetContent(msg.content)
return m, util.CmdHandler(app.FileRenderedMsg{
FilePath: *m.filename,
})
case dialog.ThemeSelectedMsg:
return m, m.render()
case tea.KeyMsg:
switch msg.String() {
// TODO
}
}
vp, cmd := m.viewport.Update(msg)
m.viewport = vp
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if !m.HasFile() {
return ""
}
header := *m.filename
header = styles.NewStyle().
Padding(1, 2).
Width(m.width).
Background(theme.CurrentTheme().BackgroundElement()).
Foreground(theme.CurrentTheme().Text()).
Render(header)
t := theme.CurrentTheme()
close := m.app.Key(commands.FileCloseCommand)
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
if m.isDiff == nil || *m.isDiff == false {
diffToggle = ""
}
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
background := t.Background()
footer := layout.Render(
layout.FlexOptions{
Background: &background,
Direction: layout.Row,
Justify: layout.JustifyCenter,
Align: layout.AlignStretch,
Width: m.width - 2,
Gap: 5,
},
layout.FlexItem{
View: close,
},
layout.FlexItem{
View: layoutToggle,
},
layout.FlexItem{
View: diffToggle,
},
)
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
return header + "\n" + m.viewport.View() + "\n" + footer
}
func (m *Model) Clear() (Model, tea.Cmd) {
m.filename = nil
m.content = nil
m.isDiff = nil
return *m, m.render()
}
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
switch m.diffStyle {
case DiffStyleSplit:
m.diffStyle = DiffStyleUnified
default:
m.diffStyle = DiffStyleSplit
}
return *m, m.render()
}
func (m *Model) DiffStyle() DiffStyle {
return m.diffStyle
}
func (m Model) HasFile() bool {
return m.filename != nil && m.content != nil
}
func (m Model) Filename() string {
if m.filename == nil {
return ""
}
return *m.filename
}
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
if m.width != width || m.height != height {
m.width = width
m.height = height
m.viewport.SetWidth(width)
m.viewport.SetHeight(height - 4)
return *m, m.render()
}
return *m, nil
}
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
m.filename = &filename
m.content = &content
m.isDiff = &isDiff
return *m, m.render()
}
func (m *Model) render() tea.Cmd {
if m.filename == nil || m.content == nil {
m.viewport.SetContent("")
return nil
}
return func() tea.Msg {
t := theme.CurrentTheme()
var rendered string
if m.isDiff != nil && *m.isDiff {
diffResult := ""
var err error
if m.diffStyle == DiffStyleSplit {
diffResult, err = diff.FormatDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
} else if m.diffStyle == DiffStyleUnified {
diffResult, err = diff.FormatUnifiedDiff(
*m.filename,
*m.content,
diff.WithWidth(m.width),
)
}
if err != nil {
rendered = styles.NewStyle().
Foreground(t.Error()).
Render(fmt.Sprintf("Error rendering diff: %v", err))
} else {
rendered = strings.TrimRight(diffResult, "\n")
}
} else {
rendered = util.RenderFile(
*m.filename,
*m.content,
m.width,
)
}
rendered = styles.NewStyle().
Width(m.width).
Background(t.BackgroundPanel()).
Render(rendered)
return fileRenderedMsg{
content: rendered,
}
}
}
func (m *Model) ScrollTo(line int) {
m.viewport.SetYOffset(line)
}
func (m *Model) ScrollToBottom() {
m.viewport.GotoBottom()
}
func (m *Model) ScrollToTop() {
m.viewport.GotoTop()
}
func (m *Model) PageUp() (Model, tea.Cmd) {
m.viewport.ViewUp()
return *m, nil
}
func (m *Model) PageDown() (Model, tea.Cmd) {
m.viewport.ViewDown()
return *m, nil
}
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
m.viewport.HalfViewUp()
return *m, nil
}
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
m.viewport.HalfViewDown()
return *m, nil
}
func (m Model) AtTop() bool {
return m.viewport.AtTop()
}
func (m Model) AtBottom() bool {
return m.viewport.AtBottom()
}
func (m Model) ScrollPercent() float64 {
return m.viewport.ScrollPercent()
}
func (m Model) TotalLineCount() int {
return m.viewport.TotalLineCount()
}
func (m Model) VisibleLineCount() int {
return m.viewport.VisibleLineCount()
}
@@ -132,7 +132,7 @@ func (m *statusComponent) View() string {
modeForeground = t.BackgroundPanel()
}
command := m.app.Commands[commands.SwitchAgentCommand]
command := m.app.Commands[commands.AgentCycleCommand]
kb := command.Keybindings[0]
key := kb.Key
if kb.RequiresLeader {
@@ -670,6 +670,28 @@ func (m *Model) InsertAttachment(att *attachment.Attachment) {
m.SetCursorColumn(m.col)
}
// removeAttachmentAtCursor replaces the attachment at or immediately before the
// cursor with its textual display and positions the cursor at the end of the
// inserted text. Returns true if an attachment was removed.
func (m *Model) removeAttachmentAtCursor() bool {
att, startIdx, _ := m.isAttachmentAtCursor()
if att == nil {
return false
}
// Replace the attachment element with the display runes
before := m.value[m.row][:startIdx]
after := m.value[m.row][startIdx+1:]
replacement := runesToInterfaces([]rune(att.Display))
newRow := make([]any, 0, len(before)+len(replacement)+len(after))
newRow = append(newRow, before...)
newRow = append(newRow, replacement...)
newRow = append(newRow, after...)
m.value[m.row] = newRow
m.col = startIdx + len(replacement)
m.SetCursorColumn(m.col)
return true
}
// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
// This preserves attachments outside the replaced range.
func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
@@ -1577,6 +1599,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
// If the cursor is at or just after an attachment, convert it to text instead of deleting
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
if m.removeAttachmentAtCursor() {
break
}
}
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
@@ -1587,6 +1615,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.SetCursorColumn(m.col - 1)
}
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
// If the cursor is on an attachment, convert it to text instead of deleting
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
if m.removeAttachmentAtCursor() {
break
}
}
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
}
@@ -0,0 +1,75 @@
package textarea
import (
"testing"
"github.com/sst/opencode/internal/attachment"
)
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
m := New()
m.InsertString("a ")
att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
m.InsertAttachment(att)
m.InsertString(" b")
// Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
m.SetCursorColumn(3)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur")
}
got := m.Value()
want := "a @file.txt b"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
m := New()
m.InsertString("x ")
att := &attachment.Attachment{ID: "2", Display: "@img.png"}
m.InsertAttachment(att)
m.InsertString(" y")
// Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
m.SetCursorColumn(2)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur")
}
got := m.Value()
want := "x @img.png y"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
m := New()
att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
m.InsertAttachment(att)
m.InsertString(" tail")
// Position cursor immediately after the attachment at start of line (index 1)
m.SetCursorColumn(1)
if ok := m.removeAttachmentAtCursor(); !ok {
t.Fatalf("expected removal to occur at start of line")
}
if got := m.Value(); got != "@a.txt tail" {
t.Fatalf("unexpected value: %q", got)
}
}
func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
m := New()
m.InsertString("hello world")
col := m.CursorColumn()
if ok := m.removeAttachmentAtCursor(); ok {
t.Fatalf("did not expect removal to occur")
}
if m.Value() != "hello world" || m.CursorColumn() != col {
t.Fatalf("value or cursor unexpectedly changed")
}
}
+300 -102
View File
@@ -23,7 +23,6 @@ import (
"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/fileviewer"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
@@ -78,7 +77,6 @@ type Model struct {
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
messagesRight bool
fileViewer fileviewer.Model
}
func (a Model) Init() tea.Cmd {
@@ -94,13 +92,6 @@ func (a Model) Init() tea.Cmd {
cmds = append(cmds, a.status.Init())
cmds = append(cmds, a.completions.Init())
cmds = append(cmds, a.toastManager.Init())
cmds = append(cmds, a.fileViewer.Init())
// Check if we should show the init dialog
cmds = append(cmds, func() tea.Msg {
shouldShow := a.app.Info.Git && a.app.Info.Time.Initialized > 0
return dialog.ShowInitDialogMsg{Show: shouldShow}
})
return tea.Batch(cmds...)
}
@@ -151,6 +142,23 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
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 {
@@ -189,7 +197,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 3. Handle completions trigger
if keyString == "/" &&
!a.showCompletionDialog &&
a.editor.Value() == "" {
a.editor.Value() == "" &&
!a.app.IsBashMode {
a.showCompletionDialog = true
updated, cmd := a.editor.Update(msg)
@@ -207,7 +216,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Handle file completions trigger
if keyString == "@" &&
!a.showCompletionDialog {
!a.showCompletionDialog &&
!a.app.IsBashMode {
a.showCompletionDialog = true
updated, cmd := a.editor.Update(msg)
@@ -223,6 +233,11 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.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":
@@ -376,8 +391,59 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, toast.NewErrorToast(msg.Error())
case app.SendPrompt:
a.showCompletionDialog = false
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
cmds = append(cmds, cmd)
// 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)
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)
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)
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)
@@ -559,12 +625,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
slog.Error("Server error", "name", err.Name, "message", err.Data.Message)
return a, toast.NewErrorToast(err.Data.Message, toast.WithTitle(string(err.Name)))
}
case opencode.EventListResponseEventFileWatcherUpdated:
if a.fileViewer.HasFile() {
if a.fileViewer.Filename() == msg.Properties.File {
return a.openFile(msg.Properties.File)
}
}
case tea.WindowSizeMsg:
msg.Height -= 2 // Make space for the status bar
a.width, a.height = msg.Width, msg.Height
@@ -579,6 +639,10 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
},
}
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())
@@ -586,10 +650,43 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.app.Session = msg
a.app.Messages = messages
return a, util.CmdHandler(app.SessionLoadedMsg{})
cmds = append(cmds, util.CmdHandler(app.SessionLoadedMsg{}))
return a, tea.Batch(cmds...)
case app.SessionCreatedMsg:
a.app.Session = msg.Session
return a, util.CmdHandler(app.SessionLoadedMsg{})
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)
} 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
@@ -626,8 +723,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Reset exit key state after timeout
a.exitKeyState = ExitKeyIdle
a.editor.SetExitKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
case tea.PasteMsg, tea.ClipboardMsg:
// Paste events: prioritize modal if active, otherwise editor
if a.modal != nil {
@@ -651,6 +746,9 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -695,6 +793,45 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
@@ -726,10 +863,6 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
}
fv, cmd := a.fileViewer.Update(msg)
a.fileViewer = fv
cmds = append(cmds, cmd)
return a, tea.Batch(cmds...)
}
@@ -779,26 +912,6 @@ func (a Model) Cleanup() {
a.status.Cleanup()
}
func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
response, err := a.app.Client.File.Read(
context.Background(),
opencode.FileReadParams{
Path: opencode.F(filepath),
},
)
if err != nil {
slog.Error("Failed to read file", "error", err)
return a, toast.NewErrorToast("Failed to read file")
}
a.fileViewer, cmd = a.fileViewer.SetFile(
filepath,
response.Content,
response.Type == "patch",
)
return a, cmd
}
func (a Model) home() (string, int, int) {
t := theme.CurrentTheme()
effectiveWidth := a.width - 4
@@ -987,11 +1100,11 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
case commands.AppHelpCommand:
helpDialog := dialog.NewHelpDialog(a.app)
a.modal = helpDialog
case commands.SwitchAgentCommand:
case commands.AgentCycleCommand:
updated, cmd := a.app.SwitchAgent()
a.app = updated
cmds = append(cmds, cmd)
case commands.SwitchAgentReverseCommand:
case commands.AgentCycleReverseCommand:
updated, cmd := a.app.SwitchAgentReverse()
a.app = updated
cmds = append(cmds, cmd)
@@ -1051,6 +1164,12 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
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
@@ -1086,6 +1205,122 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
}
// 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)
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)
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)
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)
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.")
@@ -1163,24 +1398,13 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
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.FileListCommand:
// a.editor.Blur()
// findDialog := dialog.NewFindDialog(a.fileProvider)
// cmds = append(cmds, findDialog.Init())
// a.modal = findDialog
case commands.FileCloseCommand:
a.fileViewer, cmd = a.fileViewer.Clear()
cmds = append(cmds, cmd)
case commands.FileDiffToggleCommand:
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
cmds = append(cmds, cmd)
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
cmds = append(cmds, a.app.SaveState())
case commands.FileSearchCommand:
return a, nil
case commands.ProjectInitCommand:
cmds = append(cmds, a.app.InitializeProject(context.Background()))
case commands.InputClearCommand:
@@ -1211,45 +1435,21 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageUpCommand:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.PageUp()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
updated, cmd := a.messages.PageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesPageDownCommand:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.PageDown()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
updated, cmd := a.messages.PageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageUpCommand:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageUp()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
updated, cmd := a.messages.HalfPageUp()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
case commands.MessagesHalfPageDownCommand:
if a.fileViewer.HasFile() {
a.fileViewer, cmd = a.fileViewer.HalfPageDown()
cmds = append(cmds, cmd)
} else {
updated, cmd := a.messages.HalfPageDown()
a.messages = updated.(chat.MessagesComponent)
cmds = append(cmds, cmd)
}
case commands.MessagesLayoutToggleCommand:
a.messagesRight = !a.messagesRight
a.app.State.MessagesRight = a.messagesRight
cmds = append(cmds, a.app.SaveState())
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)
@@ -1299,8 +1499,6 @@ func NewModel(app *app.App) tea.Model {
toastManager: toast.NewToastManager(),
interruptKeyState: InterruptKeyIdle,
exitKeyState: ExitKeyIdle,
fileViewer: fileviewer.New(app),
messagesRight: app.State.MessagesRight,
}
return model
+21 -10
View File
@@ -2,12 +2,31 @@ package util
import (
"context"
"fmt"
"log/slog"
"reflect"
"sync"
opencode "github.com/sst/opencode-sdk-go"
)
func sanitizeValue(val any) any {
if val == nil {
return nil
}
if err, ok := val.(error); ok {
return err.Error()
}
v := reflect.ValueOf(val)
if v.Kind() == reflect.Interface && !v.IsNil() {
return fmt.Sprintf("%T", val)
}
return val
}
type APILogHandler struct {
client *opencode.Client
service string
@@ -67,21 +86,13 @@ func (h *APILogHandler) Handle(ctx context.Context, r slog.Record) error {
h.mu.Lock()
for _, attr := range h.attrs {
val := attr.Value.Any()
if err, ok := val.(error); ok {
extra[attr.Key] = err.Error()
} else {
extra[attr.Key] = val
}
extra[attr.Key] = sanitizeValue(val)
}
h.mu.Unlock()
r.Attrs(func(attr slog.Attr) bool {
val := attr.Value.Any()
if err, ok := val.(error); ok {
extra[attr.Key] = err.Error()
} else {
extra[attr.Key] = val
}
extra[attr.Key] = sanitizeValue(val)
return true
})
+1 -1
View File
@@ -86,7 +86,7 @@ func Extension(path string) string {
func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string {
r := styles.GetMarkdownRenderer(width-6, backgroundColor)
content = strings.ReplaceAll(content, RootPath+"/", "")
hyphenRegex := regexp.MustCompile(`-([^ ]|$)`)
hyphenRegex := regexp.MustCompile(`-([^ \-|]|$)`)
content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
+143
View File
@@ -0,0 +1,143 @@
package util
import (
"math"
"os"
"strings"
"time"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/styles"
)
var shimmerStart = time.Now()
// Shimmer renders text with a moving foreground highlight.
// bg is the background color, dim is the base text color, bright is the highlight color.
func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat.AdaptiveColor) string {
if s == "" {
return ""
}
runes := []rune(s)
n := len(runes)
if n == 0 {
return s
}
pad := 10
period := float64(n + pad*2)
sweep := 2.5
elapsed := time.Since(shimmerStart).Seconds()
pos := (math.Mod(elapsed, sweep) / sweep) * period
half := 4.0
type seg struct {
useHex bool
hex string
bold bool
faint bool
text string
}
var segs []seg
useHex := hasTrueColor()
for i, r := range runes {
ip := float64(i + pad)
dist := math.Abs(ip - pos)
t := 0.0
if dist <= half {
x := math.Pi * (dist / half)
t = 0.5 * (1.0 + math.Cos(x))
}
// Cosine brightness: base + amp*t (quantized for grouping)
base := 0.55
amp := 0.45
brightness := base
if t > 0 {
brightness = base + amp*t
}
lvl := int(math.Round(brightness * 255.0))
if !useHex {
step := 24 // ~11 steps across range for non-truecolor
lvl = int(math.Round(float64(lvl)/float64(step))) * step
}
bold := lvl >= 208
faint := lvl <= 128
// truecolor if possible; else fallback to modifiers only
hex := ""
if useHex {
if lvl < 0 {
lvl = 0
}
if lvl > 255 {
lvl = 255
}
hex = rgbHex(lvl, lvl, lvl)
}
if len(segs) == 0 {
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
} else {
last := &segs[len(segs)-1]
if last.useHex == useHex && last.hex == hex && last.bold == bold && last.faint == faint {
last.text += string(r)
} else {
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
}
}
}
var b strings.Builder
for _, g := range segs {
st := styles.NewStyle().Background(bg)
if g.useHex && g.hex != "" {
c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)}
st = st.Foreground(c)
}
if g.bold {
st = st.Bold(true)
}
if g.faint {
st = st.Faint(true)
}
b.WriteString(st.Render(g.text))
}
return b.String()
}
func hasTrueColor() bool {
c := strings.ToLower(os.Getenv("COLORTERM"))
return strings.Contains(c, "truecolor") || strings.Contains(c, "24bit")
}
func rgbHex(r, g, b int) string {
if r < 0 {
r = 0
}
if r > 255 {
r = 255
}
if g < 0 {
g = 0
}
if g > 255 {
g = 255
}
if b < 0 {
b = 0
}
if b > 255 {
b = 255
}
return "#" + hex2(r) + hex2(g) + hex2(b)
}
func hex2(v int) string {
const digits = "0123456789abcdef"
return string([]byte{digits[(v>>4)&0xF], digits[v&0xF]})
}