Merge branch 'dev' into project
This commit is contained in:
@@ -39,6 +39,7 @@ type EditorComponent interface {
|
||||
Focus() (tea.Model, tea.Cmd)
|
||||
Blur()
|
||||
Submit() (tea.Model, tea.Cmd)
|
||||
SubmitBash() (tea.Model, tea.Cmd)
|
||||
Clear() (tea.Model, tea.Cmd)
|
||||
Paste() (tea.Model, tea.Cmd)
|
||||
Newline() (tea.Model, tea.Cmd)
|
||||
@@ -223,10 +224,17 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case dialog.CompletionSelectedMsg:
|
||||
switch msg.Item.ProviderID {
|
||||
case "commands":
|
||||
commandName := strings.TrimPrefix(msg.Item.Value, "/")
|
||||
command := msg.Item.RawData.(commands.Command)
|
||||
if command.Custom {
|
||||
m.SetValue("/" + command.PrimaryTrigger() + " ")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
commandName := strings.TrimPrefix(msg.Item.Value, "/")
|
||||
cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
|
||||
return m, tea.Batch(cmds...)
|
||||
case "files":
|
||||
@@ -338,10 +346,19 @@ func (m *editorComponent) Content() string {
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
|
||||
promptStyle := styles.NewStyle().Foreground(t.Primary()).
|
||||
Padding(0, 0, 0, 1).
|
||||
Bold(true)
|
||||
prompt := promptStyle.Render(">")
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
if m.app.IsBashMode {
|
||||
borderForeground = t.Secondary()
|
||||
prompt = promptStyle.Render("!")
|
||||
}
|
||||
|
||||
m.textarea.SetWidth(width - 6)
|
||||
textarea := lipgloss.JoinHorizontal(
|
||||
@@ -349,10 +366,6 @@ func (m *editorComponent) Content() string {
|
||||
prompt,
|
||||
m.textarea.View(),
|
||||
)
|
||||
borderForeground := t.Border()
|
||||
if m.app.IsLeaderSequence {
|
||||
borderForeground = t.Accent()
|
||||
}
|
||||
textarea = styles.NewStyle().
|
||||
Background(t.BackgroundElement()).
|
||||
Width(width).
|
||||
@@ -475,6 +488,25 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
var cmds []tea.Cmd
|
||||
if strings.HasPrefix(value, "/") {
|
||||
value = value[1:]
|
||||
commandName := strings.Split(value, " ")[0]
|
||||
command := m.app.Commands[commands.CommandName(commandName)]
|
||||
if command.Custom {
|
||||
args := strings.TrimPrefix(value, command.PrimaryTrigger()+" ")
|
||||
cmds = append(
|
||||
cmds,
|
||||
util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),
|
||||
)
|
||||
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
|
||||
attachments := m.textarea.GetAttachments()
|
||||
|
||||
prompt := app.Prompt{Text: value, Attachments: attachments}
|
||||
@@ -489,6 +521,16 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) {
|
||||
command := m.textarea.Value()
|
||||
var cmds []tea.Cmd
|
||||
updated, cmd := m.Clear()
|
||||
m = updated.(*editorComponent)
|
||||
cmds = append(cmds, cmd)
|
||||
cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command}))
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
|
||||
m.textarea.Reset()
|
||||
m.historyIndex = -1
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
@@ -185,6 +186,8 @@ func renderContentBlock(
|
||||
if renderer.borderRight {
|
||||
style = style.BorderRightForeground(borderColor)
|
||||
}
|
||||
} else {
|
||||
style = style.PaddingLeft(renderer.paddingLeft + 1).PaddingRight(renderer.paddingRight + 1)
|
||||
}
|
||||
|
||||
content = style.Render(content)
|
||||
@@ -211,6 +214,8 @@ func renderText(
|
||||
width int,
|
||||
extra string,
|
||||
isThinking bool,
|
||||
isQueued bool,
|
||||
shimmer bool,
|
||||
fileParts []opencode.FilePart,
|
||||
agentParts []opencode.AgentPart,
|
||||
toolCalls ...opencode.ToolPart,
|
||||
@@ -232,7 +237,18 @@ func renderText(
|
||||
}
|
||||
content = util.ToMarkdown(text, width, backgroundColor)
|
||||
if isThinking {
|
||||
content = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking") + "\n\n" + content
|
||||
var label string
|
||||
if shimmer {
|
||||
label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent())
|
||||
} else {
|
||||
label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...")
|
||||
}
|
||||
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
|
||||
content = label + "\n\n" + content
|
||||
} else if strings.TrimSpace(text) == "Generating..." {
|
||||
label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text())
|
||||
label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label)
|
||||
content = label
|
||||
}
|
||||
case opencode.UserMessage:
|
||||
ts = time.UnixMilli(int64(casted.Time.Created))
|
||||
@@ -331,6 +347,10 @@ func renderText(
|
||||
wrappedText := ansi.WordwrapWc(styledText, width-6, " ")
|
||||
wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-")
|
||||
content = base.Width(width - 6).Render(wrappedText)
|
||||
if isQueued {
|
||||
queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1)
|
||||
content = queuedStyle.Render("QUEUED") + "\n\n" + content
|
||||
}
|
||||
}
|
||||
|
||||
timestamp := ts.
|
||||
@@ -400,12 +420,16 @@ func renderText(
|
||||
|
||||
switch message.(type) {
|
||||
case opencode.UserMessage:
|
||||
borderColor := t.Secondary()
|
||||
if isQueued {
|
||||
borderColor = t.Accent()
|
||||
}
|
||||
return renderContentBlock(
|
||||
app,
|
||||
content,
|
||||
width,
|
||||
WithTextColor(t.Text()),
|
||||
WithBorderColor(t.Secondary()),
|
||||
WithBorderColor(borderColor),
|
||||
)
|
||||
case opencode.AssistantMessage:
|
||||
if isThinking {
|
||||
@@ -470,6 +494,8 @@ func renderToolDetails(
|
||||
backgroundColor := t.BackgroundPanel()
|
||||
borderColor := t.BackgroundPanel()
|
||||
defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render
|
||||
baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render
|
||||
mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render
|
||||
|
||||
permissionContent := ""
|
||||
if permission.ID != "" {
|
||||
@@ -554,6 +580,17 @@ func renderToolDetails(
|
||||
title := renderToolTitle(toolCall, width)
|
||||
title = style.Render(title)
|
||||
content := title + "\n" + body
|
||||
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusError {
|
||||
errorStyle := styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
Foreground(t.Error()).
|
||||
Padding(1, 2).
|
||||
Width(width - 4)
|
||||
errorContent := errorStyle.Render(toolCall.State.Error)
|
||||
content += "\n" + errorContent
|
||||
}
|
||||
|
||||
if permissionContent != "" {
|
||||
permissionContent = styles.NewStyle().
|
||||
Background(backgroundColor).
|
||||
@@ -582,14 +619,15 @@ func renderToolDetails(
|
||||
}
|
||||
}
|
||||
case "bash":
|
||||
command := toolInputMap["command"].(string)
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
output := metadata["output"]
|
||||
if output != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", output))
|
||||
if command, ok := toolInputMap["command"].(string); ok {
|
||||
body = fmt.Sprintf("```console\n$ %s\n", command)
|
||||
output := metadata["output"]
|
||||
if output != nil {
|
||||
body += ansi.Strip(fmt.Sprintf("%s", output))
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
}
|
||||
body += "```"
|
||||
body = util.ToMarkdown(body, width, backgroundColor)
|
||||
case "webfetch":
|
||||
if format, ok := toolInputMap["format"].(string); ok && result != nil {
|
||||
body = *result
|
||||
@@ -633,6 +671,24 @@ func renderToolDetails(
|
||||
steps = append(steps, step)
|
||||
}
|
||||
body = strings.Join(steps, "\n")
|
||||
|
||||
body += "\n\n"
|
||||
|
||||
// Build navigation hint with proper spacing
|
||||
cycleKeybind := app.Keybind(commands.SessionChildCycleCommand)
|
||||
cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand)
|
||||
|
||||
var navParts []string
|
||||
if cycleKeybind != "" {
|
||||
navParts = append(navParts, baseStyle(cycleKeybind))
|
||||
}
|
||||
if cycleReverseKeybind != "" {
|
||||
navParts = append(navParts, baseStyle(cycleReverseKeybind))
|
||||
}
|
||||
|
||||
if len(navParts) > 0 {
|
||||
body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions")
|
||||
}
|
||||
}
|
||||
body = defaultStyle(body)
|
||||
default:
|
||||
@@ -652,11 +708,17 @@ func renderToolDetails(
|
||||
}
|
||||
|
||||
if error != "" {
|
||||
body = styles.NewStyle().
|
||||
errorContent := styles.NewStyle().
|
||||
Width(width - 6).
|
||||
Foreground(t.Error()).
|
||||
Background(backgroundColor).
|
||||
Render(error)
|
||||
|
||||
if body == "" {
|
||||
body = errorContent
|
||||
} else {
|
||||
body += "\n\n" + errorContent
|
||||
}
|
||||
}
|
||||
|
||||
if body == "" && error == "" && result != nil {
|
||||
@@ -687,6 +749,8 @@ func renderToolDetails(
|
||||
|
||||
func renderToolName(name string) string {
|
||||
switch name {
|
||||
case "bash":
|
||||
return "Shell"
|
||||
case "webfetch":
|
||||
return "Fetch"
|
||||
case "invalid":
|
||||
@@ -741,7 +805,9 @@ func renderToolTitle(
|
||||
) string {
|
||||
if toolCall.State.Status == opencode.ToolPartStateStatusPending {
|
||||
title := renderToolAction(toolCall.Tool)
|
||||
return styles.NewStyle().Width(width - 6).Render(title)
|
||||
t := theme.CurrentTheme()
|
||||
shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent())
|
||||
return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny)
|
||||
}
|
||||
|
||||
toolArgs := ""
|
||||
@@ -857,7 +923,9 @@ func renderArgs(args *map[string]any, titleKey string) string {
|
||||
continue
|
||||
}
|
||||
if key == "filePath" || key == "path" {
|
||||
value = util.Relative(value.(string))
|
||||
if strValue, ok := value.(string); ok {
|
||||
value = util.Relative(strValue)
|
||||
}
|
||||
}
|
||||
if key == titleKey {
|
||||
title = fmt.Sprintf("%s", value)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
@@ -39,6 +40,7 @@ type MessagesComponent interface {
|
||||
CopyLastMessage() (tea.Model, tea.Cmd)
|
||||
UndoLastMessage() (tea.Model, tea.Cmd)
|
||||
RedoLastMessage() (tea.Model, tea.Cmd)
|
||||
ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
|
||||
}
|
||||
|
||||
type messagesComponent struct {
|
||||
@@ -57,6 +59,8 @@ type messagesComponent struct {
|
||||
partCount int
|
||||
lineCount int
|
||||
selection *selection
|
||||
messagePositions map[string]int // map message ID to line position
|
||||
animating bool
|
||||
}
|
||||
|
||||
type selection struct {
|
||||
@@ -97,6 +101,7 @@ func (s selection) coords(offset int) *selection {
|
||||
|
||||
type ToggleToolDetailsMsg struct{}
|
||||
type ToggleThinkingBlocksMsg struct{}
|
||||
type shimmerTickMsg struct{}
|
||||
|
||||
func (m *messagesComponent) Init() tea.Cmd {
|
||||
return tea.Batch(m.viewport.Init())
|
||||
@@ -105,6 +110,15 @@ func (m *messagesComponent) Init() tea.Cmd {
|
||||
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case shimmerTickMsg:
|
||||
if !m.app.HasAnimatingWork() {
|
||||
m.animating = false
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Sequence(
|
||||
m.renderView(),
|
||||
tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
|
||||
)
|
||||
case tea.MouseClickMsg:
|
||||
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
|
||||
y := msg.Y + m.viewport.YOffset
|
||||
@@ -132,15 +146,18 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case tea.MouseReleaseMsg:
|
||||
if m.selection != nil && len(m.clipboard) > 0 {
|
||||
content := strings.Join(m.clipboard, "\n")
|
||||
if m.selection != nil {
|
||||
m.selection = nil
|
||||
m.clipboard = []string{}
|
||||
return m, tea.Sequence(
|
||||
m.renderView(),
|
||||
app.SetClipboard(content),
|
||||
toast.NewSuccessToast("Copied to clipboard"),
|
||||
)
|
||||
if len(m.clipboard) > 0 {
|
||||
content := strings.Join(m.clipboard, "\n")
|
||||
m.clipboard = []string{}
|
||||
return m, tea.Sequence(
|
||||
m.renderView(),
|
||||
app.SetClipboard(content),
|
||||
toast.NewSuccessToast("Copied to clipboard"),
|
||||
)
|
||||
}
|
||||
return m, m.renderView()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
effectiveWidth := msg.Width - 4
|
||||
@@ -157,6 +174,10 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
case app.SendCommand:
|
||||
m.viewport.GotoBottom()
|
||||
m.tail = true
|
||||
return m, nil
|
||||
case dialog.ThemeSelectedMsg:
|
||||
m.cache.Clear()
|
||||
m.loading = true
|
||||
@@ -169,7 +190,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.showThinkingBlocks = !m.showThinkingBlocks
|
||||
m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
|
||||
return m, tea.Batch(m.renderView(), m.app.SaveState())
|
||||
case app.SessionLoadedMsg, app.SessionClearedMsg:
|
||||
case app.SessionLoadedMsg:
|
||||
m.tail = true
|
||||
m.loading = true
|
||||
return m, m.renderView()
|
||||
case app.SessionClearedMsg:
|
||||
m.cache.Clear()
|
||||
m.tail = true
|
||||
m.loading = true
|
||||
@@ -180,6 +205,23 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.tail = true
|
||||
return m, m.renderView()
|
||||
}
|
||||
case app.SessionSelectedMsg:
|
||||
currentParent := m.app.Session.ParentID
|
||||
if currentParent == "" {
|
||||
currentParent = m.app.Session.ID
|
||||
}
|
||||
|
||||
targetParent := msg.ParentID
|
||||
if targetParent == "" {
|
||||
targetParent = msg.ID
|
||||
}
|
||||
|
||||
// Clear cache only if switching between different session families
|
||||
if currentParent != targetParent {
|
||||
m.cache.Clear()
|
||||
}
|
||||
|
||||
m.viewport.GotoBottom()
|
||||
case app.MessageRevertedMsg:
|
||||
if msg.Session.ID == m.app.Session.ID {
|
||||
m.cache.Clear()
|
||||
@@ -203,6 +245,11 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.Properties.Part.SessionID == m.app.Session.ID {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventMessageRemoved:
|
||||
if msg.Properties.SessionID == m.app.Session.ID {
|
||||
m.cache.Clear()
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
case opencode.EventListResponseEventMessagePartRemoved:
|
||||
if msg.Properties.SessionID == m.app.Session.ID {
|
||||
// Clear the cache when a part is removed to ensure proper re-rendering
|
||||
@@ -221,6 +268,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.rendering = false
|
||||
m.clipboard = msg.clipboard
|
||||
m.loading = false
|
||||
m.messagePositions = msg.messagePositions
|
||||
m.tail = m.viewport.AtBottom()
|
||||
|
||||
// Preserve scroll across reflow
|
||||
@@ -238,6 +286,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.dirty {
|
||||
cmds = append(cmds, m.renderView())
|
||||
}
|
||||
|
||||
// Start shimmer ticks if any assistant/tool is in-flight
|
||||
if !m.animating && m.app.HasAnimatingWork() {
|
||||
m.animating = true
|
||||
cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
|
||||
}
|
||||
}
|
||||
|
||||
m.tail = m.viewport.AtBottom()
|
||||
@@ -249,11 +303,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
type renderCompleteMsg struct {
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
viewport viewport.Model
|
||||
clipboard []string
|
||||
header string
|
||||
partCount int
|
||||
lineCount int
|
||||
messagePositions map[string]int
|
||||
}
|
||||
|
||||
func (m *messagesComponent) renderView() tea.Cmd {
|
||||
@@ -279,11 +334,31 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
blocks := make([]string, 0)
|
||||
partCount := 0
|
||||
lineCount := 0
|
||||
messagePositions := make(map[string]int) // Track message ID to line position
|
||||
|
||||
orphanedToolCalls := make([]opencode.ToolPart, 0)
|
||||
|
||||
width := m.width // always use full width
|
||||
|
||||
// Find the last streaming ReasoningPart to only shimmer that one
|
||||
lastStreamingReasoningID := ""
|
||||
if m.showThinkingBlocks {
|
||||
for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
|
||||
if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
|
||||
continue
|
||||
}
|
||||
parts := m.app.Messages[mi].Parts
|
||||
for pi := len(parts) - 1; pi >= 0; pi-- {
|
||||
if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
|
||||
if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
|
||||
lastStreamingReasoningID = rp.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reverted := false
|
||||
revertedMessageCount := 0
|
||||
revertedToolCount := 0
|
||||
@@ -301,6 +376,9 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
|
||||
switch casted := message.Info.(type) {
|
||||
case opencode.UserMessage:
|
||||
// Track the position of this user message
|
||||
messagePositions[casted.ID] = lineCount
|
||||
|
||||
if casted.ID == m.app.Session.Revert.MessageID {
|
||||
reverted = true
|
||||
revertedMessageCount = 1
|
||||
@@ -368,10 +446,8 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
)
|
||||
|
||||
author := m.app.Config.Username
|
||||
if casted.ID > lastAssistantMessage {
|
||||
author += " [queued]"
|
||||
}
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
|
||||
isQueued := casted.ID > lastAssistantMessage
|
||||
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
|
||||
content, cached = m.cache.Get(key)
|
||||
if !cached {
|
||||
content = renderText(
|
||||
@@ -383,15 +459,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
files,
|
||||
false,
|
||||
isQueued,
|
||||
false,
|
||||
fileParts,
|
||||
agentParts,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
if content != "" {
|
||||
@@ -464,16 +536,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
toolCallParts...,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
@@ -486,16 +554,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
toolCallParts...,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
partCount++
|
||||
@@ -536,12 +600,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
permission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
m.cache.Set(key, content)
|
||||
}
|
||||
} else {
|
||||
@@ -552,12 +610,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
permission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
}
|
||||
if content != "" {
|
||||
partCount++
|
||||
@@ -574,6 +626,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
if part.Text != "" {
|
||||
text := part.Text
|
||||
shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
|
||||
content = renderText(
|
||||
m.app,
|
||||
message.Info,
|
||||
@@ -583,15 +636,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
shimmer,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
@@ -622,15 +671,11 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
"",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
[]opencode.FilePart{},
|
||||
[]opencode.AgentPart{},
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
@@ -645,12 +690,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
width,
|
||||
WithBorderColor(t.Error()),
|
||||
)
|
||||
error = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
error,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
blocks = append(blocks, error)
|
||||
lineCount += lipgloss.Height(error) + 1
|
||||
}
|
||||
@@ -736,22 +775,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
} else {
|
||||
for _, part := range response.Parts {
|
||||
if part.CallID == m.app.CurrentPermission.CallID {
|
||||
content := renderToolDetails(
|
||||
m.app,
|
||||
part.AsUnion().(opencode.ToolPart),
|
||||
m.app.CurrentPermission,
|
||||
width,
|
||||
)
|
||||
content = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
content,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
if content != "" {
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
|
||||
content := renderToolDetails(
|
||||
m.app,
|
||||
toolPart,
|
||||
m.app.CurrentPermission,
|
||||
width,
|
||||
)
|
||||
if content != "" {
|
||||
partCount++
|
||||
lineCount += lipgloss.Height(content) + 1
|
||||
blocks = append(blocks, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -811,11 +846,12 @@ func (m *messagesComponent) renderView() tea.Cmd {
|
||||
}
|
||||
|
||||
return renderCompleteMsg{
|
||||
header: header,
|
||||
clipboard: clipboard,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
header: header,
|
||||
clipboard: clipboard,
|
||||
viewport: viewport,
|
||||
partCount: partCount,
|
||||
lineCount: lineCount,
|
||||
messagePositions: messagePositions,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,8 +864,17 @@ func (m *messagesComponent) renderHeader() string {
|
||||
headerWidth := m.width
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
|
||||
bgColor := t.Background()
|
||||
borderColor := t.BackgroundElement()
|
||||
|
||||
isChildSession := m.app.Session.ParentID != ""
|
||||
if isChildSession {
|
||||
bgColor = t.BackgroundElement()
|
||||
borderColor = t.Accent()
|
||||
}
|
||||
|
||||
base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
|
||||
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
|
||||
|
||||
sessionInfo := ""
|
||||
tokens := float64(0)
|
||||
@@ -861,20 +906,44 @@ func (m *messagesComponent) renderHeader() string {
|
||||
sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
|
||||
sessionInfo = styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Render(sessionInfoText)
|
||||
|
||||
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
|
||||
|
||||
navHint := ""
|
||||
if isChildSession {
|
||||
navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
|
||||
}
|
||||
|
||||
headerTextWidth := headerWidth
|
||||
if !shareEnabled {
|
||||
// +1 is to ensure there is always at least one space between header and session info
|
||||
headerTextWidth -= len(sessionInfoText) + 1
|
||||
if isChildSession {
|
||||
headerTextWidth -= lipgloss.Width(navHint)
|
||||
} else if !shareEnabled {
|
||||
headerTextWidth -= lipgloss.Width(sessionInfoText)
|
||||
}
|
||||
headerText := util.ToMarkdown(
|
||||
"# "+m.app.Session.Title,
|
||||
headerTextWidth,
|
||||
t.Background(),
|
||||
bgColor,
|
||||
)
|
||||
if isChildSession {
|
||||
headerText = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: headerTextWidth,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: headerText,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: navHint,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var items []layout.FlexItem
|
||||
if shareEnabled {
|
||||
@@ -887,10 +956,9 @@ func (m *messagesComponent) renderHeader() string {
|
||||
items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
|
||||
}
|
||||
|
||||
background := t.Background()
|
||||
headerRow := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
@@ -906,22 +974,16 @@ func (m *messagesComponent) renderHeader() string {
|
||||
|
||||
header := strings.Join(headerLines, "\n")
|
||||
header = styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Width(headerWidth).
|
||||
PaddingLeft(2).
|
||||
PaddingRight(2).
|
||||
BorderLeft(true).
|
||||
BorderRight(true).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.BackgroundElement()).
|
||||
BorderForeground(borderColor).
|
||||
BorderStyle(lipgloss.ThickBorder()).
|
||||
Render(header)
|
||||
header = lipgloss.PlaceHorizontal(
|
||||
m.width,
|
||||
lipgloss.Center,
|
||||
header,
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
)
|
||||
|
||||
return "\n" + header + "\n"
|
||||
}
|
||||
@@ -966,7 +1028,7 @@ func formatTokensAndCost(
|
||||
|
||||
formattedCost := fmt.Sprintf("$%.2f", cost)
|
||||
return fmt.Sprintf(
|
||||
"%s/%d%% (%s)",
|
||||
" %s/%d%% (%s)",
|
||||
formattedTokens,
|
||||
int(percentage),
|
||||
formattedCost,
|
||||
@@ -975,20 +1037,22 @@ func formatTokensAndCost(
|
||||
|
||||
func (m *messagesComponent) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
bgColor := t.Background()
|
||||
|
||||
if m.loading {
|
||||
return lipgloss.Place(
|
||||
m.width,
|
||||
m.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
styles.NewStyle().Background(t.Background()).Render(""),
|
||||
styles.WhitespaceStyle(t.Background()),
|
||||
styles.NewStyle().Background(bgColor).Render(""),
|
||||
styles.WhitespaceStyle(bgColor),
|
||||
)
|
||||
}
|
||||
|
||||
viewport := m.viewport.View()
|
||||
return styles.NewStyle().
|
||||
Background(t.Background()).
|
||||
Background(bgColor).
|
||||
Render(m.header + "\n" + viewport)
|
||||
}
|
||||
|
||||
@@ -1206,14 +1270,26 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
|
||||
if m.messagePositions == nil {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if position, exists := m.messagePositions[messageID]; exists {
|
||||
m.viewport.SetYOffset(position)
|
||||
m.tail = false // Stop auto-scrolling to bottom when manually navigating
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
vp := viewport.New()
|
||||
vp.KeyMap = viewport.KeyMap{}
|
||||
|
||||
if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 {
|
||||
vp.MouseWheelDelta = *app.State.ScrollSpeed
|
||||
if app.ScrollSpeed > 0 {
|
||||
vp.MouseWheelDelta = app.ScrollSpeed
|
||||
} else {
|
||||
vp.MouseWheelDelta = 4
|
||||
vp.MouseWheelDelta = 2
|
||||
}
|
||||
|
||||
// Default to showing tool details, hidden thinking blocks
|
||||
@@ -1234,5 +1310,6 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
|
||||
showThinkingBlocks: showThinkingBlocks,
|
||||
cache: NewPartCache(),
|
||||
tail: true,
|
||||
messagePositions: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,20 +76,15 @@ func (a agentSelectItem) Render(
|
||||
|
||||
agentName := a.displayName
|
||||
|
||||
// For user agents and subagents, show description; for built-in, show mode
|
||||
// Determine if agent is built-in or custom using the agent's builtIn field
|
||||
var displayText string
|
||||
if a.description != "" && (a.mode == "all" || a.mode == "subagent") {
|
||||
// User agent or subagent with description
|
||||
displayText = a.description
|
||||
if a.agent.BuiltIn {
|
||||
displayText = "(built-in)"
|
||||
} else {
|
||||
// Built-in without description - show mode
|
||||
switch a.mode {
|
||||
case "primary":
|
||||
displayText = "(built-in)"
|
||||
case "all":
|
||||
if a.description != "" {
|
||||
displayText = a.description
|
||||
} else {
|
||||
displayText = "(user)"
|
||||
default:
|
||||
displayText = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,23 +201,19 @@ func (a *agentDialog) calculateOptimalWidth(agents []agentSelectItem) int {
|
||||
for _, agent := range agents {
|
||||
// Calculate the width needed for this item: "AgentName - Description" (visual improvement)
|
||||
itemWidth := len(agent.displayName)
|
||||
if agent.description != "" && (agent.mode == "all" || agent.mode == "subagent") {
|
||||
// User agent or subagent - use description (capped to maxDescriptionLength)
|
||||
descLength := len(agent.description)
|
||||
if descLength > maxDescriptionLength {
|
||||
descLength = maxDescriptionLength
|
||||
}
|
||||
itemWidth += descLength + 3 // " - "
|
||||
|
||||
if agent.agent.BuiltIn {
|
||||
itemWidth += len("(built-in)") + 3 // " - "
|
||||
} else {
|
||||
// Built-in without description - use mode
|
||||
var modeText string
|
||||
switch agent.mode {
|
||||
case "primary":
|
||||
modeText = "(built-in)"
|
||||
case "all":
|
||||
modeText = "(user)"
|
||||
if agent.description != "" {
|
||||
descLength := len(agent.description)
|
||||
if descLength > maxDescriptionLength {
|
||||
descLength = maxDescriptionLength
|
||||
}
|
||||
itemWidth += descLength + 3 // " - "
|
||||
} else {
|
||||
itemWidth += len("(user)") + 3 // " - "
|
||||
}
|
||||
itemWidth += len(modeText) + 3 // " - "
|
||||
}
|
||||
|
||||
if itemWidth > maxWidth {
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/sst/opencode/internal/completions"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
findDialogWidth = 76
|
||||
)
|
||||
|
||||
type FindSelectedMsg struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
type FindDialogCloseMsg struct{}
|
||||
|
||||
type findInitialSuggestionsMsg struct {
|
||||
suggestions []completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
type FindDialog interface {
|
||||
layout.Modal
|
||||
tea.Model
|
||||
tea.ViewModel
|
||||
SetWidth(width int)
|
||||
SetHeight(height int)
|
||||
IsEmpty() bool
|
||||
}
|
||||
|
||||
// findItem is a custom list item for file suggestions
|
||||
type findItem struct {
|
||||
suggestion completions.CompletionSuggestion
|
||||
}
|
||||
|
||||
func (f findItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
baseStyle styles.Style,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
itemStyle := baseStyle.
|
||||
Background(t.BackgroundPanel()).
|
||||
Foreground(t.TextMuted())
|
||||
|
||||
if selected {
|
||||
itemStyle = itemStyle.Foreground(t.Primary())
|
||||
}
|
||||
|
||||
return itemStyle.PaddingLeft(1).Render(f.suggestion.Display(itemStyle))
|
||||
}
|
||||
|
||||
func (f findItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type findDialogComponent struct {
|
||||
completionProvider completions.CompletionProvider
|
||||
allSuggestions []completions.CompletionSuggestion
|
||||
width, height int
|
||||
modal *modal.Modal
|
||||
searchDialog *SearchDialog
|
||||
dialogWidth int
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
f.loadInitialSuggestions(),
|
||||
f.searchDialog.Init(),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) loadInitialSuggestions() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries("")
|
||||
if err != nil {
|
||||
slog.Error("Failed to get initial completion items", "error", err)
|
||||
return findInitialSuggestionsMsg{suggestions: []completions.CompletionSuggestion{}}
|
||||
}
|
||||
return findInitialSuggestionsMsg{suggestions: items}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case findInitialSuggestionsMsg:
|
||||
// Handle initial suggestions setup
|
||||
f.allSuggestions = msg.suggestions
|
||||
|
||||
// Calculate dialog width
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
|
||||
// Initialize search dialog with calculated width
|
||||
f.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
|
||||
// Convert to list items
|
||||
items := make([]list.Item, len(f.allSuggestions))
|
||||
for i, suggestion := range f.allSuggestions {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
|
||||
// Update modal with calculated width
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
|
||||
return f, f.searchDialog.Init()
|
||||
|
||||
case []completions.CompletionSuggestion:
|
||||
// Store suggestions and convert to findItem for the search dialog
|
||||
f.allSuggestions = msg
|
||||
items := make([]list.Item, len(msg))
|
||||
for i, suggestion := range msg {
|
||||
items[i] = findItem{suggestion: suggestion}
|
||||
}
|
||||
f.searchDialog.SetItems(items)
|
||||
return f, nil
|
||||
|
||||
case SearchSelectionMsg:
|
||||
// Handle selection from search dialog - now we can directly access the suggestion
|
||||
if item, ok := msg.Item.(findItem); ok {
|
||||
return f, f.selectFile(item.suggestion)
|
||||
}
|
||||
return f, nil
|
||||
|
||||
case SearchCancelledMsg:
|
||||
return f, f.Close()
|
||||
|
||||
case SearchQueryChangedMsg:
|
||||
// Update completion items based on search query
|
||||
return f, func() tea.Msg {
|
||||
items, err := f.completionProvider.GetChildEntries(msg.Query)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get completion items", "error", err)
|
||||
return []completions.CompletionSuggestion{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
f.width = msg.Width
|
||||
f.height = msg.Height
|
||||
// Recalculate width based on new viewport size
|
||||
oldWidth := f.dialogWidth
|
||||
f.dialogWidth = f.calculateDialogWidth()
|
||||
if oldWidth != f.dialogWidth {
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
// Update modal max width too
|
||||
f.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(f.dialogWidth+4),
|
||||
)
|
||||
}
|
||||
f.searchDialog.SetHeight(msg.Height)
|
||||
}
|
||||
|
||||
// Forward all other messages to the search dialog
|
||||
updatedDialog, cmd := f.searchDialog.Update(msg)
|
||||
f.searchDialog = updatedDialog.(*SearchDialog)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) View() string {
|
||||
return f.searchDialog.View()
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) calculateDialogWidth() int {
|
||||
// Use fixed width unless viewport is smaller
|
||||
if f.width > 0 && f.width < findDialogWidth+10 {
|
||||
return f.width - 10
|
||||
}
|
||||
return findDialogWidth
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetWidth(width int) {
|
||||
f.width = width
|
||||
f.searchDialog.SetWidth(f.dialogWidth)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) SetHeight(height int) {
|
||||
f.height = height
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) IsEmpty() bool {
|
||||
return f.searchDialog.GetQuery() == ""
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) selectFile(item completions.CompletionSuggestion) tea.Cmd {
|
||||
return tea.Sequence(
|
||||
f.Close(),
|
||||
util.CmdHandler(FindSelectedMsg{
|
||||
FilePath: item.Value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Render(background string) string {
|
||||
return f.modal.Render(f.View(), background)
|
||||
}
|
||||
|
||||
func (f *findDialogComponent) Close() tea.Cmd {
|
||||
f.searchDialog.SetQuery("")
|
||||
f.searchDialog.Blur()
|
||||
return util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
|
||||
func NewFindDialog(completionProvider completions.CompletionProvider) FindDialog {
|
||||
component := &findDialogComponent{
|
||||
completionProvider: completionProvider,
|
||||
dialogWidth: findDialogWidth,
|
||||
allSuggestions: []completions.CompletionSuggestion{},
|
||||
}
|
||||
|
||||
// Create search dialog and modal with fixed width
|
||||
component.searchDialog = NewSearchDialog("Search files...", 10)
|
||||
component.searchDialog.SetWidth(findDialogWidth)
|
||||
|
||||
component.modal = modal.New(
|
||||
modal.WithTitle("Find Files"),
|
||||
modal.WithMaxWidth(findDialogWidth+4),
|
||||
)
|
||||
|
||||
return component
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/bubbles/v2/key"
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// InitDialogCmp is a component that asks the user if they want to initialize the project.
|
||||
type InitDialogCmp struct {
|
||||
width, height int
|
||||
selected int
|
||||
keys initDialogKeyMap
|
||||
}
|
||||
|
||||
// NewInitDialogCmp creates a new InitDialogCmp.
|
||||
func NewInitDialogCmp() InitDialogCmp {
|
||||
return InitDialogCmp{
|
||||
selected: 0,
|
||||
keys: initDialogKeyMap{},
|
||||
}
|
||||
}
|
||||
|
||||
type initDialogKeyMap struct {
|
||||
Tab key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Enter key.Binding
|
||||
Escape key.Binding
|
||||
Y key.Binding
|
||||
N key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp implements key.Map.
|
||||
func (k initDialogKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
key.NewBinding(
|
||||
key.WithKeys("tab", "left", "right"),
|
||||
key.WithHelp("tab/←/→", "toggle selection"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "confirm"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("esc", "q"),
|
||||
key.WithHelp("esc/q", "cancel"),
|
||||
),
|
||||
key.NewBinding(
|
||||
key.WithKeys("y", "n"),
|
||||
key.WithHelp("y/n", "yes/no"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// FullHelp implements key.Map.
|
||||
func (k initDialogKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{k.ShortHelp()}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m InitDialogCmp) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model.
|
||||
func (m InitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("tab", "left", "right", "h", "l"))):
|
||||
m.selected = (m.selected + 1) % 2
|
||||
return m, nil
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: m.selected == 0})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("y"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: true})
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("n"))):
|
||||
return m, util.CmdHandler(CloseInitDialogMsg{Initialize: false})
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model.
|
||||
func (m InitDialogCmp) View() string {
|
||||
t := theme.CurrentTheme()
|
||||
baseStyle := styles.NewStyle().Foreground(t.Text())
|
||||
|
||||
// Calculate width needed for content
|
||||
maxWidth := 60 // Width for explanation text
|
||||
|
||||
title := baseStyle.
|
||||
Foreground(t.Primary()).
|
||||
Bold(true).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("Initialize Project")
|
||||
|
||||
explanation := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(0, 1).
|
||||
Render("Initialization generates a new AGENTS.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
|
||||
|
||||
question := baseStyle.
|
||||
Foreground(t.Text()).
|
||||
Width(maxWidth).
|
||||
Padding(1, 1).
|
||||
Render("Would you like to initialize this project?")
|
||||
|
||||
maxWidth = min(maxWidth, m.width-10)
|
||||
yesStyle := baseStyle
|
||||
noStyle := baseStyle
|
||||
|
||||
if m.selected == 0 {
|
||||
yesStyle = yesStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Bold(true)
|
||||
noStyle = noStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary())
|
||||
} else {
|
||||
noStyle = noStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.Background()).
|
||||
Bold(true)
|
||||
yesStyle = yesStyle.
|
||||
Background(t.Background()).
|
||||
Foreground(t.Primary())
|
||||
}
|
||||
|
||||
yes := yesStyle.Padding(0, 3).Render("Yes")
|
||||
no := noStyle.Padding(0, 3).Render("No")
|
||||
|
||||
buttons := lipgloss.JoinHorizontal(lipgloss.Center, yes, baseStyle.Render(" "), no)
|
||||
buttons = baseStyle.
|
||||
Width(maxWidth).
|
||||
Padding(1, 0).
|
||||
Render(buttons)
|
||||
|
||||
content := lipgloss.JoinVertical(
|
||||
lipgloss.Left,
|
||||
title,
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
explanation,
|
||||
question,
|
||||
buttons,
|
||||
baseStyle.Width(maxWidth).Render(""),
|
||||
)
|
||||
|
||||
return baseStyle.Padding(1, 2).
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderBackground(t.Background()).
|
||||
BorderForeground(t.TextMuted()).
|
||||
Width(lipgloss.Width(content) + 4).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// SetSize sets the size of the component.
|
||||
func (m *InitDialogCmp) SetSize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
|
||||
type CloseInitDialogMsg struct {
|
||||
Initialize bool
|
||||
}
|
||||
|
||||
// ShowInitDialogMsg is a message that is sent to show the init dialog.
|
||||
type ShowInitDialogMsg struct {
|
||||
Show bool
|
||||
}
|
||||
@@ -234,7 +234,10 @@ func (s *sessionDialog) Render(background string) string {
|
||||
t := theme.CurrentTheme()
|
||||
renameView := s.renameInput.View()
|
||||
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
mutedStyle := styles.NewStyle().
|
||||
Foreground(t.TextMuted()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Render
|
||||
helpText := mutedStyle("Enter to confirm, Esc to cancel")
|
||||
helpText = styles.NewStyle().PaddingLeft(1).PaddingTop(1).Render(helpText)
|
||||
|
||||
@@ -245,11 +248,15 @@ func (s *sessionDialog) Render(background string) string {
|
||||
listView := s.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := styles.NewStyle().Foreground(t.Text()).Background(t.BackgroundPanel()).Render
|
||||
keyStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Bold(true).
|
||||
Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new session") + " " + keyStyle("r") + mutedStyle(" rename")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete session")
|
||||
leftHelp := keyStyle("n") + mutedStyle(" new ") + keyStyle("r") + mutedStyle(" rename")
|
||||
rightHelp := keyStyle("x/del") + mutedStyle(" delete")
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpText := layout.Render(layout.FlexOptions{
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package dialog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
"github.com/charmbracelet/lipgloss/v2"
|
||||
"github.com/muesli/reflow/truncate"
|
||||
"github.com/sst/opencode-sdk-go"
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/components/list"
|
||||
"github.com/sst/opencode/internal/components/modal"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
)
|
||||
|
||||
// TimelineDialog interface for the session timeline dialog
|
||||
type TimelineDialog interface {
|
||||
layout.Modal
|
||||
}
|
||||
|
||||
// ScrollToMessageMsg is sent when a message should be scrolled to
|
||||
type ScrollToMessageMsg struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// RestoreToMessageMsg is sent when conversation should be restored to a specific message
|
||||
type RestoreToMessageMsg struct {
|
||||
MessageID string
|
||||
Index int
|
||||
}
|
||||
|
||||
// timelineItem represents a user message in the timeline list
|
||||
type timelineItem struct {
|
||||
messageID string
|
||||
content string
|
||||
timestamp time.Time
|
||||
index int // Index in the full message list
|
||||
toolCount int // Number of tools used in this message
|
||||
}
|
||||
|
||||
func (n timelineItem) Render(
|
||||
selected bool,
|
||||
width int,
|
||||
isFirstInViewport bool,
|
||||
baseStyle styles.Style,
|
||||
isCurrent bool,
|
||||
) string {
|
||||
t := theme.CurrentTheme()
|
||||
infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
|
||||
textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
|
||||
|
||||
// Add dot after timestamp if this is the current message - only apply color when not selected
|
||||
var dot string
|
||||
var dotVisualLen int
|
||||
if isCurrent {
|
||||
if selected {
|
||||
dot = "● "
|
||||
} else {
|
||||
dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
|
||||
}
|
||||
dotVisualLen = 2 // "● " is 2 characters wide
|
||||
}
|
||||
|
||||
// Format timestamp - only apply color when not selected
|
||||
var timeStr string
|
||||
var timeVisualLen int
|
||||
if selected {
|
||||
timeStr = n.timestamp.Format("15:04") + " " + dot
|
||||
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
|
||||
} else {
|
||||
timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
|
||||
timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
|
||||
}
|
||||
|
||||
// Tool count display (fixed width for alignment) - only apply color when not selected
|
||||
toolInfo := ""
|
||||
toolInfoVisualLen := 0
|
||||
if n.toolCount > 0 {
|
||||
toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
|
||||
if selected {
|
||||
toolInfo = toolInfoText
|
||||
} else {
|
||||
toolInfo = infoStyle(toolInfoText)
|
||||
}
|
||||
toolInfoVisualLen = lipgloss.Width(toolInfo)
|
||||
}
|
||||
|
||||
// Calculate available space for content
|
||||
// Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
|
||||
reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
|
||||
contentWidth := max(width-reservedSpace, 8)
|
||||
|
||||
truncatedContent := truncate.StringWithTail(
|
||||
strings.Split(n.content, "\n")[0],
|
||||
uint(contentWidth),
|
||||
"...",
|
||||
)
|
||||
|
||||
// Apply normal text color to content for non-selected items
|
||||
var styledContent string
|
||||
if selected {
|
||||
styledContent = truncatedContent
|
||||
} else {
|
||||
styledContent = textStyle(truncatedContent)
|
||||
}
|
||||
|
||||
// Create the line with proper spacing - content left-aligned, tools right-aligned
|
||||
var text string
|
||||
text = timeStr + styledContent
|
||||
if toolInfo != "" {
|
||||
bgColor := t.BackgroundPanel()
|
||||
if selected {
|
||||
bgColor = t.Primary()
|
||||
}
|
||||
text = layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &bgColor,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifySpaceBetween,
|
||||
Align: layout.AlignStretch,
|
||||
Width: width - 2,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: text,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: toolInfo,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
var itemStyle styles.Style
|
||||
if selected {
|
||||
itemStyle = baseStyle.
|
||||
Background(t.Primary()).
|
||||
Foreground(t.BackgroundElement()).
|
||||
Width(width).
|
||||
PaddingLeft(1)
|
||||
} else {
|
||||
itemStyle = baseStyle.PaddingLeft(1)
|
||||
}
|
||||
|
||||
return itemStyle.Render(text)
|
||||
}
|
||||
|
||||
func (n timelineItem) Selectable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type timelineDialog struct {
|
||||
width int
|
||||
height int
|
||||
modal *modal.Modal
|
||||
list list.List[timelineItem]
|
||||
app *app.App
|
||||
}
|
||||
|
||||
func (n *timelineDialog) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
n.width = msg.Width
|
||||
n.height = msg.Height
|
||||
n.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "up", "down":
|
||||
// Handle navigation and immediately scroll to selected message
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := n.list.Update(msg)
|
||||
n.list = listModel.(list.List[timelineItem])
|
||||
|
||||
// Get the newly selected item and scroll to it immediately
|
||||
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, tea.Sequence(
|
||||
cmd,
|
||||
util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
|
||||
)
|
||||
}
|
||||
return n, cmd
|
||||
case "r":
|
||||
// Restore conversation to selected message
|
||||
if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, tea.Sequence(
|
||||
util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
|
||||
util.CmdHandler(modal.CloseModalMsg{}),
|
||||
)
|
||||
}
|
||||
case "enter":
|
||||
// Keep Enter functionality for closing the modal
|
||||
if _, idx := n.list.GetSelectedItem(); idx >= 0 {
|
||||
return n, util.CmdHandler(modal.CloseModalMsg{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
listModel, cmd := n.list.Update(msg)
|
||||
n.list = listModel.(list.List[timelineItem])
|
||||
return n, cmd
|
||||
}
|
||||
|
||||
func (n *timelineDialog) Render(background string) string {
|
||||
listView := n.list.View()
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
keyStyle := styles.NewStyle().
|
||||
Foreground(t.Text()).
|
||||
Background(t.BackgroundPanel()).
|
||||
Bold(true).
|
||||
Render
|
||||
mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
||||
|
||||
helpText := keyStyle(
|
||||
"↑/↓",
|
||||
) + mutedStyle(
|
||||
" jump ",
|
||||
) + keyStyle(
|
||||
"r",
|
||||
) + mutedStyle(
|
||||
" restore",
|
||||
)
|
||||
|
||||
bgColor := t.BackgroundPanel()
|
||||
helpView := styles.NewStyle().
|
||||
Background(bgColor).
|
||||
Width(layout.Current.Container.Width - 14).
|
||||
PaddingLeft(1).
|
||||
PaddingTop(1).
|
||||
Render(helpText)
|
||||
|
||||
content := strings.Join([]string{listView, helpView}, "\n")
|
||||
|
||||
return n.modal.Render(content, background)
|
||||
}
|
||||
|
||||
func (n *timelineDialog) Close() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractMessagePreview extracts a preview from message parts
|
||||
func extractMessagePreview(parts []opencode.PartUnion) string {
|
||||
for _, part := range parts {
|
||||
switch casted := part.(type) {
|
||||
case opencode.TextPart:
|
||||
text := strings.TrimSpace(casted.Text)
|
||||
if text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return "No text content"
|
||||
}
|
||||
|
||||
// countToolsInResponse counts tools in the assistant's response to a user message
|
||||
func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
|
||||
count := 0
|
||||
// Look at subsequent messages to find the assistant's response
|
||||
for i := userMessageIndex + 1; i < len(messages); i++ {
|
||||
message := messages[i]
|
||||
// If we hit another user message, stop looking
|
||||
if _, isUser := message.Info.(opencode.UserMessage); isUser {
|
||||
break
|
||||
}
|
||||
// Count tools in this assistant message
|
||||
for _, part := range message.Parts {
|
||||
switch part.(type) {
|
||||
case opencode.ToolPart:
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// NewTimelineDialog creates a new session timeline dialog
|
||||
func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
|
||||
var items []timelineItem
|
||||
|
||||
// Filter to only user messages and extract relevant info
|
||||
for i, message := range app.Messages {
|
||||
if userMsg, ok := message.Info.(opencode.UserMessage); ok {
|
||||
preview := extractMessagePreview(message.Parts)
|
||||
toolCount := countToolsInResponse(app.Messages, i)
|
||||
|
||||
items = append(items, timelineItem{
|
||||
messageID: userMsg.ID,
|
||||
content: preview,
|
||||
timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
|
||||
index: i,
|
||||
toolCount: toolCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
listComponent := list.NewListComponent(
|
||||
list.WithItems(items),
|
||||
list.WithMaxVisibleHeight[timelineItem](12),
|
||||
list.WithFallbackMessage[timelineItem]("No user messages in this session"),
|
||||
list.WithAlphaNumericKeys[timelineItem](true),
|
||||
list.WithRenderFunc(
|
||||
func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
|
||||
// Determine if this item is the current message for the session
|
||||
isCurrent := false
|
||||
if app.Session.Revert.MessageID != "" {
|
||||
// When reverted, Session.Revert.MessageID contains the NEXT user message ID
|
||||
// So we need to find the previous user message to highlight the correct one
|
||||
for i, navItem := range items {
|
||||
if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
|
||||
// Found the next message, so the previous one is current
|
||||
isCurrent = item.messageID == items[i-1].messageID
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if len(app.Messages) > 0 {
|
||||
// If not reverted, highlight the last user message
|
||||
lastUserMsgID := ""
|
||||
for i := len(app.Messages) - 1; i >= 0; i-- {
|
||||
if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
|
||||
lastUserMsgID = userMsg.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
isCurrent = item.messageID == lastUserMsgID
|
||||
}
|
||||
// Only show the dot if undo/redo/restore is available
|
||||
showDot := app.Session.Revert.MessageID != ""
|
||||
return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
|
||||
},
|
||||
),
|
||||
list.WithSelectableFunc(func(item timelineItem) bool {
|
||||
return true
|
||||
}),
|
||||
)
|
||||
listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
||||
|
||||
return &timelineDialog{
|
||||
list: listComponent,
|
||||
app: app,
|
||||
modal: modal.New(
|
||||
modal.WithTitle("Session Timeline"),
|
||||
modal.WithMaxWidth(layout.Current.Container.Width-8),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
package fileviewer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea/v2"
|
||||
|
||||
"github.com/sst/opencode/internal/app"
|
||||
"github.com/sst/opencode/internal/commands"
|
||||
"github.com/sst/opencode/internal/components/dialog"
|
||||
"github.com/sst/opencode/internal/components/diff"
|
||||
"github.com/sst/opencode/internal/layout"
|
||||
"github.com/sst/opencode/internal/styles"
|
||||
"github.com/sst/opencode/internal/theme"
|
||||
"github.com/sst/opencode/internal/util"
|
||||
"github.com/sst/opencode/internal/viewport"
|
||||
)
|
||||
|
||||
type DiffStyle int
|
||||
|
||||
const (
|
||||
DiffStyleSplit DiffStyle = iota
|
||||
DiffStyleUnified
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
app *app.App
|
||||
width, height int
|
||||
viewport viewport.Model
|
||||
filename *string
|
||||
content *string
|
||||
isDiff *bool
|
||||
diffStyle DiffStyle
|
||||
}
|
||||
|
||||
type fileRenderedMsg struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func New(app *app.App) Model {
|
||||
vp := viewport.New()
|
||||
m := Model{
|
||||
app: app,
|
||||
viewport: vp,
|
||||
diffStyle: DiffStyleUnified,
|
||||
}
|
||||
if app.State.SplitDiff {
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.viewport.Init()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case fileRenderedMsg:
|
||||
m.viewport.SetContent(msg.content)
|
||||
return m, util.CmdHandler(app.FileRenderedMsg{
|
||||
FilePath: *m.filename,
|
||||
})
|
||||
case dialog.ThemeSelectedMsg:
|
||||
return m, m.render()
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
vp, cmd := m.viewport.Update(msg)
|
||||
m.viewport = vp
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.HasFile() {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := *m.filename
|
||||
header = styles.NewStyle().
|
||||
Padding(1, 2).
|
||||
Width(m.width).
|
||||
Background(theme.CurrentTheme().BackgroundElement()).
|
||||
Foreground(theme.CurrentTheme().Text()).
|
||||
Render(header)
|
||||
|
||||
t := theme.CurrentTheme()
|
||||
|
||||
close := m.app.Key(commands.FileCloseCommand)
|
||||
diffToggle := m.app.Key(commands.FileDiffToggleCommand)
|
||||
if m.isDiff == nil || *m.isDiff == false {
|
||||
diffToggle = ""
|
||||
}
|
||||
layoutToggle := m.app.Key(commands.MessagesLayoutToggleCommand)
|
||||
|
||||
background := t.Background()
|
||||
footer := layout.Render(
|
||||
layout.FlexOptions{
|
||||
Background: &background,
|
||||
Direction: layout.Row,
|
||||
Justify: layout.JustifyCenter,
|
||||
Align: layout.AlignStretch,
|
||||
Width: m.width - 2,
|
||||
Gap: 5,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: close,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: layoutToggle,
|
||||
},
|
||||
layout.FlexItem{
|
||||
View: diffToggle,
|
||||
},
|
||||
)
|
||||
footer = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(footer)
|
||||
|
||||
return header + "\n" + m.viewport.View() + "\n" + footer
|
||||
}
|
||||
|
||||
func (m *Model) Clear() (Model, tea.Cmd) {
|
||||
m.filename = nil
|
||||
m.content = nil
|
||||
m.isDiff = nil
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) ToggleDiff() (Model, tea.Cmd) {
|
||||
switch m.diffStyle {
|
||||
case DiffStyleSplit:
|
||||
m.diffStyle = DiffStyleUnified
|
||||
default:
|
||||
m.diffStyle = DiffStyleSplit
|
||||
}
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) DiffStyle() DiffStyle {
|
||||
return m.diffStyle
|
||||
}
|
||||
|
||||
func (m Model) HasFile() bool {
|
||||
return m.filename != nil && m.content != nil
|
||||
}
|
||||
|
||||
func (m Model) Filename() string {
|
||||
if m.filename == nil {
|
||||
return ""
|
||||
}
|
||||
return *m.filename
|
||||
}
|
||||
|
||||
func (m *Model) SetSize(width, height int) (Model, tea.Cmd) {
|
||||
if m.width != width || m.height != height {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height - 4)
|
||||
return *m, m.render()
|
||||
}
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) SetFile(filename string, content string, isDiff bool) (Model, tea.Cmd) {
|
||||
m.filename = &filename
|
||||
m.content = &content
|
||||
m.isDiff = &isDiff
|
||||
return *m, m.render()
|
||||
}
|
||||
|
||||
func (m *Model) render() tea.Cmd {
|
||||
if m.filename == nil || m.content == nil {
|
||||
m.viewport.SetContent("")
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
t := theme.CurrentTheme()
|
||||
var rendered string
|
||||
|
||||
if m.isDiff != nil && *m.isDiff {
|
||||
diffResult := ""
|
||||
var err error
|
||||
if m.diffStyle == DiffStyleSplit {
|
||||
diffResult, err = diff.FormatDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
} else if m.diffStyle == DiffStyleUnified {
|
||||
diffResult, err = diff.FormatUnifiedDiff(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
diff.WithWidth(m.width),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
rendered = styles.NewStyle().
|
||||
Foreground(t.Error()).
|
||||
Render(fmt.Sprintf("Error rendering diff: %v", err))
|
||||
} else {
|
||||
rendered = strings.TrimRight(diffResult, "\n")
|
||||
}
|
||||
} else {
|
||||
rendered = util.RenderFile(
|
||||
*m.filename,
|
||||
*m.content,
|
||||
m.width,
|
||||
)
|
||||
}
|
||||
|
||||
rendered = styles.NewStyle().
|
||||
Width(m.width).
|
||||
Background(t.BackgroundPanel()).
|
||||
Render(rendered)
|
||||
|
||||
return fileRenderedMsg{
|
||||
content: rendered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) ScrollTo(line int) {
|
||||
m.viewport.SetYOffset(line)
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToBottom() {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
|
||||
func (m *Model) ScrollToTop() {
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
|
||||
func (m *Model) PageUp() (Model, tea.Cmd) {
|
||||
m.viewport.ViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) PageDown() (Model, tea.Cmd) {
|
||||
m.viewport.ViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageUp() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewUp()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m *Model) HalfPageDown() (Model, tea.Cmd) {
|
||||
m.viewport.HalfViewDown()
|
||||
return *m, nil
|
||||
}
|
||||
|
||||
func (m Model) AtTop() bool {
|
||||
return m.viewport.AtTop()
|
||||
}
|
||||
|
||||
func (m Model) AtBottom() bool {
|
||||
return m.viewport.AtBottom()
|
||||
}
|
||||
|
||||
func (m Model) ScrollPercent() float64 {
|
||||
return m.viewport.ScrollPercent()
|
||||
}
|
||||
|
||||
func (m Model) TotalLineCount() int {
|
||||
return m.viewport.TotalLineCount()
|
||||
}
|
||||
|
||||
func (m Model) VisibleLineCount() int {
|
||||
return m.viewport.VisibleLineCount()
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func (m *statusComponent) View() string {
|
||||
modeForeground = t.BackgroundPanel()
|
||||
}
|
||||
|
||||
command := m.app.Commands[commands.SwitchAgentCommand]
|
||||
command := m.app.Commands[commands.AgentCycleCommand]
|
||||
kb := command.Keybindings[0]
|
||||
key := kb.Key
|
||||
if kb.RequiresLeader {
|
||||
|
||||
@@ -670,6 +670,28 @@ func (m *Model) InsertAttachment(att *attachment.Attachment) {
|
||||
m.SetCursorColumn(m.col)
|
||||
}
|
||||
|
||||
// removeAttachmentAtCursor replaces the attachment at or immediately before the
|
||||
// cursor with its textual display and positions the cursor at the end of the
|
||||
// inserted text. Returns true if an attachment was removed.
|
||||
func (m *Model) removeAttachmentAtCursor() bool {
|
||||
att, startIdx, _ := m.isAttachmentAtCursor()
|
||||
if att == nil {
|
||||
return false
|
||||
}
|
||||
// Replace the attachment element with the display runes
|
||||
before := m.value[m.row][:startIdx]
|
||||
after := m.value[m.row][startIdx+1:]
|
||||
replacement := runesToInterfaces([]rune(att.Display))
|
||||
newRow := make([]any, 0, len(before)+len(replacement)+len(after))
|
||||
newRow = append(newRow, before...)
|
||||
newRow = append(newRow, replacement...)
|
||||
newRow = append(newRow, after...)
|
||||
m.value[m.row] = newRow
|
||||
m.col = startIdx + len(replacement)
|
||||
m.SetCursorColumn(m.col)
|
||||
return true
|
||||
}
|
||||
|
||||
// ReplaceRange replaces text from startCol to endCol on the current row with the given string.
|
||||
// This preserves attachments outside the replaced range.
|
||||
func (m *Model) ReplaceRange(startCol, endCol int, replacement string) {
|
||||
@@ -1577,6 +1599,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
m.deleteBeforeCursor()
|
||||
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
|
||||
// If the cursor is at or just after an attachment, convert it to text instead of deleting
|
||||
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
|
||||
if m.removeAttachmentAtCursor() {
|
||||
break
|
||||
}
|
||||
}
|
||||
m.col = clamp(m.col, 0, len(m.value[m.row]))
|
||||
if m.col <= 0 {
|
||||
m.mergeLineAbove(m.row)
|
||||
@@ -1587,6 +1615,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.SetCursorColumn(m.col - 1)
|
||||
}
|
||||
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
|
||||
// If the cursor is on an attachment, convert it to text instead of deleting
|
||||
if att, _, _ := m.isAttachmentAtCursor(); att != nil {
|
||||
if m.removeAttachmentAtCursor() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
|
||||
m.value[m.row] = slices.Delete(m.value[m.row], m.col, m.col+1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package textarea
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sst/opencode/internal/attachment"
|
||||
)
|
||||
|
||||
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorAfterAttachment(t *testing.T) {
|
||||
m := New()
|
||||
m.InsertString("a ")
|
||||
att := &attachment.Attachment{ID: "1", Display: "@file.txt"}
|
||||
m.InsertAttachment(att)
|
||||
m.InsertString(" b")
|
||||
|
||||
// Position cursor immediately after the attachment (index 3: 'a',' ',att,' ', 'b')
|
||||
m.SetCursorColumn(3)
|
||||
|
||||
if ok := m.removeAttachmentAtCursor(); !ok {
|
||||
t.Fatalf("expected removal to occur")
|
||||
}
|
||||
got := m.Value()
|
||||
want := "a @file.txt b"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAttachmentAtCursor_ConvertsToText_WhenCursorOnAttachment(t *testing.T) {
|
||||
m := New()
|
||||
m.InsertString("x ")
|
||||
att := &attachment.Attachment{ID: "2", Display: "@img.png"}
|
||||
m.InsertAttachment(att)
|
||||
m.InsertString(" y")
|
||||
|
||||
// Position cursor on the attachment token (index 2: 'x',' ',att,' ', 'y')
|
||||
m.SetCursorColumn(2)
|
||||
|
||||
if ok := m.removeAttachmentAtCursor(); !ok {
|
||||
t.Fatalf("expected removal to occur")
|
||||
}
|
||||
got := m.Value()
|
||||
want := "x @img.png y"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAttachmentAtCursor_StartOfLine(t *testing.T) {
|
||||
m := New()
|
||||
att := &attachment.Attachment{ID: "3", Display: "@a.txt"}
|
||||
m.InsertAttachment(att)
|
||||
m.InsertString(" tail")
|
||||
|
||||
// Position cursor immediately after the attachment at start of line (index 1)
|
||||
m.SetCursorColumn(1)
|
||||
if ok := m.removeAttachmentAtCursor(); !ok {
|
||||
t.Fatalf("expected removal to occur at start of line")
|
||||
}
|
||||
if got := m.Value(); got != "@a.txt tail" {
|
||||
t.Fatalf("unexpected value: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAttachmentAtCursor_NoAttachment_NoChange(t *testing.T) {
|
||||
m := New()
|
||||
m.InsertString("hello world")
|
||||
col := m.CursorColumn()
|
||||
if ok := m.removeAttachmentAtCursor(); ok {
|
||||
t.Fatalf("did not expect removal to occur")
|
||||
}
|
||||
if m.Value() != "hello world" || m.CursorColumn() != col {
|
||||
t.Fatalf("value or cursor unexpectedly changed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user