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
@@ -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 {