Merge branch 'dev' into project

This commit is contained in:
Dax Raad
2025-08-12 18:52:30 -04:00
214 changed files with 15187 additions and 2009 deletions
+15 -10
View File
@@ -32,6 +32,7 @@ func main() {
var model *string = flag.String("model", "", "model to begin with")
var prompt *string = flag.String("prompt", "", "prompt to begin with")
var agent *string = flag.String("agent", "", "agent to begin with")
var sessionID *string = flag.String("session", "", "session ID")
flag.Parse()
url := os.Getenv("OPENCODE_SERVER")
@@ -44,14 +45,6 @@ func main() {
os.Exit(1)
}
agentsStr := os.Getenv("OPENCODE_AGENTS")
var agents []opencode.Agent
err = json.Unmarshal([]byte(agentsStr), &agents)
if err != nil {
slog.Error("Failed to unmarshal modes", "error", err)
os.Exit(1)
}
stat, err := os.Stdin.Stat()
if err != nil {
slog.Error("Failed to stat stdin", "error", err)
@@ -80,13 +73,25 @@ func main() {
option.WithBaseURL(url),
)
// Fetch agents from the /agent endpoint
agentsPtr, err := httpClient.App.Agents(context.Background())
if err != nil {
slog.Error("Failed to fetch agents", "error", err)
os.Exit(1)
}
if agentsPtr == nil {
slog.Error("No agents returned from server")
os.Exit(1)
}
agents := *agentsPtr
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
apiHandler := util.NewAPILogHandler(ctx, httpClient, "tui", slog.LevelDebug)
logger := slog.New(apiHandler)
slog.SetDefault(logger)
slog.Debug("TUI launched", "app", appInfoStr, "modes", agentsStr, "url", url)
slog.Debug("TUI launched", "app", appInfoStr, "agents_count", len(agents), "url", url)
go func() {
err = clipboard.Init()
@@ -96,7 +101,7 @@ func main() {
}()
// Create main context for the application
app_, err := app.New(ctx, version, appInfo, agents, httpClient, model, prompt, agent)
app_, err := app.New(ctx, version, appInfo, agents, httpClient, model, prompt, agent, sessionID)
if err != nil {
panic(err)
}
+142 -17
View File
@@ -46,6 +46,7 @@ type App struct {
InitialModel *string
InitialPrompt *string
InitialAgent *string
InitialSession *string
compactCancel context.CancelFunc
IsLeaderSequence bool
}
@@ -70,6 +71,11 @@ type ModelSelectedMsg struct {
Provider opencode.Provider
Model opencode.Model
}
type AgentSelectedMsg struct {
AgentName string
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendPrompt = Prompt
@@ -92,6 +98,7 @@ func New(
initialModel *string,
initialPrompt *string,
initialAgent *string,
initialSession *string,
) (*App, error) {
util.RootPath = appInfo.Path.Root
util.CwdPath = appInfo.Path.Cwd
@@ -172,20 +179,21 @@ func New(
slog.Debug("Loaded config", "config", configInfo)
app := &App{
Info: appInfo,
Agents: agents,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
AgentIndex: agentIndex,
Session: &opencode.Session{},
Messages: []Message{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: initialModel,
InitialPrompt: initialPrompt,
InitialAgent: initialAgent,
Info: appInfo,
Agents: agents,
Version: version,
StatePath: appStatePath,
Config: configInfo,
State: appState,
Client: httpClient,
AgentIndex: agentIndex,
Session: &opencode.Session{},
Messages: []Message{},
Commands: commands.LoadFromConfig(configInfo),
InitialModel: initialModel,
InitialPrompt: initialPrompt,
InitialAgent: initialAgent,
InitialSession: initialSession,
}
return app, nil
@@ -266,6 +274,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
}
a.State.Agent = a.Agent().Name
a.State.UpdateAgentUsage(a.Agent().Name)
return a, a.SaveState()
}
@@ -277,6 +286,78 @@ func (a *App) SwitchAgentReverse() (*App, tea.Cmd) {
return a.cycleMode(false)
}
func (a *App) CycleRecentModel() (*App, tea.Cmd) {
recentModels := a.State.RecentlyUsedModels
if len(recentModels) > 5 {
recentModels = recentModels[:5]
}
if len(recentModels) < 2 {
return a, toast.NewInfoToast("Need at least 2 recent models to cycle")
}
nextIndex := 0
for i, recentModel := range recentModels {
if a.Provider != nil && a.Model != nil && recentModel.ProviderID == a.Provider.ID && recentModel.ModelID == a.Model.ID {
nextIndex = (i + 1) % len(recentModels)
break
}
}
for range recentModels {
currentRecentModel := recentModels[nextIndex%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)))
}
recentModels = append(recentModels[:nextIndex%len(recentModels)], recentModels[nextIndex%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"))
}
}
a.State.RecentlyUsedModels = recentModels
return a, toast.NewErrorToast("Recent model not found")
}
func (a *App) SwitchToAgent(agentName string) (*App, tea.Cmd) {
// Find the agent index by name
for i, agent := range a.Agents {
if agent.Name == agentName {
a.AgentIndex = i
break
}
}
// Set up model for the new agent
modelID := a.Agent().Model.ModelID
providerID := a.Agent().Model.ProviderID
if modelID == "" {
if model, ok := a.State.AgentModel[a.Agent().Name]; ok {
modelID = model.ModelID
providerID = model.ProviderID
}
}
if modelID != "" {
for _, provider := range a.Providers {
if provider.ID == providerID {
a.Provider = &provider
for _, model := range provider.Models {
if model.ID == modelID {
a.Model = &model
break
}
}
break
}
}
}
a.State.Agent = a.Agent().Name
a.State.UpdateAgentUsage(agentName)
return a, a.SaveState()
}
// findModelByFullID finds a model by its full ID in the format "provider/model"
func findModelByFullID(
providers []opencode.Provider,
@@ -381,7 +462,18 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
// Priority 3: Recent model usage (most recently used model)
// 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 {
selectedProvider = provider
selectedModel = model
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)
}
}
// Priority 4: Recent model usage (most recently used model)
if selectedProvider == nil && len(a.State.RecentlyUsedModels) > 0 {
recentUsage := a.State.RecentlyUsedModels[0] // Most recent is first
if provider, model := findModelByProviderAndModelID(providers, recentUsage.ProviderID, recentUsage.ModelID); provider != nil &&
@@ -400,7 +492,7 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
// Priority 4: State-based model (backwards compatibility)
// Priority 5: State-based model (backwards compatibility)
if selectedProvider == nil && a.State.Provider != "" && a.State.Model != "" {
if provider, model := findModelByProviderAndModelID(providers, a.State.Provider, a.State.Model); provider != nil &&
model != nil {
@@ -412,7 +504,7 @@ func (a *App) InitializeProvider() tea.Cmd {
}
}
// Priority 5: Internal priority fallback (Anthropic preferred, then first available)
// Priority 6: Internal priority fallback (Anthropic preferred, then first available)
if selectedProvider == nil {
// Try Anthropic first as internal priority
if provider := findProviderByID(providers, "anthropic"); provider != nil {
@@ -457,6 +549,28 @@ func (a *App) InitializeProvider() tea.Cmd {
Provider: *selectedProvider,
Model: *selectedModel,
}))
// Load initial session if provided
if a.InitialSession != nil && *a.InitialSession != "" {
cmds = append(cmds, func() tea.Msg {
// Find the session by ID
sessions, err := a.ListSessions(context.Background())
if err != nil {
slog.Error("Failed to list sessions for initial session", "error", err)
return toast.NewErrorToast("Failed to load initial session")()
}
for _, session := range sessions {
if session.ID == *a.InitialSession {
return SessionSelectedMsg(&session)
}
}
slog.Warn("Initial session not found", "sessionID", *a.InitialSession)
return toast.NewErrorToast("Session not found: " + *a.InitialSession)()
})
}
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
}
@@ -646,6 +760,17 @@ func (a *App) DeleteSession(ctx context.Context, sessionID string) error {
return nil
}
func (a *App) UpdateSession(ctx context.Context, sessionID string, title string) error {
_, err := a.Client.Session.Update(ctx, sessionID, opencode.SessionUpdateParams{
Title: opencode.F(title),
})
if err != nil {
slog.Error("Failed to update session", "error", err)
return err
}
return nil
}
func (a *App) ListMessages(ctx context.Context, sessionId string) ([]Message, error) {
response, err := a.Client.Session.Messages(ctx, sessionId)
if err != nil {
+45
View File
@@ -16,6 +16,11 @@ type ModelUsage struct {
LastUsed time.Time `toml:"last_used"`
}
type AgentUsage struct {
AgentName string `toml:"agent_name"`
LastUsed time.Time `toml:"last_used"`
}
type AgentModel struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
@@ -29,9 +34,12 @@ type State struct {
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"`
}
func NewState() *State {
@@ -40,6 +48,7 @@ func NewState() *State {
Agent: "build",
AgentModel: make(map[string]AgentModel),
RecentlyUsedModels: make([]ModelUsage, 0),
RecentlyUsedAgents: make([]AgentUsage, 0),
MessageHistory: make([]Prompt, 0),
}
}
@@ -81,6 +90,42 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
}
}
// UpdateAgentUsage updates the recently used agents list with the specified agent
func (s *State) UpdateAgentUsage(agentName string) {
now := time.Now()
// Check if this agent is already in the list
for i, usage := range s.RecentlyUsedAgents {
if usage.AgentName == agentName {
s.RecentlyUsedAgents[i].LastUsed = now
usage := s.RecentlyUsedAgents[i]
copy(s.RecentlyUsedAgents[1:i+1], s.RecentlyUsedAgents[0:i])
s.RecentlyUsedAgents[0] = usage
return
}
}
newUsage := AgentUsage{
AgentName: agentName,
LastUsed: now,
}
// Prepend to slice and limit to last 20 entries
s.RecentlyUsedAgents = append([]AgentUsage{newUsage}, s.RecentlyUsedAgents...)
if len(s.RecentlyUsedAgents) > 20 {
s.RecentlyUsedAgents = s.RecentlyUsedAgents[:20]
}
}
func (s *State) RemoveAgentFromRecentlyUsed(agentName string) {
for i, usage := range s.RecentlyUsedAgents {
if usage.AgentName == agentName {
s.RecentlyUsedAgents = append(s.RecentlyUsedAgents[:i], s.RecentlyUsedAgents[i+1:]...)
return
}
}
}
func (s *State) AddPromptToHistory(prompt Prompt) {
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
if len(s.MessageHistory) > 50 {
+22 -1
View File
@@ -64,12 +64,13 @@ func (r CommandRegistry) Sorted() []Command {
commands = append(commands, command)
}
slices.SortFunc(commands, func(a, b Command) int {
// Priority order: session_new, session_share, model_list, app_help first, app_exit last
// Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last
priorityOrder := map[CommandName]int{
SessionNewCommand: 0,
AppHelpCommand: 1,
SessionShareCommand: 2,
ModelListCommand: 3,
AgentListCommand: 4,
}
aPriority, aHasPriority := priorityOrder[a.Name]
@@ -118,7 +119,10 @@ const (
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"
@@ -242,12 +246,29 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Keybindings: parseBindings("<leader>d"),
Trigger: []string{"details"},
},
{
Name: ThinkingBlocksCommand,
Description: "toggle thinking blocks",
Keybindings: parseBindings("<leader>b"),
Trigger: []string{"thinking"},
},
{
Name: ModelListCommand,
Description: "list models",
Keybindings: parseBindings("<leader>m"),
Trigger: []string{"models"},
},
{
Name: AgentListCommand,
Description: "list agents",
Keybindings: parseBindings("<leader>a"),
Trigger: []string{"agents"},
},
{
Name: ModelCycleRecentCommand,
Description: "cycle recent models",
Keybindings: parseBindings("f2"),
},
{
Name: ThemeListCommand,
Description: "list themes",
+1 -1
View File
@@ -45,7 +45,7 @@ func (cg *agentsContextGroup) GetChildEntries(
if query != "" && !strings.Contains(strings.ToLower(agent.Name), strings.ToLower(query)) {
continue
}
if agent.Mode == opencode.AgentModePrimary || agent.Name == "general" {
if agent.Mode == opencode.AgentModePrimary {
continue
}
@@ -31,6 +31,7 @@ type EditorComponent interface {
tea.Model
tea.ViewModel
Content() string
Cursor() *tea.Cursor
Lines() int
Value() string
Length() int
@@ -407,6 +408,10 @@ func (m *editorComponent) Content() string {
return content
}
func (m *editorComponent) Cursor() *tea.Cursor {
return m.textarea.Cursor()
}
func (m *editorComponent) View() string {
width := m.width
if m.app.Session.ID == "" {
@@ -694,6 +699,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta.VirtualCursor = false
ta = updateTextareaStyles(ta)
m := &editorComponent{
@@ -210,6 +210,7 @@ func renderText(
showToolDetails bool,
width int,
extra string,
isThinking bool,
fileParts []opencode.FilePart,
agentParts []opencode.AgentPart,
toolCalls ...opencode.ToolPart,
@@ -221,8 +222,18 @@ func renderText(
var content string
switch casted := message.(type) {
case opencode.AssistantMessage:
backgroundColor = t.Background()
if isThinking {
backgroundColor = t.BackgroundPanel()
}
ts = time.UnixMilli(int64(casted.Time.Created))
content = util.ToMarkdown(text, width, t.Background())
if casted.Time.Completed > 0 {
ts = time.UnixMilli(int64(casted.Time.Completed))
}
content = util.ToMarkdown(text, width, backgroundColor)
if isThinking {
content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
}
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
@@ -270,7 +281,26 @@ func renderText(
return 0
})
// Merge overlapping highlights to prevent duplication
merged := make([]highlightPart, 0)
for _, part := range highlights {
if len(merged) == 0 {
merged = append(merged, part)
continue
}
last := &merged[len(merged)-1]
// If current part overlaps with the last one, merge them
if part.start <= last.end {
if part.end > last.end {
last.end = part.end
}
} else {
merged = append(merged, part)
}
}
for _, part := range merged {
highlight := base.Foreground(part.color)
start, end := part.start, part.end
@@ -297,7 +327,9 @@ func renderText(
// wrap styled text
styledText := result.String()
wrappedText := ansi.WordwrapWc(styledText, width-6, " -")
styledText = strings.ReplaceAll(styledText, "-", "\u2011")
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
content = base.Width(width - 6).Render(wrappedText)
}
@@ -307,11 +339,46 @@ func renderText(
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
info := fmt.Sprintf("%s (%s)", author, timestamp)
info = styles.NewStyle().Foreground(t.TextMuted()).Render(info)
timestamp = styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Render(" (" + timestamp + ")")
// Check if this is an assistant message with agent information
var modelAndAgentSuffix string
if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" {
// Find the agent index by name to get the correct color
var agentIndex int
for i, agent := range app.Agents {
if agent.Name == assistantMsg.Mode {
agentIndex = i
break
}
}
// Get agent color based on the original agent index (same as status bar)
agentColor := util.GetAgentColor(agentIndex)
// Style the agent name with the same color as status bar
agentName := cases.Title(language.Und).String(assistantMsg.Mode)
styledAgentName := styles.NewStyle().
Background(backgroundColor).
Foreground(agentColor).
Render(agentName + " ")
styledModelID := styles.NewStyle().
Background(backgroundColor).
Foreground(t.TextMuted()).
Render(assistantMsg.ModelID)
modelAndAgentSuffix = styledAgentName + styledModelID
}
var info string
if modelAndAgentSuffix != "" {
info = modelAndAgentSuffix + timestamp
} else {
info = author + timestamp
}
if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 {
content = content + "\n\n"
for _, toolCall := range toolCalls {
title := renderToolTitle(toolCall, width-2)
style := styles.NewStyle()
@@ -319,15 +386,16 @@ func renderText(
style = style.Foreground(t.Error())
}
title = style.Render(title)
title = "∟ " + title + "\n"
title = "\n∟ " + title
content = content + title
}
}
sections := []string{content, info}
sections := []string{content}
if extra != "" {
sections = append(sections, "\n"+extra)
sections = append(sections, "\n"+extra+"\n")
}
sections = append(sections, info)
content = strings.Join(sections, "\n")
switch message.(type) {
@@ -340,6 +408,16 @@ func renderText(
WithBorderColor(t.Secondary()),
)
case opencode.AssistantMessage:
if isThinking {
return renderContentBlock(
app,
content,
width,
WithTextColor(t.Text()),
WithBackgroundColor(t.BackgroundPanel()),
WithBorderColor(t.BackgroundPanel()),
)
}
return renderContentBlock(
app,
content,
@@ -506,13 +584,9 @@ func renderToolDetails(
case "bash":
command := toolInputMap["command"].(string)
body = fmt.Sprintf("```console\n$ %s\n", command)
stdout := metadata["stdout"]
if stdout != nil {
body += ansi.Strip(fmt.Sprintf("%s", stdout))
}
stderr := metadata["stderr"]
if stderr != nil {
body += ansi.Strip(fmt.Sprintf("%s", stderr))
output := metadata["output"]
if output != nil {
body += ansi.Strip(fmt.Sprintf("%s", output))
}
body += "```"
body = util.ToMarkdown(body, width, backgroundColor)
+124 -26
View File
@@ -33,6 +33,7 @@ type MessagesComponent interface {
HalfPageUp() (tea.Model, tea.Cmd)
HalfPageDown() (tea.Model, tea.Cmd)
ToolDetailsVisible() bool
ThinkingBlocksVisible() bool
GotoTop() (tea.Model, tea.Cmd)
GotoBottom() (tea.Model, tea.Cmd)
CopyLastMessage() (tea.Model, tea.Cmd)
@@ -41,20 +42,21 @@ type MessagesComponent interface {
}
type messagesComponent struct {
width, height int
app *app.App
header string
viewport viewport.Model
clipboard []string
cache *PartCache
loading bool
showToolDetails bool
rendering bool
dirty bool
tail bool
partCount int
lineCount int
selection *selection
width, height int
app *app.App
header string
viewport viewport.Model
clipboard []string
cache *PartCache
loading bool
showToolDetails bool
showThinkingBlocks bool
rendering bool
dirty bool
tail bool
partCount int
lineCount int
selection *selection
}
type selection struct {
@@ -94,6 +96,7 @@ func (s selection) coords(offset int) *selection {
}
type ToggleToolDetailsMsg struct{}
type ToggleThinkingBlocksMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init())
@@ -160,7 +163,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.renderView()
case ToggleToolDetailsMsg:
m.showToolDetails = !m.showToolDetails
return m, m.renderView()
m.app.State.ShowToolDetails = &m.showToolDetails
return m, tea.Batch(m.renderView(), m.app.SaveState())
case ToggleThinkingBlocksMsg:
m.showThinkingBlocks = !m.showThinkingBlocks
m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
return m, tea.Batch(m.renderView(), m.app.SaveState())
case app.SessionLoadedMsg, app.SessionClearedMsg:
m.cache.Clear()
m.tail = true
@@ -187,6 +195,10 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.Properties.Info.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventSessionError:
if msg.Properties.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
}
case opencode.EventListResponseEventMessagePartUpdated:
if msg.Properties.Part.SessionID == m.app.Session.ID {
cmds = append(cmds, m.renderView())
@@ -210,7 +222,18 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clipboard = msg.clipboard
m.loading = false
m.tail = m.viewport.AtBottom()
// Preserve scroll across reflow
// if the user was at bottom, keep following; otherwise restore the previous offset.
wasAtBottom := m.viewport.AtBottom()
prevYOffset := m.viewport.YOffset
m.viewport = msg.viewport
if wasAtBottom {
m.viewport.GotoBottom()
} else {
m.viewport.YOffset = prevYOffset
}
m.header = msg.header
if m.dirty {
cmds = append(cmds, m.renderView())
@@ -218,7 +241,6 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.tail = m.viewport.AtBottom()
viewport, cmd := m.viewport.Update(msg)
m.viewport = viewport
cmds = append(cmds, cmd)
@@ -275,6 +297,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
for _, message := range m.app.Messages {
var content string
var cached bool
error := ""
switch casted := message.Info.(type) {
case opencode.UserMessage:
@@ -359,6 +382,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails,
width,
files,
false,
fileParts,
agentParts,
)
@@ -385,6 +409,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
revertedToolCount = 0
}
hasTextPart := false
hasContent := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
case opencode.TextPart:
@@ -427,7 +452,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
if finished {
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails)
key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
content, cached = m.cache.Get(key)
if !cached {
content = renderText(
@@ -438,6 +463,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails,
width,
"",
false,
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
@@ -459,6 +485,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
m.showToolDetails,
width,
"",
false,
[]opencode.FilePart{},
[]opencode.AgentPart{},
toolCallParts...,
@@ -474,6 +501,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
hasContent = true
}
case opencode.ToolPart:
if reverted {
@@ -535,14 +563,44 @@ func (m *messagesComponent) renderView() tea.Cmd {
partCount++
lineCount += lipgloss.Height(content) + 1
blocks = append(blocks, content)
hasContent = true
}
case opencode.ReasoningPart:
if reverted {
continue
}
if !m.showThinkingBlocks {
continue
}
if part.Text != "" {
text := part.Text
content = renderText(
m.app,
message.Info,
text,
casted.ModelID,
m.showToolDetails,
width,
"",
true,
[]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)
hasContent = true
}
}
}
}
error := ""
if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
switch err := assistant.Error.AsUnion().(type) {
switch err := casted.Error.AsUnion().(type) {
case nil:
case opencode.AssistantMessageErrorMessageOutputLengthError:
error = "Message output length exceeded"
@@ -553,6 +611,30 @@ func (m *messagesComponent) renderView() tea.Cmd {
case opencode.UnknownError:
error = err.Data.Message
}
if !hasContent && error == "" && !reverted {
content = renderText(
m.app,
message.Info,
"Generating...",
casted.ModelID,
m.showToolDetails,
width,
"",
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)
}
}
if error != "" && !reverted {
@@ -934,6 +1016,10 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
return m.showToolDetails
}
func (m *messagesComponent) ThinkingBlocksVisible() bool {
return m.showThinkingBlocks
}
func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
m.viewport.GotoTop()
return m, nil
@@ -1130,11 +1216,23 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
vp.MouseWheelDelta = 4
}
// Default to showing tool details, hidden thinking blocks
showToolDetails := true
if app.State.ShowToolDetails != nil {
showToolDetails = *app.State.ShowToolDetails
}
showThinkingBlocks := false
if app.State.ShowThinkingBlocks != nil {
showThinkingBlocks = *app.State.ShowThinkingBlocks
}
return &messagesComponent{
app: app,
viewport: vp,
showToolDetails: true,
cache: NewPartCache(),
tail: true,
app: app,
viewport: vp,
showToolDetails: showToolDetails,
showThinkingBlocks: showThinkingBlocks,
cache: NewPartCache(),
tail: true,
}
}
@@ -83,10 +83,10 @@ func (c *commandsComponent) View() string {
}
commandsToShow = append(commandsToShow,
// empty line
commands.Command{
Name: "",
Description: "",
},
// commands.Command{
// Name: "",
// Description: "",
// },
commands.Command{
Name: commands.CommandName(util.Ide()),
Description: "open opencode",
@@ -0,0 +1,461 @@
package dialog
import (
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"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"
)
const (
numVisibleAgents = 10
minAgentDialogWidth = 40
maxAgentDialogWidth = 60
maxDescriptionLength = 60
maxRecentAgents = 5
)
// AgentDialog interface for the agent selection dialog
type AgentDialog interface {
layout.Modal
}
type agentDialog struct {
app *app.App
allAgents []agentSelectItem
width int
height int
modal *modal.Modal
searchDialog *SearchDialog
dialogWidth int
}
// agentSelectItem combines the visual improvements with code patterns
type agentSelectItem struct {
name string
displayName string
description string
mode string // "primary", "subagent", "all"
isCurrent bool
agentIndex int
agent opencode.Agent // Keep original agent for compatibility
}
func (a agentSelectItem) Render(
selected bool,
width int,
baseStyle styles.Style,
) string {
t := theme.CurrentTheme()
itemStyle := baseStyle.
Background(t.BackgroundPanel()).
Foreground(t.Text())
if selected {
// Use agent color for highlighting when selected (visual improvement)
agentColor := util.GetAgentColor(a.agentIndex)
itemStyle = itemStyle.Foreground(agentColor)
}
descStyle := baseStyle.
Foreground(t.TextMuted()).
Background(t.BackgroundPanel())
// Calculate available width (accounting for padding and margins)
availableWidth := width - 2 // Account for left padding
agentName := a.displayName
// For user agents and subagents, show description; for built-in, show mode
var displayText string
if a.description != "" && (a.mode == "all" || a.mode == "subagent") {
// User agent or subagent with description
displayText = a.description
} else {
// Built-in without description - show mode
switch a.mode {
case "primary":
displayText = "(built-in)"
case "all":
displayText = "(user)"
default:
displayText = ""
}
}
separator := " - "
// Calculate how much space we have for the description (visual improvement)
nameAndSeparatorLength := len(agentName) + len(separator)
descriptionMaxLength := availableWidth - nameAndSeparatorLength
// Cap description length to the maximum allowed
if descriptionMaxLength > maxDescriptionLength {
descriptionMaxLength = maxDescriptionLength
}
// Truncate description if it's too long (visual improvement)
if len(displayText) > descriptionMaxLength && descriptionMaxLength > 3 {
displayText = displayText[:descriptionMaxLength-3] + "..."
}
namePart := itemStyle.Render(agentName)
descPart := descStyle.Render(separator + displayText)
combinedText := namePart + descPart
return baseStyle.
Background(t.BackgroundPanel()).
PaddingLeft(1).
Width(width).
Render(combinedText)
}
func (a agentSelectItem) Selectable() bool {
return true
}
type agentKeyMap struct {
Enter key.Binding
Escape key.Binding
}
var agentKeys = agentKeyMap{
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select agent"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "close"),
),
}
func (a *agentDialog) Init() tea.Cmd {
a.setupAllAgents()
return a.searchDialog.Init()
}
func (a *agentDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.width = msg.Width
a.height = msg.Height
a.searchDialog.SetWidth(a.dialogWidth)
a.searchDialog.SetHeight(msg.Height)
case SearchSelectionMsg:
// Handle selection from search dialog
if item, ok := msg.Item.(agentSelectItem); ok {
if !item.isCurrent {
// Switch to selected agent (using their better pattern)
return a, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.AgentSelectedMsg{AgentName: item.name}),
)
}
}
return a, util.CmdHandler(modal.CloseModalMsg{})
case SearchCancelledMsg:
return a, util.CmdHandler(modal.CloseModalMsg{})
case SearchRemoveItemMsg:
if item, ok := msg.Item.(agentSelectItem); ok {
if a.isAgentInRecentSection(item, msg.Index) {
a.app.State.RemoveAgentFromRecentlyUsed(item.name)
items := a.buildDisplayList(a.searchDialog.GetQuery())
a.searchDialog.SetItems(items)
return a, a.app.SaveState()
}
}
return a, nil
case SearchQueryChangedMsg:
// Update the list based on search query
items := a.buildDisplayList(msg.Query)
a.searchDialog.SetItems(items)
return a, nil
}
updatedDialog, cmd := a.searchDialog.Update(msg)
a.searchDialog = updatedDialog.(*SearchDialog)
return a, cmd
}
func (a *agentDialog) SetSize(width, height int) {
a.width = width
a.height = height
}
func (a *agentDialog) View() string {
return a.searchDialog.View()
}
func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
maxWidth := minAgentDialogWidth
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 // " - "
} else {
// Built-in without description - use mode
var modeText string
switch agent.mode {
case "primary":
modeText = "(built-in)"
case "all":
modeText = "(user)"
}
itemWidth += len(modeText) + 3 // " - "
}
if itemWidth > maxWidth {
maxWidth = itemWidth
}
}
maxWidth = min(maxWidth, maxAgentDialogWidth)
return maxWidth
}
func (a *agentDialog) setupAllAgents() {
currentAgentName := a.app.Agent().Name
// Build agent items from app.Agents (no API call needed) - their pattern
a.allAgents = make([]agentSelectItem, 0, len(a.app.Agents))
for i, agent := range a.app.Agents {
if agent.Mode == "subagent" {
continue // Skip subagents entirely
}
isCurrent := agent.Name == currentAgentName
// Create display name (capitalize first letter)
displayName := strings.Title(agent.Name)
a.allAgents = append(a.allAgents, agentSelectItem{
name: agent.Name,
displayName: displayName,
description: agent.Description, // Keep for search but don't use in display
mode: string(agent.Mode),
isCurrent: isCurrent,
agentIndex: i,
agent: agent, // Keep original for compatibility
})
}
a.sortAgents()
// Calculate optimal width based on all agents (visual improvement)
a.dialogWidth = a.calculateOptimalWidth(a.allAgents)
// Ensure minimum width to prevent textinput issues
a.dialogWidth = max(a.dialogWidth, minAgentDialogWidth)
a.searchDialog = NewSearchDialog("Search agents...", numVisibleAgents)
a.searchDialog.SetWidth(a.dialogWidth)
// Build initial display list (empty query shows grouped view)
items := a.buildDisplayList("")
a.searchDialog.SetItems(items)
}
func (a *agentDialog) sortAgents() {
sort.Slice(a.allAgents, func(i, j int) bool {
agentA := a.allAgents[i]
agentB := a.allAgents[j]
// Current agent goes first (your preference)
if agentA.name == a.app.Agent().Name {
return true
}
if agentB.name == a.app.Agent().Name {
return false
}
// Alphabetical order for all other agents
return agentA.name < agentB.name
})
}
// buildDisplayList creates the list items based on search query
func (a *agentDialog) buildDisplayList(query string) []list.Item {
if query != "" {
// Search mode: use fuzzy matching
return a.buildSearchResults(query)
} else {
// Grouped mode: show Recent agents section and alphabetical list (their pattern)
return a.buildGroupedResults()
}
}
// buildSearchResults creates a flat list of search results using fuzzy matching
func (a *agentDialog) buildSearchResults(query string) []list.Item {
agentNames := []string{}
agentMap := make(map[string]agentSelectItem)
for _, agent := range a.allAgents {
// Only include non-subagents in search
if agent.mode == "subagent" {
continue
}
searchStr := agent.name
agentNames = append(agentNames, searchStr)
agentMap[searchStr] = agent
}
matches := fuzzy.RankFindFold(query, agentNames)
sort.Sort(matches)
items := []list.Item{}
seenAgents := make(map[string]bool)
for _, match := range matches {
agent := agentMap[match.Target]
// Create a unique key to avoid duplicates
key := agent.name
if seenAgents[key] {
continue
}
seenAgents[key] = true
items = append(items, agent)
}
return items
}
// buildGroupedResults creates a grouped list with Recent agents section and categorized agents
func (a *agentDialog) buildGroupedResults() []list.Item {
var items []list.Item
// Add Recent section (their pattern)
recentAgents := a.getRecentAgents(maxRecentAgents)
if len(recentAgents) > 0 {
items = append(items, list.HeaderItem("Recent"))
for _, agent := range recentAgents {
items = append(items, agent)
}
}
// Create map of recent agent names for filtering
recentAgentNames := make(map[string]bool)
for _, recent := range recentAgents {
recentAgentNames[recent.name] = true
}
// Only show non-subagents (primary/user) in the main section
mainAgents := make([]agentSelectItem, 0)
for _, agent := range a.allAgents {
if !recentAgentNames[agent.name] {
mainAgents = append(mainAgents, agent)
}
}
// Sort main agents alphabetically
sort.Slice(mainAgents, func(i, j int) bool {
return mainAgents[i].name < mainAgents[j].name
})
// Add main agents section
if len(mainAgents) > 0 {
items = append(items, list.HeaderItem("Agents"))
for _, agent := range mainAgents {
items = append(items, agent)
}
}
return items
}
func (a *agentDialog) Render(background string) string {
return a.modal.Render(a.View(), background)
}
func (a *agentDialog) Close() tea.Cmd {
return nil
}
// getRecentAgents returns the most recently used agents (their pattern)
func (a *agentDialog) getRecentAgents(limit int) []agentSelectItem {
var recentAgents []agentSelectItem
// Get recent agents from app state
for _, usage := range a.app.State.RecentlyUsedAgents {
if len(recentAgents) >= limit {
break
}
// Find the corresponding agent
for _, agent := range a.allAgents {
if agent.name == usage.AgentName {
recentAgents = append(recentAgents, agent)
break
}
}
}
// If no recent agents, use the current agent
if len(recentAgents) == 0 {
currentAgentName := a.app.Agent().Name
for _, agent := range a.allAgents {
if agent.name == currentAgentName {
recentAgents = append(recentAgents, agent)
break
}
}
}
return recentAgents
}
func (a *agentDialog) isAgentInRecentSection(agent agentSelectItem, index int) bool {
// Only check if we're in grouped mode (no search query)
if a.searchDialog.GetQuery() != "" {
return false
}
recentAgents := a.getRecentAgents(maxRecentAgents)
if len(recentAgents) == 0 {
return false
}
// Index 0 is the "Recent" header, so recent agents are at indices 1 to len(recentAgents)
if index >= 1 && index <= len(recentAgents) {
if index-1 < len(recentAgents) {
recentAgent := recentAgents[index-1]
return recentAgent.name == agent.name
}
}
return false
}
func NewAgentDialog(app *app.App) AgentDialog {
dialog := &agentDialog{
app: app,
}
dialog.setupAllAgents()
dialog.modal = modal.New(
modal.WithTitle("Select Agent"),
modal.WithMaxWidth(dialog.dialogWidth+4),
)
return dialog
}
@@ -99,7 +99,10 @@ func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
// Ensure stable provider order just in case
sort.SliceStable(itemsByProvider, func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx })
sort.SliceStable(
itemsByProvider,
func(i, j int) bool { return itemsByProvider[i].idx < itemsByProvider[j].idx },
)
final := make([]completions.CompletionSuggestion, 0)
for _, entry := range itemsByProvider {
@@ -167,6 +170,16 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
value := c.pseudoSearchTextArea.Value()
width := lipgloss.Width(value)
triggerWidth := lipgloss.Width(c.trigger)
if msg.String() == "space" || msg.String() == " " {
item, i := c.list.GetSelectedItem()
if i > -1 {
return c, c.complete(item)
}
// If no exact match, close the dialog
return c, c.close()
}
// Only close on backspace when there are no characters left, unless we're back to just the trigger
if (msg.String() != "backspace" && msg.String() != "ctrl+h") || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
@@ -6,6 +6,7 @@ import (
"slices"
"github.com/charmbracelet/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode-sdk-go"
@@ -110,6 +111,9 @@ type sessionDialog struct {
list list.List[sessionItem]
app *app.App
deleteConfirmation int // -1 means no confirmation, >= 0 means confirming deletion of session at this index
renameMode bool
renameInput textinput.Model
renameIndex int // index of session being renamed
}
func (s *sessionDialog) Init() tea.Cmd {
@@ -123,69 +127,128 @@ func (s *sessionDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.height = msg.Height
s.list.SetMaxWidth(layout.Current.Container.Width - 12)
case tea.KeyPressMsg:
switch msg.String() {
case "enter":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
if s.renameMode {
switch msg.String() {
case "enter":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) && idx == s.renameIndex {
newTitle := s.renameInput.Value()
if strings.TrimSpace(newTitle) != "" {
sessionToUpdate := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
ctx := context.Background()
err := s.app.UpdateSession(ctx, sessionToUpdate.ID, newTitle)
if err != nil {
return toast.NewErrorToast("Failed to rename session: " + err.Error())()
}
s.sessions[idx].Title = newTitle
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
return toast.NewSuccessToast("Session renamed successfully")()
},
)
}
}
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
return s, nil
default:
var cmd tea.Cmd
s.renameInput, cmd = s.renameInput.Update(msg)
return s, cmd
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
} else {
switch msg.String() {
case "enter":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
selectedSession := s.sessions[idx]
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
)
}
case "n":
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionSelectedMsg(&selectedSession)),
util.CmdHandler(app.SessionClearedMsg{}),
)
}
case "n":
return s, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(app.SessionClearedMsg{}),
)
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
case "r":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
s.renameMode = true
s.renameIndex = idx
s.setupRenameInput(s.sessions[idx].Title)
s.modal.SetTitle("Rename Session")
s.updateListItems()
return s, textinput.Blink
}
case "x", "delete", "backspace":
if _, idx := s.list.GetSelectedItem(); idx >= 0 && idx < len(s.sessions) {
if s.deleteConfirmation == idx {
// Second press - actually delete the session
sessionToDelete := s.sessions[idx]
return s, tea.Sequence(
func() tea.Msg {
s.sessions = slices.Delete(s.sessions, idx, idx+1)
s.deleteConfirmation = -1
s.updateListItems()
return nil
},
s.deleteSession(sessionToDelete.ID),
)
} else {
// First press - enter delete confirmation mode
s.deleteConfirmation = idx
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
}
case "esc":
if s.deleteConfirmation >= 0 {
s.deleteConfirmation = -1
s.updateListItems()
return s, nil
}
}
}
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[sessionItem])
return s, cmd
if !s.renameMode {
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[sessionItem])
return s, cmd
}
return s, nil
}
func (s *sessionDialog) Render(background string) string {
if s.renameMode {
// Show rename input instead of list
t := theme.CurrentTheme()
renameView := s.renameInput.View()
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)
content := strings.Join([]string{renameView, helpText}, "\n")
return s.modal.Render(content, background)
}
listView := s.list.View()
t := theme.CurrentTheme()
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
leftHelp := keyStyle("n") + mutedStyle(" new session")
leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename")
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
bgColor := t.BackgroundPanel()
@@ -203,6 +266,39 @@ func (s *sessionDialog) Render(background string) string {
return s.modal.Render(content, background)
}
func (s *sessionDialog) setupRenameInput(currentTitle string) {
t := theme.CurrentTheme()
bgColor := t.BackgroundPanel()
textColor := t.Text()
textMutedColor := t.TextMuted()
s.renameInput = textinput.New()
s.renameInput.SetValue(currentTitle)
s.renameInput.Focus()
s.renameInput.CharLimit = 100
s.renameInput.SetWidth(layout.Current.Container.Width - 20)
s.renameInput.Styles.Blurred.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Blurred.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Placeholder = styles.NewStyle().
Foreground(textMutedColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Text = styles.NewStyle().
Foreground(textColor).
Background(bgColor).
Lipgloss()
s.renameInput.Styles.Focused.Prompt = styles.NewStyle().
Background(bgColor).
Lipgloss()
}
func (s *sessionDialog) updateListItems() {
_, currentIdx := s.list.GetSelectedItem()
@@ -229,7 +325,22 @@ func (s *sessionDialog) deleteSession(sessionID string) tea.Cmd {
}
}
// ReopenSessionModalMsg is emitted when the session modal should be reopened
type ReopenSessionModalMsg struct{}
func (s *sessionDialog) Close() tea.Cmd {
if s.renameMode {
// If in rename mode, exit rename mode and return a command to reopen the modal
s.renameMode = false
s.modal.SetTitle("Switch Session")
s.updateListItems()
// Return a command that will reopen the session modal
return func() tea.Msg {
return ReopenSessionModalMsg{}
}
}
// Normal close behavior
return nil
}
@@ -272,6 +383,8 @@ func NewSessionDialog(app *app.App) SessionDialog {
list: listComponent,
app: app,
deleteConfirmation: -1,
renameMode: false,
renameIndex: -1,
modal: modal.New(
modal.WithTitle("Switch Session"),
modal.WithMaxWidth(layout.Current.Container.Width-8),
+17 -12
View File
@@ -173,7 +173,13 @@ func (c *listComponent[T]) moveUp() {
}
}
// If no selectable item found above, stay at current position
// If no selectable item found above, wrap to the bottom
for i := len(c.items) - 1; i > c.selectedIdx; i-- {
if c.isSelectable(c.items[i]) {
c.selectedIdx = i
return
}
}
}
// moveDown moves the selection down, skipping non-selectable items
@@ -183,20 +189,19 @@ func (c *listComponent[T]) moveDown() {
}
originalIdx := c.selectedIdx
for {
if c.selectedIdx < len(c.items)-1 {
c.selectedIdx++
} else {
break
}
if c.isSelectable(c.items[c.selectedIdx]) {
// First try moving down from current position
for i := c.selectedIdx + 1; i < len(c.items); i++ {
if c.isSelectable(c.items[i]) {
c.selectedIdx = i
return
}
}
// Prevent infinite loop
if c.selectedIdx == originalIdx {
break
// If no selectable item found below, wrap to the top
for i := 0; i < originalIdx; i++ {
if c.isSelectable(c.items[i]) {
c.selectedIdx = i
return
}
}
}
@@ -138,15 +138,18 @@ func TestCtrlNavigation(t *testing.T) {
func TestNavigationBoundaries(t *testing.T) {
list := createTestList()
// Test up arrow at first item (should stay at 0)
// Test up arrow at first item (should wrap to last item)
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
updatedModel, _ := list.Update(upKey)
list = updatedModel.(*listComponent[testItem])
_, idx := list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected to stay at index 0 when pressing up at first item, got %d", idx)
if idx != 2 {
t.Errorf("Expected to wrap to index 2 when pressing up at first item, got %d", idx)
}
// Move to first item
list.SetSelectedIndex(0)
// Move to last item
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
updatedModel, _ = list.Update(downKey)
@@ -158,12 +161,12 @@ func TestNavigationBoundaries(t *testing.T) {
t.Errorf("Expected to be at index 2, got %d", idx)
}
// Test down arrow at last item (should stay at 2)
// Test down arrow at last item (should wrap to first item)
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 2 {
t.Errorf("Expected to stay at index 2 when pressing down at last item, got %d", idx)
if idx != 0 {
t.Errorf("Expected to wrap to index 0 when pressing down at last item, got %d", idx)
}
}
@@ -208,3 +211,39 @@ func TestEmptyList(t *testing.T) {
t.Error("Expected IsEmpty() to return true for empty list")
}
}
func TestWrapAroundNavigation(t *testing.T) {
list := createTestList()
// Start at first item (index 0)
_, idx := list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected to start at index 0, got %d", idx)
}
// Press up arrow - should wrap to last item (index 2)
upKey := tea.KeyPressMsg{Code: tea.KeyUp}
updatedModel, _ := list.Update(upKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 2 {
t.Errorf("Expected to wrap to index 2 when pressing up from first item, got %d", idx)
}
// Press down arrow - should wrap to first item (index 0)
downKey := tea.KeyPressMsg{Code: tea.KeyDown}
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 0 {
t.Errorf("Expected to wrap to index 0 when pressing down from last item, got %d", idx)
}
// Navigate to middle and verify normal navigation still works
updatedModel, _ = list.Update(downKey)
list = updatedModel.(*listComponent[testItem])
_, idx = list.GetSelectedItem()
if idx != 1 {
t.Errorf("Expected to move to index 1, got %d", idx)
}
}
@@ -121,30 +121,14 @@ func (m *statusComponent) View() string {
var modeBackground compat.AdaptiveColor
var modeForeground compat.AdaptiveColor
switch m.app.AgentIndex {
case 0:
agentColor := util.GetAgentColor(m.app.AgentIndex)
if m.app.AgentIndex == 0 {
modeBackground = t.BackgroundElement()
modeForeground = t.TextMuted()
case 1:
modeBackground = t.Secondary()
modeForeground = t.BackgroundPanel()
case 2:
modeBackground = t.Accent()
modeForeground = t.BackgroundPanel()
case 3:
modeBackground = t.Success()
modeForeground = t.BackgroundPanel()
case 4:
modeBackground = t.Warning()
modeForeground = t.BackgroundPanel()
case 5:
modeBackground = t.Primary()
modeForeground = t.BackgroundPanel()
case 6:
modeBackground = t.Error()
modeForeground = t.BackgroundPanel()
default:
modeBackground = t.Secondary()
modeForeground = agentColor
} else {
modeBackground = agentColor
modeForeground = t.BackgroundPanel()
}
@@ -2045,6 +2045,109 @@ func itemWidth(item any) int {
return 0
}
// forceWrapAttachment splits an attachment's display text across multiple lines
func forceWrapAttachment(att *attachment.Attachment, width int) [][]any {
if width <= 0 {
return [][]any{{att}}
}
display := att.Display
displayRunes := []rune(display)
if len(displayRunes) <= width {
return [][]any{{att}}
}
var lines [][]any
start := 0
for start < len(displayRunes) {
// Calculate how many runes fit in this line
end := start + width
if end > len(displayRunes) {
end = len(displayRunes)
}
// Create a wrapped attachment for this segment
wrappedAtt := &attachment.Attachment{
ID: att.ID,
Type: att.Type,
Display: string(displayRunes[start:end]),
URL: att.URL,
Filename: att.Filename,
MediaType: att.MediaType,
Source: att.Source,
}
lines = append(lines, []any{wrappedAtt})
start = end
}
return lines
}
// forceWrapWord splits a word that's too long to fit within the given width
func forceWrapWord(word []any, width int) [][]any {
if width <= 0 || len(word) == 0 {
return [][]any{word}
}
var lines [][]any
currentLine := []any{}
currentWidth := 0
for _, item := range word {
if att, ok := item.(*attachment.Attachment); ok {
// Handle attachment that might be too wide
attWidth := uniseg.StringWidth(att.Display)
// If the attachment display is too wide, split it
if attWidth > width {
// Finish current line if it has content
if len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
// Split the attachment display across multiple lines
wrappedAttachment := forceWrapAttachment(att, width)
lines = append(lines, wrappedAttachment...)
continue
}
// If adding this attachment would exceed the width, start a new line
if currentWidth+attWidth > width && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
currentLine = append(currentLine, item)
currentWidth += attWidth
} else if r, ok := item.(rune); ok {
itemWidth := rw.RuneWidth(r)
// If adding this rune would exceed the width, start a new line
if currentWidth+itemWidth > width && len(currentLine) > 0 {
lines = append(lines, currentLine)
currentLine = []any{}
currentWidth = 0
}
currentLine = append(currentLine, item)
currentWidth += itemWidth
}
}
// Add the last line if it has content
if len(currentLine) > 0 {
lines = append(lines, currentLine)
}
return lines
}
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}
@@ -2076,11 +2179,49 @@ func wrapInterfaces(content []any, width int) [][]any {
if !inSpaces {
// End of a word
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
// If the word itself is too long to fit on a line, force-wrap it
if wordW > width {
wrappedLines := forceWrapWord(word, width)
lines = append(lines, wrappedLines...)
// Calculate width of the last wrapped line
lastLine := wrappedLines[len(wrappedLines)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
} else {
lines = append(lines, word)
lineW = wordW
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
// Check if the word needs to be force-wrapped even when it fits on the current line
if wordW > width {
currentLine := lines[len(lines)-1]
wrappedWord := forceWrapWord(word, width-lineW)
if len(wrappedWord) > 0 {
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
for i := 1; i < len(wrappedWord); i++ {
lines = append(lines, wrappedWord[i])
}
// Calculate width of the last wrapped line
lastLine := wrappedWord[len(wrappedWord)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
word = nil
wordW = 0
@@ -2110,11 +2251,49 @@ func wrapInterfaces(content []any, width int) [][]any {
// Handle any remaining word/spaces at the end of the content.
if wordW > 0 {
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
// If the word itself is too long to fit on a line, force-wrap it
if wordW > width {
wrappedLines := forceWrapWord(word, width)
lines = append(lines, wrappedLines...)
// Calculate width of the last wrapped line
lastLine := wrappedLines[len(wrappedLines)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
} else {
lines = append(lines, word)
lineW = wordW
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
// Check if the word needs to be force-wrapped even when it fits on the current line
if wordW > width {
currentLine := lines[len(lines)-1]
wrappedWord := forceWrapWord(word, width-lineW)
if len(wrappedWord) > 0 {
lines[len(lines)-1] = append(currentLine, wrappedWord[0]...)
for i := 1; i < len(wrappedWord); i++ {
lines = append(lines, wrappedWord[i])
}
// Calculate width of the last wrapped line
lastLine := wrappedWord[len(wrappedWord)-1]
lineW = 0
for _, item := range lastLine {
if r, ok := item.(rune); ok {
lineW += rw.RuneWidth(r)
} else if att, ok := item.(*attachment.Attachment); ok {
lineW += uniseg.StringWidth(att.Display)
}
}
}
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
}
if spaceW > 0 {
+64 -13
View File
@@ -59,6 +59,8 @@ const interruptDebounceTimeout = 1 * time.Second
const exitDebounceTimeout = 1 * time.Second
type Model struct {
tea.Model
tea.CursorModel
width, height int
app *app.App
modal layout.Modal
@@ -355,6 +357,11 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.modal = nil
return a, cmd
case dialog.ReopenSessionModalMsg:
// Reopen the session modal (used when exiting rename mode)
sessionDialog := dialog.NewSessionDialog(a.app)
a.modal = sessionDialog
return a, nil
case commands.ExecuteCommandMsg:
updated, cmd := a.executeCommand(commands.Command(msg))
return updated, cmd
@@ -420,6 +427,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch casted := p.(type) {
case opencode.TextPart:
return casted.ID == msg.Properties.Part.ID
case opencode.ReasoningPart:
return casted.ID == msg.Properties.Part.ID
case opencode.FilePart:
return casted.ID == msg.Properties.Part.ID
case opencode.ToolPart:
@@ -458,6 +467,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch casted := p.(type) {
case opencode.TextPart:
return casted.ID == msg.Properties.PartID
case opencode.ReasoningPart:
return casted.ID == msg.Properties.PartID
case opencode.FilePart:
return casted.ID == msg.Properties.PartID
case opencode.ToolPart:
@@ -592,6 +603,10 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
cmds = append(cmds, a.app.SaveState())
case app.AgentSelectedMsg:
updated, cmd := a.app.SwitchToAgent(msg.AgentName)
a.app = updated
cmds = append(cmds, cmd)
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
cmds = append(cmds, a.app.SaveState())
@@ -718,15 +733,17 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Batch(cmds...)
}
func (a Model) View() string {
func (a Model) View() (string, *tea.Cursor) {
t := theme.CurrentTheme()
var mainLayout string
var editorX int
var editorY int
if a.app.Session.ID == "" {
mainLayout = a.home()
mainLayout, editorX, editorY = a.home()
} else {
mainLayout = a.chat()
mainLayout, editorX, editorY = a.chat()
}
mainLayout = styles.NewStyle().
Background(t.Background()).
@@ -750,7 +767,12 @@ func (a Model) View() string {
if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
}
return mainLayout + "\n" + a.status.View()
cursor := a.editor.Cursor()
cursor.Position.X += editorX
cursor.Position.Y += editorY
return mainLayout + "\n" + a.status.View(), cursor
}
func (a Model) Cleanup() {
@@ -777,7 +799,7 @@ func (a Model) openFile(filepath string) (tea.Model, tea.Cmd) {
return a, cmd
}
func (a Model) home() string {
func (a Model) home() (string, int, int) {
t := theme.CurrentTheme()
effectiveWidth := a.width - 4
baseStyle := styles.NewStyle().Background(t.Background())
@@ -869,14 +891,21 @@ func (a Model) home() string {
styles.WhitespaceStyle(t.Background()),
)
editorX := (effectiveWidth - editorWidth) / 2
editorX := max(0, (effectiveWidth-editorWidth)/2)
editorY := (a.height / 2) + (mainHeight / 2) - 2
if editorLines > 1 {
content := a.editor.Content()
editorHeight := lipgloss.Height(content)
if editorY+editorHeight > a.height {
difference := (editorY + editorHeight) - a.height
editorY -= difference
}
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
content,
mainLayout,
)
}
@@ -894,10 +923,10 @@ func (a Model) home() string {
)
}
return mainLayout
return mainLayout, editorX + 5, editorY + 2
}
func (a Model) chat() string {
func (a Model) chat() (string, int, int) {
effectiveWidth := a.width - 4
t := theme.CurrentTheme()
editorView := a.editor.View()
@@ -914,14 +943,20 @@ func (a Model) chat() string {
)
mainLayout := messagesView + "\n" + editorView
editorX := (effectiveWidth - editorWidth) / 2
editorX := max(0, (effectiveWidth-editorWidth)/2)
editorY := a.height - editorHeight
if lines > 1 {
editorY := a.height - editorHeight
content := a.editor.Content()
editorHeight := lipgloss.Height(content)
if editorY+editorHeight > a.height {
difference := (editorY + editorHeight) - a.height
editorY -= difference
}
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
content,
mainLayout,
)
}
@@ -940,7 +975,7 @@ func (a Model) chat() string {
)
}
return mainLayout
return mainLayout, editorX + 5, editorY + 2
}
func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
@@ -1109,9 +1144,25 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
}
cmds = append(cmds, util.CmdHandler(chat.ToggleToolDetailsMsg{}))
cmds = append(cmds, toast.NewInfoToast(message))
case commands.ThinkingBlocksCommand:
message := "Thinking blocks are now visible"
if a.messages.ThinkingBlocksVisible() {
message = "Thinking blocks are now hidden"
}
cmds = append(cmds, util.CmdHandler(chat.ToggleThinkingBlocksMsg{}))
cmds = append(cmds, toast.NewInfoToast(message))
case commands.ModelListCommand:
modelDialog := dialog.NewModelDialog(a.app)
a.modal = modelDialog
case commands.AgentListCommand:
agentDialog := dialog.NewAgentDialog(a.app)
a.modal = agentDialog
case commands.ModelCycleRecentCommand:
slog.Debug("ModelCycleRecentCommand triggered")
updated, cmd := a.app.CycleRecentModel()
a.app = updated
cmds = append(cmds, cmd)
case commands.ThemeListCommand:
themeDialog := dialog.NewThemeDialog()
a.modal = themeDialog
+23 -1
View File
@@ -3,6 +3,9 @@ package util
import (
"regexp"
"strings"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/theme"
)
var csiRE *regexp.Regexp
@@ -89,5 +92,24 @@ func ConvertRGBToAnsi16Colors(s string) string {
// func looksLikeByte(tok string) bool {
// v, err := strconv.Atoi(tok)
// return err == nil && v >= 0 && v <= 255
// return err == nil && v >= 0 && v <= 255
// }
// GetAgentColor returns the color for a given agent index, matching the status bar colors
func GetAgentColor(agentIndex int) compat.AdaptiveColor {
t := theme.CurrentTheme()
agentColors := []compat.AdaptiveColor{
t.TextMuted(),
t.Secondary(),
t.Accent(),
t.Success(),
t.Warning(),
t.Primary(),
t.Error(),
}
if agentIndex >= 0 && agentIndex < len(agentColors) {
return agentColors[agentIndex]
}
return t.Secondary() // default fallback
}
+4
View File
@@ -3,6 +3,7 @@ package util
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"unicode"
@@ -85,6 +86,8 @@ 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(`-([^ ]|$)`)
content = hyphenRegex.ReplaceAllString(content, "\u2011$1")
rendered, _ := r.Render(content)
lines := strings.Split(rendered, "\n")
@@ -105,5 +108,6 @@ func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor)
}
}
content = strings.Join(lines, "\n")
content = strings.ReplaceAll(content, "\u2011", "-")
return strings.TrimSuffix(content, "\n")
}
+4 -3
View File
@@ -6,11 +6,11 @@ import (
)
var SUPPORTED_IDES = []struct {
Search string
Search string
ShortName string
}{
{"Windsurf", "Windsurf"},
{"Visual Studio Code", "VS Code"},
{"Visual Studio Code", "vscode"},
{"Cursor", "Cursor"},
{"VSCodium", "VSCodium"},
}
@@ -27,4 +27,5 @@ func Ide() string {
}
return "unknown"
}
}