Merge branch 'dev' into project
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user