Compare commits

...
11 Commits
Author SHA1 Message Date
Dax Raad 2d84dadc0c fix broken attachments
publish / publish (push) Has been cancelled
2025-07-21 15:38:41 -04:00
Dax Raad 45c0578b22 fix title generation bug
publish / publish (push) Has been cancelled
2025-07-21 15:23:47 -04:00
Dax 1ded535175 message queuing (#1200)
publish / publish (push) Has been cancelled
2025-07-21 15:14:54 -04:00
adamdotdevin d957ab849b fix(tui): up/down arrow handling
publish / publish (push) Has been cancelled
2025-07-21 10:44:21 -05:00
plyghtandadamdotdevin 4b2e52c834 feat(tui): paste minimizing (#784)
Co-authored-by: adamdotdevin <2363879+adamdottv@users.noreply.github.com>
2025-07-21 10:31:29 -05:00
Dax Raad 6867658c0f do not copy empty strings 2025-07-21 11:27:15 -04:00
Dax Raad b8620395cb include newline between messages when copying
publish / publish (push) Has been cancelled
2025-07-21 11:22:51 -04:00
Dax Raad 90d37c98f8 add toast for copy
publish / publish (push) Has been cancelled
2025-07-21 11:19:54 -04:00
adamelmore c9a40917c2 feat(tui): disable keybinds 2025-07-21 10:08:25 -05:00
adamelmore 0aa0e740cd docs: cleanup 2025-07-21 10:02:58 -05:00
adamelmore bb17d14665 feat(tui): theme override with OPENCODE_THEME 2025-07-21 10:02:57 -05:00
13 changed files with 343 additions and 177 deletions
+148 -62
View File
@@ -118,11 +118,22 @@ export namespace Session {
const sessions = new Map<string, Info>() const sessions = new Map<string, Info>()
const messages = new Map<string, MessageV2.Info[]>() const messages = new Map<string, MessageV2.Info[]>()
const pending = new Map<string, AbortController>() const pending = new Map<string, AbortController>()
const queued = new Map<
string,
{
input: ChatInput
message: MessageV2.User
parts: MessageV2.Part[]
processed: boolean
callback: (input: { info: MessageV2.Assistant; parts: MessageV2.Part[] }) => void
}[]
>()
return { return {
sessions, sessions,
messages, messages,
pending, pending,
queued,
} }
}, },
async (state) => { async (state) => {
@@ -351,64 +362,14 @@ export namespace Session {
]), ]),
), ),
}) })
export type ChatInput = z.infer<typeof ChatInput>
export async function chat(input: z.infer<typeof ChatInput>) { export async function chat(
input: z.infer<typeof ChatInput>,
): Promise<{ info: MessageV2.Assistant; parts: MessageV2.Part[] }> {
const l = log.clone().tag("session", input.sessionID) const l = log.clone().tag("session", input.sessionID)
l.info("chatting") l.info("chatting")
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.info.id > session.revert.messageID ||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
await Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.info.id,
})
continue
}
if (msg.info.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
// auto summarize if too long
if (previous && previous.tokens) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
})
return chat(input)
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
const userMsg: MessageV2.Info = { const userMsg: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"), id: input.messageID ?? Identifier.ascending("message"),
role: "user", role: "user",
@@ -469,7 +430,7 @@ export namespace Session {
const args = { filePath, offset, limit } const args = { filePath, offset, limit }
const result = await ReadTool.execute(args, { const result = await ReadTool.execute(args, {
sessionID: input.sessionID, sessionID: input.sessionID,
abort: abort.signal, abort: new AbortController().signal,
messageID: userMsg.id, messageID: userMsg.id,
metadata: async () => {}, metadata: async () => {},
}) })
@@ -533,7 +494,6 @@ export namespace Session {
] ]
}), }),
).then((x) => x.flat()) ).then((x) => x.flat())
if (input.mode === "plan") if (input.mode === "plan")
userParts.push({ userParts.push({
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@@ -544,7 +504,79 @@ export namespace Session {
synthetic: true, synthetic: true,
}) })
if (msgs.length === 0 && !session.parentID) { await updateMessage(userMsg)
for (const part of userParts) {
await updatePart(part)
}
if (isLocked(input.sessionID)) {
return new Promise((resolve) => {
const queue = state().queued.get(input.sessionID) ?? []
queue.push({
input: input,
message: userMsg,
parts: userParts,
processed: false,
callback: resolve,
})
state().queued.set(input.sessionID, queue)
})
}
const model = await Provider.getModel(input.providerID, input.modelID)
let msgs = await messages(input.sessionID)
const session = await get(input.sessionID)
if (session.revert) {
const trimmed = []
for (const msg of msgs) {
if (
msg.info.id > session.revert.messageID ||
(msg.info.id === session.revert.messageID && session.revert.part === 0)
) {
await Storage.remove("session/message/" + input.sessionID + "/" + msg.info.id)
await Bus.publish(MessageV2.Event.Removed, {
sessionID: input.sessionID,
messageID: msg.info.id,
})
continue
}
if (msg.info.id === session.revert.messageID) {
if (session.revert.part === 0) break
msg.parts = msg.parts.slice(0, session.revert.part)
}
trimmed.push(msg)
}
msgs = trimmed
await update(input.sessionID, (draft) => {
draft.revert = undefined
})
}
const previous = msgs.filter((x) => x.info.role === "assistant").at(-1)?.info as MessageV2.Assistant
const outputLimit = Math.min(model.info.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
// auto summarize if too long
if (previous && previous.tokens) {
const tokens =
previous.tokens.input + previous.tokens.cache.read + previous.tokens.cache.write + previous.tokens.output
if (model.info.limit.context && tokens > Math.max((model.info.limit.context - outputLimit) * 0.9, 0)) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
})
return chat(input)
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.info.role === "assistant" && msg.info.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.info.id >= lastSummary.info.id)
if (msgs.length === 1 && !session.parentID) {
const small = (await Provider.getSmallModel(input.providerID)) ?? model const small = (await Provider.getSmallModel(input.providerID)) ?? model
generateText({ generateText({
maxOutputTokens: small.info.reasoning ? 1024 : 20, maxOutputTokens: small.info.reasoning ? 1024 : 20,
@@ -582,11 +614,6 @@ export namespace Session {
}) })
.catch(() => {}) .catch(() => {})
} }
await updateMessage(userMsg)
for (const part of userParts) {
await updatePart(part)
}
msgs.push({ info: userMsg, parts: userParts })
const mode = await Mode.get(input.mode ?? "build") const mode = await Mode.get(input.mode ?? "build")
let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : [] let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : []
@@ -692,6 +719,51 @@ export namespace Session {
const stream = streamText({ const stream = streamText({
onError() {}, onError() {},
async prepareStep({ messages }) {
const queue = (state().queued.get(input.sessionID) ?? []).filter((x) => !x.processed)
if (queue.length) {
for (const item of queue) {
if (item.processed) continue
messages.push(
...MessageV2.toModelMessage([
{
info: item.message,
parts: item.parts,
},
]),
)
item.processed = true
}
assistantMsg.time.completed = Date.now()
await updateMessage(assistantMsg)
Object.assign(assistantMsg, {
id: Identifier.ascending("message"),
role: "assistant",
system,
path: {
cwd: app.path.cwd,
root: app.path.root,
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: input.modelID,
providerID: input.providerID,
time: {
created: Date.now(),
},
sessionID: input.sessionID,
})
await updateMessage(assistantMsg)
}
return {
messages,
}
},
maxRetries: 10, maxRetries: 10,
maxOutputTokens: outputLimit, maxOutputTokens: outputLimit,
abortSignal: abort.signal, abortSignal: abort.signal,
@@ -726,6 +798,16 @@ export namespace Session {
}), }),
}) })
const result = await processor.process(stream) const result = await processor.process(stream)
const queued = state().queued.get(input.sessionID) ?? []
const unprocessed = queued.find((x) => !x.processed)
if (unprocessed) {
unprocessed.processed = true
return chat(unprocessed.input)
}
for (const item of queued) {
item.callback(result)
}
state().queued.delete(input.sessionID)
return result return result
} }
@@ -1087,6 +1169,10 @@ export namespace Session {
return result return result
} }
function isLocked(sessionID: string) {
return state().pending.has(sessionID)
}
function lock(sessionID: string) { function lock(sessionID: string) {
log.info("locking", { sessionID }) log.info("locking", { sessionID })
if (state().pending.has(sessionID)) throw new BusyError(sessionID) if (state().pending.has(sessionID)) throw new BusyError(sessionID)
-2
View File
@@ -70,7 +70,6 @@ func main() {
}() }()
// Create main context for the application // Create main context for the application
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode) app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -79,7 +78,6 @@ func main() {
program := tea.NewProgram( program := tea.NewProgram(
tui.NewModel(app_), tui.NewModel(app_),
tea.WithAltScreen(), tea.WithAltScreen(),
// tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(), tea.WithMouseCellMotion(),
) )
+7 -1
View File
@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -104,6 +105,11 @@ func New(
appState.Theme = configInfo.Theme appState.Theme = configInfo.Theme
} }
themeEnv := os.Getenv("OPENCODE_THEME")
if themeEnv != "" {
appState.Theme = themeEnv
}
var modeIndex int var modeIndex int
var mode *opencode.Mode var mode *opencode.Mode
modeName := "build" modeName := "build"
@@ -365,7 +371,7 @@ func (a *App) IsBusy() bool {
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok { if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0 return casted.Time.Completed == 0
} }
return false return true
} }
func (a *App) SaveState() tea.Cmd { func (a *App) SaveState() tea.Cmd {
+23 -1
View File
@@ -25,12 +25,32 @@ func (p Prompt) ToMessage(
Created: float64(time.Now().UnixMilli()), Created: float64(time.Now().UnixMilli()),
}, },
} }
text := p.Text
textAttachments := []*attachment.Attachment{}
for _, attachment := range p.Attachments {
if attachment.Type == "text" {
textAttachments = append(textAttachments, attachment)
}
}
for i := 0; i < len(textAttachments)-1; i++ {
for j := i + 1; j < len(textAttachments); j++ {
if textAttachments[i].StartIndex < textAttachments[j].StartIndex {
textAttachments[i], textAttachments[j] = textAttachments[j], textAttachments[i]
}
}
}
for _, att := range textAttachments {
source, _ := att.GetTextSource()
text = text[:att.StartIndex] + source.Value + text[att.EndIndex:]
}
parts := []opencode.PartUnion{opencode.TextPart{ parts := []opencode.PartUnion{opencode.TextPart{
ID: id.Ascending(id.Part), ID: id.Ascending(id.Part),
MessageID: messageID, MessageID: messageID,
SessionID: sessionID, SessionID: sessionID,
Type: opencode.TextPartTypeText, Type: opencode.TextPartTypeText,
Text: p.Text, Text: text,
}} }}
for _, attachment := range p.Attachments { for _, attachment := range p.Attachments {
text := opencode.FilePartSourceText{ text := opencode.FilePartSourceText{
@@ -40,6 +60,8 @@ func (p Prompt) ToMessage(
} }
var source *opencode.FilePartSource var source *opencode.FilePartSource
switch attachment.Type { switch attachment.Type {
case "text":
continue
case "file": case "file":
fileSource, _ := attachment.GetFileSource() fileSource, _ := attachment.GetFileSource()
source = &opencode.FilePartSource{ source = &opencode.FilePartSource{
@@ -4,6 +4,10 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type TextSource struct {
Value string `toml:"value"`
}
type FileSource struct { type FileSource struct {
Path string `toml:"path"` Path string `toml:"path"`
Mime string `toml:"mime"` Mime string `toml:"mime"`
@@ -46,6 +50,14 @@ func NewAttachment() *Attachment {
} }
} }
func (a *Attachment) GetTextSource() (*TextSource, bool) {
if a.Type != "text" {
return nil, false
}
ts, ok := a.Source.(*TextSource)
return ts, ok
}
// GetFileSource returns the source as FileSource if the attachment is a file type // GetFileSource returns the source as FileSource if the attachment is a file type
func (a *Attachment) GetFileSource() (*FileSource, bool) { func (a *Attachment) GetFileSource() (*FileSource, bool) {
if a.Type != "file" { if a.Type != "file" {
@@ -349,6 +349,9 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
continue continue
} }
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" { if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
if keybind == "none" {
continue
}
command.Keybindings = parseBindings(keybind) command.Keybindings = parseBindings(keybind)
} }
registry[command.Name] = command registry[command.Name] = command
@@ -56,6 +56,7 @@ type editorComponent struct {
exitKeyInDebounce bool exitKeyInDebounce bool
historyIndex int // -1 means current (not in history) historyIndex int // -1 means current (not in history)
currentText string // Store current text when navigating history currentText string // Store current text when navigating history
pasteCounter int
} }
func (m *editorComponent) Init() tea.Cmd { func (m *editorComponent) Init() tea.Cmd {
@@ -82,13 +83,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.historyIndex == -1 { if m.historyIndex == -1 {
// Save current text before entering history // Save current text before entering history
m.currentText = m.textarea.Value() m.currentText = m.textarea.Value()
m.textarea.CursorStart() m.textarea.MoveToBegin()
} }
// Move up in history (older messages) // Move up in history (older messages)
if m.historyIndex < len(m.app.State.MessageHistory)-1 { if m.historyIndex < len(m.app.State.MessageHistory)-1 {
m.historyIndex++ m.historyIndex++
m.RestoreFromHistory(m.historyIndex) m.RestoreFromHistory(m.historyIndex)
m.textarea.CursorStart() m.textarea.MoveToBegin()
} }
return m, nil return m, nil
} }
@@ -104,11 +105,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.currentText = "" m.currentText = ""
} else { } else {
m.RestoreFromHistory(m.historyIndex) m.RestoreFromHistory(m.historyIndex)
m.textarea.CursorEnd() m.textarea.MoveToEnd()
} }
return m, nil return m, nil
} else if m.historyIndex > -1 { } else if m.historyIndex > -1 {
m.textarea.CursorEnd() m.textarea.MoveToEnd()
return m, nil return m, nil
} }
} }
@@ -129,12 +130,22 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text, err := strconv.Unquote(`"` + text + `"`) text, err := strconv.Unquote(`"` + text + `"`)
if err != nil { if err != nil {
slog.Error("Failed to unquote text", "error", err) slog.Error("Failed to unquote text", "error", err)
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg)) m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil return m, nil
} }
if _, err := os.Stat(text); err != nil { if _, err := os.Stat(text); err != nil {
slog.Error("Failed to paste file", "error", err) slog.Error("Failed to paste file", "error", err)
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg)) m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil return m, nil
} }
@@ -142,7 +153,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
attachment := m.createAttachmentFromFile(filePath) attachment := m.createAttachmentFromFile(filePath)
if attachment == nil { if attachment == nil {
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg)) m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil return m, nil
} }
@@ -150,7 +165,12 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertString(" ") m.textarea.InsertString(" ")
case tea.ClipboardMsg: case tea.ClipboardMsg:
text := string(msg) text := string(msg)
// Check if the pasted text is long and should be summarized
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(text)) m.textarea.InsertRunesFromUserInput([]rune(text))
}
case dialog.ThemeSelectedMsg: case dialog.ThemeSelectedMsg:
m.textarea = updateTextareaStyles(m.textarea) m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner() m.spinner = createSpinner()
@@ -392,6 +412,7 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset() m.textarea.Reset()
m.historyIndex = -1 m.historyIndex = -1
m.currentText = "" m.currentText = ""
m.pasteCounter = 0
return m, nil return m, nil
} }
@@ -421,7 +442,13 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
textBytes := clipboard.Read(clipboard.FmtText) textBytes := clipboard.Read(clipboard.FmtText)
if textBytes != nil { if textBytes != nil {
m.textarea.InsertRunesFromUserInput([]rune(string(textBytes))) text := string(textBytes)
// Check if the pasted text is long and should be summarized
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(text))
}
return m, nil return m, nil
} }
@@ -490,6 +517,48 @@ func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0] return m.app.Commands[commands.AppExitCommand].Keys()[0]
} }
// shouldSummarizePastedText determines if pasted text should be summarized
func (m *editorComponent) shouldSummarizePastedText(text string) bool {
lines := strings.Split(text, "\n")
lineCount := len(lines)
charCount := len(text)
// Consider text long if it has more than 3 lines or more than 150 characters
return lineCount > 3 || charCount > 150
}
// handleLongPaste handles long pasted text by creating a summary attachment
func (m *editorComponent) handleLongPaste(text string) {
lines := strings.Split(text, "\n")
lineCount := len(lines)
// Increment paste counter
m.pasteCounter++
// Create attachment with full text as base64 encoded data
fileBytes := []byte(text)
base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes)
url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText)
fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter)
displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount)
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "text",
MediaType: "text/plain",
Display: displayText,
URL: url,
Filename: fileName,
Source: &attachment.TextSource{
Value: text,
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
}
func updateTextareaStyles(ta textarea.Model) textarea.Model { func updateTextareaStyles(ta textarea.Model) textarea.Model {
t := theme.CurrentTheme() t := theme.CurrentTheme()
bgColor := t.BackgroundElement() bgColor := t.BackgroundElement()
@@ -551,6 +620,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
spinner: s, spinner: s,
interruptKeyInDebounce: false, interruptKeyInDebounce: false,
historyIndex: -1, historyIndex: -1,
pasteCounter: 0,
} }
return m return m
@@ -196,7 +196,10 @@ func renderText(
case opencode.UserMessage: case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created)) ts = time.UnixMilli(int64(casted.Time.Created))
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor) base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
words := strings.Fields(text) text = ansi.WordwrapWc(text, width-6, " -")
lines := strings.Split(text, "\n")
for i, line := range lines {
words := strings.Fields(line)
for i, word := range words { for i, word := range words {
if strings.HasPrefix(word, "@") { if strings.HasPrefix(word, "@") {
words[i] = base.Foreground(t.Secondary()).Render(word + " ") words[i] = base.Foreground(t.Secondary()).Render(word + " ")
@@ -204,8 +207,9 @@ func renderText(
words[i] = base.Render(word + " ") words[i] = base.Render(word + " ")
} }
} }
text = strings.Join(words, "") lines[i] = strings.Join(words, "")
text = ansi.WordwrapWc(text, width-6, " -") }
text = strings.Join(lines, "\n")
content = base.Width(width - 6).Render(text) content = base.Width(width - 6).Render(text)
} }
@@ -3,6 +3,7 @@ package chat
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"slices"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea/v2" tea "github.com/charmbracelet/bubbletea/v2"
@@ -46,7 +47,7 @@ type messagesComponent struct {
tail bool tail bool
partCount int partCount int
lineCount int lineCount int
selection selection selection *selection
} }
type selection struct { type selection struct {
@@ -56,18 +57,10 @@ type selection struct {
endY int endY int
} }
func (s selection) selecting() bool { func (s selection) coords(offset int) *selection {
return s.startX >= 0 && s.startY >= 0
}
func (s selection) hasCompleteSelection() bool {
return s.startX >= 0 && s.startY >= 0 && s.endX >= 0 && s.endY >= 0
}
func (s selection) coords(offset int) selection {
// selecting backwards // selecting backwards
if s.startY > s.endY && s.endY >= 0 { if s.startY > s.endY && s.endY >= 0 {
return selection{ return &selection{
startX: max(0, s.endX-1), startX: max(0, s.endX-1),
startY: s.endY - offset, startY: s.endY - offset,
endX: s.startX + 1, endX: s.startX + 1,
@@ -77,7 +70,7 @@ func (s selection) coords(offset int) selection {
// selecting backwards same line // selecting backwards same line
if s.startY == s.endY && s.startX >= s.endX { if s.startY == s.endY && s.startX >= s.endX {
return selection{ return &selection{
startY: s.startY - offset, startY: s.startY - offset,
startX: max(0, s.endX-1), startX: max(0, s.endX-1),
endY: s.endY - offset, endY: s.endY - offset,
@@ -85,7 +78,7 @@ func (s selection) coords(offset int) selection {
} }
} }
return selection{ return &selection{
startX: s.startX, startX: s.startX,
startY: s.startY - offset, startY: s.startY - offset,
endX: s.endX, endX: s.endX,
@@ -108,7 +101,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset) slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
y := msg.Y + m.viewport.YOffset y := msg.Y + m.viewport.YOffset
if y > 0 { if y > 0 {
m.selection = selection{ m.selection = &selection{
startY: y, startY: y,
startX: msg.X, startX: msg.X,
endY: -1, endY: -1,
@@ -120,8 +113,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.MouseMotionMsg: case tea.MouseMotionMsg:
if m.selection.selecting() { if m.selection != nil {
m.selection = selection{ m.selection = &selection{
startX: m.selection.startX, startX: m.selection.startX,
startY: m.selection.startY, startY: m.selection.startY,
endX: msg.X + 1, endX: msg.X + 1,
@@ -131,16 +124,14 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case tea.MouseReleaseMsg: case tea.MouseReleaseMsg:
if m.selection.hasCompleteSelection() { if m.selection != nil && len(m.clipboard) > 0 {
m.selection = selection{ content := strings.Join(m.clipboard, "\n")
startX: -1, m.selection = nil
startY: -1, m.clipboard = []string{}
endX: -1, return m, tea.Sequence(
endY: -1,
}
return m, tea.Batch(
app.SetClipboard(strings.Join(m.clipboard, "\n")),
m.renderView(), m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
) )
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -242,6 +233,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
width := m.width // always use full width width := m.width // always use full width
lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
for _, msg := range slices.Backward(m.app.Messages) {
if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
lastAssistantMessage = assistant.ID
break
}
}
for _, message := range m.app.Messages { for _, message := range m.app.Messages {
var content string var content string
var cached bool var cached bool
@@ -293,14 +291,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
flexItems..., flexItems...,
) )
key := m.cache.GenerateKey(casted.ID, part.Text, width, files) author := m.app.Config.Username
if casted.ID > lastAssistantMessage {
author += " [queued]"
}
key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author)
content, cached = m.cache.Get(key) content, cached = m.cache.Get(key)
if !cached { if !cached {
content = renderText( content = renderText(
m.app, m.app,
message.Info, message.Info,
part.Text, part.Text,
m.app.Config.Username, author,
m.showToolDetails, m.showToolDetails,
width, width,
files, files,
@@ -491,12 +493,14 @@ func (m *messagesComponent) renderView() tea.Cmd {
final := []string{} final := []string{}
clipboard := []string{} clipboard := []string{}
selection := m.selection.coords(lipgloss.Height(header) + 1) var selection *selection
hasSelection := m.selection.selecting() if m.selection != nil {
selection = m.selection.coords(lipgloss.Height(header) + 1)
}
for _, block := range blocks { for _, block := range blocks {
lines := strings.Split(block, "\n") lines := strings.Split(block, "\n")
for index, line := range lines { for index, line := range lines {
if !hasSelection || index == 0 || index == len(lines)-1 { if selection == nil || index == 0 || index == len(lines)-1 {
final = append(final, line) final = append(final, line)
continue continue
} }
@@ -522,6 +526,10 @@ func (m *messagesComponent) renderView() tea.Cmd {
} }
final = append(final, line) final = append(final, line)
} }
y := len(final)
if selection != nil && y >= selection.startY && y < selection.endY {
clipboard = append(clipboard, "")
}
final = append(final, "") final = append(final, "")
} }
content := "\n" + strings.Join(final, "\n") content := "\n" + strings.Join(final, "\n")
@@ -776,11 +784,5 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
showToolDetails: true, showToolDetails: true,
cache: NewPartCache(), cache: NewPartCache(),
tail: true, tail: true,
selection: selection{
startX: -1,
startY: -1,
endX: -1,
endY: -1,
},
} }
} }
@@ -1430,14 +1430,14 @@ func (m Model) Width() int {
return m.width return m.width
} }
// moveToBegin moves the cursor to the beginning of the input. // MoveToBegin moves the cursor to the beginning of the input.
func (m *Model) moveToBegin() { func (m *Model) MoveToBegin() {
m.row = 0 m.row = 0
m.SetCursorColumn(0) m.SetCursorColumn(0)
} }
// moveToEnd moves the cursor to the end of the input. // MoveToEnd moves the cursor to the end of the input.
func (m *Model) moveToEnd() { func (m *Model) MoveToEnd() {
m.row = len(m.value) - 1 m.row = len(m.value) - 1
m.SetCursorColumn(len(m.value[m.row])) m.SetCursorColumn(len(m.value[m.row]))
} }
@@ -1626,9 +1626,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case key.Matches(msg, m.KeyMap.WordBackward): case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft() m.wordLeft()
case key.Matches(msg, m.KeyMap.InputBegin): case key.Matches(msg, m.KeyMap.InputBegin):
m.moveToBegin() m.MoveToBegin()
case key.Matches(msg, m.KeyMap.InputEnd): case key.Matches(msg, m.KeyMap.InputEnd):
m.moveToEnd() m.MoveToEnd()
case key.Matches(msg, m.KeyMap.LowercaseWordForward): case key.Matches(msg, m.KeyMap.LowercaseWordForward):
m.lowercaseRight() m.lowercaseRight()
case key.Matches(msg, m.KeyMap.UppercaseWordForward): case key.Matches(msg, m.KeyMap.UppercaseWordForward):
@@ -92,24 +92,6 @@ You can configure the theme you want to use in your opencode config through the
--- ---
### Layout
You can configure the layout of the TUI with the `layout` option.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"layout": "stretch"
}
```
This takes:
- `"auto"`: Centers content with padding. This is the default.
- `"stretch"`: Uses full terminal width.
---
### Logging ### Logging
Logs are written to: Logs are written to:
@@ -9,7 +9,6 @@ opencode has a list of keybinds that you can customize through the opencode conf
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"keybinds": { "keybinds": {
"leader": "ctrl+x", "leader": "ctrl+x",
"app_help": "<leader>h", "app_help": "<leader>h",
"switch_mode": "tab", "switch_mode": "tab",
@@ -28,10 +27,6 @@ opencode has a list of keybinds that you can customize through the opencode conf
"theme_list": "<leader>t", "theme_list": "<leader>t",
"project_init": "<leader>i", "project_init": "<leader>i",
"file_list": "<leader>f",
"file_close": "esc",
"file_diff_toggle": "<leader>v",
"input_clear": "ctrl+c", "input_clear": "ctrl+c",
"input_paste": "ctrl+v", "input_paste": "ctrl+v",
"input_submit": "enter", "input_submit": "enter",
@@ -41,13 +36,10 @@ opencode has a list of keybinds that you can customize through the opencode conf
"messages_page_down": "pgdown", "messages_page_down": "pgdown",
"messages_half_page_up": "ctrl+alt+u", "messages_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d", "messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+up",
"messages_next": "ctrl+down",
"messages_first": "ctrl+g", "messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g", "messages_last": "ctrl+alt+g",
"messages_layout_toggle": "<leader>p",
"messages_copy": "<leader>y", "messages_copy": "<leader>y",
"messages_revert": "<leader>r",
"app_exit": "ctrl+c,<leader>q" "app_exit": "ctrl+c,<leader>q"
} }
} }
@@ -60,3 +52,16 @@ opencode uses a `leader` key for most keybinds. This avoids conflicts in your te
By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. By default, `ctrl+x` is the leader key and most actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`.
You don't need to use a leader key for your keybinds but we recommend doing so. You don't need to use a leader key for your keybinds but we recommend doing so.
## Disable a keybind
You can disable a keybind by adding the key to your config with a value of "none".
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"session_compact": "none",
}
}
```
@@ -117,27 +117,3 @@ export DISPLAY=:99.0
opencode will detect if you're using Wayland and prefer `wl-clipboard`, otherwise it will try to find clipboard tools in order of: `xclip` and `xsel`. opencode will detect if you're using Wayland and prefer `wl-clipboard`, otherwise it will try to find clipboard tools in order of: `xclip` and `xsel`.
---
### How to select and copy text in the TUI
There are several ways to copy text from opencode's TUI:
- **Copy latest message**: Use `<leader>y` to copy the most recent message in your current session to the clipboard
- **Export session**: Use `/export` (or `<leader>x`) to open the current session as plain text in your `$EDITOR` (requires the `EDITOR` environment variable to be set)
We're working on adding click & drag text selection in a future update.
---
### TUI not rendering full width
By default, opencode's TUI uses an "auto" layout that centers content with padding. If you want the TUI to use the full width of your terminal, you can configure the layout setting:
```json title="opencode.json"
{
"layout": "stretch"
}
```
Read more about this in the [config docs](/docs/config#layout).