Compare commits

...

21 Commits

Author SHA1 Message Date
Dax Raad
2d84dadc0c fix broken attachments 2025-07-21 15:38:41 -04:00
Dax Raad
45c0578b22 fix title generation bug 2025-07-21 15:23:47 -04:00
Dax
1ded535175 message queuing (#1200) 2025-07-21 15:14:54 -04:00
adamdotdevin
d957ab849b fix(tui): up/down arrow handling 2025-07-21 10:44:21 -05:00
plyght
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 2025-07-21 11:22:51 -04:00
Dax Raad
90d37c98f8 add toast for copy 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
adamdotdevin
cd0b2ae032 fix(tui): restore spinner ticks 2025-07-21 05:58:24 -05:00
adamdotdevin
8e8796507d feat(tui): message history select with up/down arrows 2025-07-21 05:52:11 -05:00
Aiden Cline
cef5c29583 fix: pasting issue (#1182) 2025-07-21 04:09:16 -05:00
Aiden Cline
acaed1f270 fix: export cmd (#1184) 2025-07-21 04:08:26 -05:00
Dax
cda0dbc195 Update STATS.md 2025-07-20 20:36:23 -04:00
Dax Raad
758425a8e4 trimmed selection ui 2025-07-20 19:36:56 -04:00
Dax Raad
93446df335 ignore: remove log 2025-07-20 19:08:19 -04:00
Dax Raad
adc8b90e0f implement copy paste much wow can you believe we went this long without it so stupid i blame adam 2025-07-20 19:05:38 -04:00
Dax Raad
733c9903ec do not snapshot nongit projects for now 2025-07-20 13:59:30 -04:00
Frank
7306e20361 wip: vscode extension 2025-07-20 13:31:16 -04:00
19 changed files with 955 additions and 296 deletions

View File

@@ -20,6 +20,5 @@
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-18 | 70,380 (+1) | 102,587 (+0) | 172,967 (+1) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |

View File

@@ -118,11 +118,22 @@ export namespace Session {
const sessions = new Map<string, Info>()
const messages = new Map<string, MessageV2.Info[]>()
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 {
sessions,
messages,
pending,
queued,
}
},
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)
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 = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -469,7 +430,7 @@ export namespace Session {
const args = { filePath, offset, limit }
const result = await ReadTool.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
abort: new AbortController().signal,
messageID: userMsg.id,
metadata: async () => {},
})
@@ -533,7 +494,6 @@ export namespace Session {
]
}),
).then((x) => x.flat())
if (input.mode === "plan")
userParts.push({
id: Identifier.ascending("part"),
@@ -544,7 +504,79 @@ export namespace Session {
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
generateText({
maxOutputTokens: small.info.reasoning ? 1024 : 20,
@@ -582,11 +614,6 @@ export namespace Session {
})
.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")
let system = input.providerID === "anthropic" ? [PROMPT_ANTHROPIC_SPOOF.trim()] : []
@@ -692,6 +719,51 @@ export namespace Session {
const stream = streamText({
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,
maxOutputTokens: outputLimit,
abortSignal: abort.signal,
@@ -726,6 +798,16 @@ export namespace Session {
}),
})
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
}
@@ -1087,6 +1169,10 @@ export namespace Session {
return result
}
function isLocked(sessionID: string) {
return state().pending.has(sessionID)
}
function lock(sessionID: string) {
log.info("locking", { sessionID })
if (state().pending.has(sessionID)) throw new BusyError(sessionID)

View File

@@ -14,6 +14,7 @@ export namespace Snapshot {
// not a git repo, check if too big to snapshot
if (!app.git) {
return
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: 1000,

View File

@@ -70,7 +70,6 @@ func main() {
}()
// Create main context for the application
app_, err := app.New(ctx, version, appInfo, modes, httpClient, model, prompt, mode)
if err != nil {
panic(err)
@@ -79,7 +78,6 @@ func main() {
program := tea.NewProgram(
tui.NewModel(app_),
tea.WithAltScreen(),
// tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
)

View File

@@ -3,10 +3,10 @@ package app
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"log/slog"
@@ -15,7 +15,6 @@ import (
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/id"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -35,7 +34,7 @@ type App struct {
StatePath string
Config *opencode.Config
Client *opencode.Client
State *config.State
State *State
ModeIndex int
Mode *opencode.Mode
Provider *opencode.Provider
@@ -61,10 +60,7 @@ type ModelSelectedMsg struct {
}
type SessionClearedMsg struct{}
type CompactSessionMsg struct{}
type SendMsg struct {
Text string
Attachments []opencode.FilePartInputParam
}
type SendPrompt = Prompt
type SetEditorContentMsg struct {
Text string
}
@@ -95,20 +91,25 @@ func New(
}
appStatePath := filepath.Join(appInfo.Path.State, "tui")
appState, err := config.LoadState(appStatePath)
appState, err := LoadState(appStatePath)
if err != nil {
appState = config.NewState()
config.SaveState(appStatePath, appState)
appState = NewState()
SaveState(appStatePath, appState)
}
if appState.ModeModel == nil {
appState.ModeModel = make(map[string]config.ModeModel)
appState.ModeModel = make(map[string]ModeModel)
}
if configInfo.Theme != "" {
appState.Theme = configInfo.Theme
}
themeEnv := os.Getenv("OPENCODE_THEME")
if themeEnv != "" {
appState.Theme = themeEnv
}
var modeIndex int
var mode *opencode.Mode
modeName := "build"
@@ -127,7 +128,7 @@ func New(
mode = &modes[modeIndex]
if mode.Model.ModelID != "" {
appState.ModeModel[mode.Name] = config.ModeModel{
appState.ModeModel[mode.Name] = ModeModel{
ProviderID: mode.Model.ProviderID,
ModelID: mode.Model.ModelID,
}
@@ -191,7 +192,7 @@ func (a *App) Key(commandName commands.CommandName) string {
return base(key) + muted(" "+command.Description)
}
func (a *App) SetClipboard(text string) tea.Cmd {
func SetClipboard(text string) tea.Cmd {
var cmds []tea.Cmd
cmds = append(cmds, func() tea.Msg {
clipboard.Write(clipboard.FmtText, []byte(text))
@@ -241,11 +242,7 @@ func (a *App) cycleMode(forward bool) (*App, tea.Cmd) {
}
a.State.Mode = a.Mode.Name
return a, func() tea.Msg {
a.SaveState()
return nil
}
return a, a.SaveState()
}
func (a *App) SwitchMode() (*App, tea.Cmd) {
@@ -346,7 +343,7 @@ func (a *App) InitializeProvider() tea.Cmd {
Model: *currentModel,
}))
if a.InitialPrompt != nil && *a.InitialPrompt != "" {
cmds = append(cmds, util.CmdHandler(SendMsg{Text: *a.InitialPrompt}))
cmds = append(cmds, util.CmdHandler(SendPrompt{Text: *a.InitialPrompt}))
}
return tea.Sequence(cmds...)
}
@@ -370,18 +367,20 @@ func (a *App) IsBusy() bool {
if len(a.Messages) == 0 {
return false
}
lastMessage := a.Messages[len(a.Messages)-1]
if casted, ok := lastMessage.Info.(opencode.AssistantMessage); ok {
return casted.Time.Completed == 0
}
return false
return true
}
func (a *App) SaveState() {
err := config.SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
func (a *App) SaveState() tea.Cmd {
return func() tea.Msg {
err := SaveState(a.StatePath, a.State)
if err != nil {
slog.Error("Failed to save state", "error", err)
}
return nil
}
}
@@ -459,11 +458,7 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) {
return session, nil
}
func (a *App) SendChatMessage(
ctx context.Context,
text string,
attachments []opencode.FilePartInputParam,
) (*App, tea.Cmd) {
func (a *App) SendPrompt(ctx context.Context, prompt Prompt) (*App, tea.Cmd) {
var cmds []tea.Cmd
if a.Session.ID == "" {
session, err := a.CreateSession(ctx)
@@ -474,65 +469,18 @@ func (a *App) SendChatMessage(
cmds = append(cmds, util.CmdHandler(SessionCreatedMsg{Session: session}))
}
message := opencode.UserMessage{
ID: id.Ascending(id.Message),
SessionID: a.Session.ID,
Role: opencode.UserMessageRoleUser,
Time: opencode.UserMessageTime{
Created: float64(time.Now().UnixMilli()),
},
}
messageID := id.Ascending(id.Message)
message := prompt.ToMessage(messageID, a.Session.ID)
parts := []opencode.PartUnion{opencode.TextPart{
ID: id.Ascending(id.Part),
MessageID: message.ID,
SessionID: a.Session.ID,
Type: opencode.TextPartTypeText,
Text: text,
}}
if len(attachments) > 0 {
for _, attachment := range attachments {
parts = append(parts, opencode.FilePart{
ID: id.Ascending(id.Part),
MessageID: message.ID,
SessionID: a.Session.ID,
Type: opencode.FilePartTypeFile,
Filename: attachment.Filename.Value,
Mime: attachment.Mime.Value,
URL: attachment.URL.Value,
})
}
}
a.Messages = append(a.Messages, Message{Info: message, Parts: parts})
a.Messages = append(a.Messages, message)
cmds = append(cmds, func() tea.Msg {
partsParam := []opencode.SessionChatParamsPartUnion{}
for _, part := range parts {
switch casted := part.(type) {
case opencode.TextPart:
partsParam = append(partsParam, opencode.TextPartInputParam{
ID: opencode.F(casted.ID),
Type: opencode.F(opencode.TextPartInputType(casted.Type)),
Text: opencode.F(casted.Text),
})
case opencode.FilePart:
partsParam = append(partsParam, opencode.FilePartInputParam{
ID: opencode.F(casted.ID),
Mime: opencode.F(casted.Mime),
Type: opencode.F(opencode.FilePartInputType(casted.Type)),
URL: opencode.F(casted.URL),
Filename: opencode.F(casted.Filename),
})
}
}
_, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{
Parts: opencode.F(partsParam),
MessageID: opencode.F(message.ID),
ProviderID: opencode.F(a.Provider.ID),
ModelID: opencode.F(a.Model.ID),
Mode: opencode.F(a.Mode.Name),
MessageID: opencode.F(messageID),
Parts: opencode.F(message.ToSessionChatParams()),
})
if err != nil {
errormsg := fmt.Sprintf("failed to send message: %v", err)
@@ -557,7 +505,6 @@ func (a *App) Cancel(ctx context.Context, sessionID string) error {
_, err := a.Client.Session.Abort(ctx, sessionID)
if err != nil {
slog.Error("Failed to cancel session", "error", err)
// status.Error(err.Error())
return err
}
return nil

View File

@@ -0,0 +1,232 @@
package app
import (
"time"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/attachment"
"github.com/sst/opencode/internal/id"
)
type Prompt struct {
Text string `toml:"text"`
Attachments []*attachment.Attachment `toml:"attachments"`
}
func (p Prompt) ToMessage(
messageID string,
sessionID string,
) Message {
message := opencode.UserMessage{
ID: messageID,
SessionID: sessionID,
Role: opencode.UserMessageRoleUser,
Time: opencode.UserMessageTime{
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{
ID: id.Ascending(id.Part),
MessageID: messageID,
SessionID: sessionID,
Type: opencode.TextPartTypeText,
Text: text,
}}
for _, attachment := range p.Attachments {
text := opencode.FilePartSourceText{
Start: int64(attachment.StartIndex),
End: int64(attachment.EndIndex),
Value: attachment.Display,
}
var source *opencode.FilePartSource
switch attachment.Type {
case "text":
continue
case "file":
fileSource, _ := attachment.GetFileSource()
source = &opencode.FilePartSource{
Text: text,
Path: fileSource.Path,
Type: opencode.FilePartSourceTypeFile,
}
case "symbol":
symbolSource, _ := attachment.GetSymbolSource()
source = &opencode.FilePartSource{
Text: text,
Path: symbolSource.Path,
Type: opencode.FilePartSourceTypeSymbol,
Kind: int64(symbolSource.Kind),
Name: symbolSource.Name,
Range: opencode.SymbolSourceRange{
Start: opencode.SymbolSourceRangeStart{
Line: float64(symbolSource.Range.Start.Line),
Character: float64(symbolSource.Range.Start.Char),
},
End: opencode.SymbolSourceRangeEnd{
Line: float64(symbolSource.Range.End.Line),
Character: float64(symbolSource.Range.End.Char),
},
},
}
}
parts = append(parts, opencode.FilePart{
ID: id.Ascending(id.Part),
MessageID: messageID,
SessionID: sessionID,
Type: opencode.FilePartTypeFile,
Filename: attachment.Filename,
Mime: attachment.MediaType,
URL: attachment.URL,
Source: *source,
})
}
return Message{
Info: message,
Parts: parts,
}
}
func (m Message) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{}
for _, part := range m.Parts {
switch p := part.(type) {
case opencode.TextPart:
parts = append(parts, opencode.TextPartInputParam{
ID: opencode.F(p.ID),
Type: opencode.F(opencode.TextPartInputTypeText),
Text: opencode.F(p.Text),
Synthetic: opencode.F(p.Synthetic),
Time: opencode.F(opencode.TextPartInputTimeParam{
Start: opencode.F(p.Time.Start),
End: opencode.F(p.Time.End),
}),
})
case opencode.FilePart:
var source opencode.FilePartSourceUnionParam
switch p.Source.Type {
case "file":
source = opencode.FileSourceParam{
Type: opencode.F(opencode.FileSourceTypeFile),
Path: opencode.F(p.Source.Path),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(p.Source.Text.Start)),
End: opencode.F(int64(p.Source.Text.End)),
Value: opencode.F(p.Source.Text.Value),
}),
}
case "symbol":
source = opencode.SymbolSourceParam{
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
Path: opencode.F(p.Source.Path),
Name: opencode.F(p.Source.Name),
Kind: opencode.F(p.Source.Kind),
Range: opencode.F(opencode.SymbolSourceRangeParam{
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Line)),
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).Start.Character)),
}),
End: opencode.F(opencode.SymbolSourceRangeEndParam{
Line: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Line)),
Character: opencode.F(float64(p.Source.Range.(opencode.SymbolSourceRange).End.Character)),
}),
}),
Text: opencode.F(opencode.FilePartSourceTextParam{
Value: opencode.F(p.Source.Text.Value),
Start: opencode.F(p.Source.Text.Start),
End: opencode.F(p.Source.Text.End),
}),
}
}
parts = append(parts, opencode.FilePartInputParam{
ID: opencode.F(p.ID),
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(p.Mime),
URL: opencode.F(p.URL),
Filename: opencode.F(p.Filename),
Source: opencode.F(source),
})
}
}
return parts
}
func (p Prompt) ToSessionChatParams() []opencode.SessionChatParamsPartUnion {
parts := []opencode.SessionChatParamsPartUnion{
opencode.TextPartInputParam{
Type: opencode.F(opencode.TextPartInputTypeText),
Text: opencode.F(p.Text),
},
}
for _, att := range p.Attachments {
filePart := opencode.FilePartInputParam{
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(att.MediaType),
URL: opencode.F(att.URL),
Filename: opencode.F(att.Filename),
}
switch att.Type {
case "file":
if fs, ok := att.GetFileSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.FileSourceParam{
Type: opencode.F(opencode.FileSourceTypeFile),
Path: opencode.F(fs.Path),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
case "symbol":
if ss, ok := att.GetSymbolSource(); ok {
filePart.Source = opencode.F(
opencode.FilePartSourceUnionParam(opencode.SymbolSourceParam{
Type: opencode.F(opencode.SymbolSourceTypeSymbol),
Path: opencode.F(ss.Path),
Name: opencode.F(ss.Name),
Kind: opencode.F(int64(ss.Kind)),
Range: opencode.F(opencode.SymbolSourceRangeParam{
Start: opencode.F(opencode.SymbolSourceRangeStartParam{
Line: opencode.F(float64(ss.Range.Start.Line)),
Character: opencode.F(float64(ss.Range.Start.Char)),
}),
End: opencode.F(opencode.SymbolSourceRangeEndParam{
Line: opencode.F(float64(ss.Range.End.Line)),
Character: opencode.F(float64(ss.Range.End.Char)),
}),
}),
Text: opencode.F(opencode.FilePartSourceTextParam{
Start: opencode.F(int64(att.StartIndex)),
End: opencode.F(int64(att.EndIndex)),
Value: opencode.F(att.Display),
}),
}),
)
}
}
parts = append(parts, filePart)
}
return parts
}

View File

@@ -1,4 +1,4 @@
package config
package app
import (
"bufio"
@@ -30,6 +30,7 @@ type State struct {
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
MessagesRight bool `toml:"messages_right"`
SplitDiff bool `toml:"split_diff"`
MessageHistory []Prompt `toml:"message_history"`
}
func NewState() *State {
@@ -38,6 +39,7 @@ func NewState() *State {
Mode: "build",
ModeModel: make(map[string]ModeModel),
RecentlyUsedModels: make([]ModelUsage, 0),
MessageHistory: make([]Prompt, 0),
}
}
@@ -78,6 +80,13 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) {
}
}
func (s *State) AddPromptToHistory(prompt Prompt) {
s.MessageHistory = append([]Prompt{prompt}, s.MessageHistory...)
if len(s.MessageHistory) > 50 {
s.MessageHistory = s.MessageHistory[:50]
}
}
// SaveState writes the provided Config struct to the specified TOML file.
// It will create the file if it doesn't exist, or overwrite it if it does.
func SaveState(filePath string, state *State) error {

View File

@@ -0,0 +1,77 @@
package attachment
import (
"github.com/google/uuid"
)
type TextSource struct {
Value string `toml:"value"`
}
type FileSource struct {
Path string `toml:"path"`
Mime string `toml:"mime"`
Data []byte `toml:"data,omitempty"` // Optional for image data
}
type SymbolSource struct {
Path string `toml:"path"`
Name string `toml:"name"`
Kind int `toml:"kind"`
Range SymbolRange `toml:"range"`
}
type SymbolRange struct {
Start Position `toml:"start"`
End Position `toml:"end"`
}
type Position struct {
Line int `toml:"line"`
Char int `toml:"char"`
}
type Attachment struct {
ID string `toml:"id"`
Type string `toml:"type"`
Display string `toml:"display"`
URL string `toml:"url"`
Filename string `toml:"filename"`
MediaType string `toml:"media_type"`
StartIndex int `toml:"start_index"`
EndIndex int `toml:"end_index"`
Source any `toml:"source,omitempty"`
}
// NewAttachment creates a new attachment with a unique ID
func NewAttachment() *Attachment {
return &Attachment{
ID: uuid.NewString(),
}
}
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
func (a *Attachment) GetFileSource() (*FileSource, bool) {
if a.Type != "file" {
return nil, false
}
fs, ok := a.Source.(*FileSource)
return fs, ok
}
// GetSymbolSource returns the source as SymbolSource if the attachment is a symbol type
func (a *Attachment) GetSymbolSource() (*SymbolSource, bool) {
if a.Type != "symbol" {
return nil, false
}
ss, ok := a.Source.(*SymbolSource)
return ss, ok
}

View File

@@ -349,6 +349,9 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
continue
}
if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
if keybind == "none" {
continue
}
command.Keybindings = parseBindings(keybind)
}
registry[command.Name] = command

View File

@@ -16,6 +16,7 @@ import (
"github.com/google/uuid"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/attachment"
"github.com/sst/opencode/internal/clipboard"
"github.com/sst/opencode/internal/commands"
"github.com/sst/opencode/internal/components/dialog"
@@ -43,6 +44,7 @@ type EditorComponent interface {
SetValueWithAttachments(value string)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
RestoreFromHistory(index int)
}
type editorComponent struct {
@@ -52,6 +54,9 @@ type editorComponent struct {
spinner spinner.Model
interruptKeyInDebounce bool
exitKeyInDebounce bool
historyIndex int // -1 means current (not in history)
currentText string // Store current text when navigating history
pasteCounter int
}
func (m *editorComponent) Init() tea.Cmd {
@@ -70,6 +75,49 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case tea.KeyPressMsg:
// Handle up/down arrows for history navigation
switch msg.String() {
case "up":
// Only navigate history if cursor is at the first line and column
if m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0 && len(m.app.State.MessageHistory) > 0 {
if m.historyIndex == -1 {
// Save current text before entering history
m.currentText = m.textarea.Value()
m.textarea.MoveToBegin()
}
// Move up in history (older messages)
if m.historyIndex < len(m.app.State.MessageHistory)-1 {
m.historyIndex++
m.RestoreFromHistory(m.historyIndex)
m.textarea.MoveToBegin()
}
return m, nil
}
case "down":
// Only navigate history if cursor is at the last line and we're in history navigation
if m.textarea.IsCursorAtEnd() && m.historyIndex > -1 {
// Move down in history (newer messages)
m.historyIndex--
if m.historyIndex == -1 {
// Restore current text
m.textarea.Reset()
m.textarea.SetValue(m.currentText)
m.currentText = ""
} else {
m.RestoreFromHistory(m.historyIndex)
m.textarea.MoveToEnd()
}
return m, nil
} else if m.historyIndex > -1 {
m.textarea.MoveToEnd()
return m, nil
}
}
// Reset history navigation on any other input
if m.historyIndex != -1 {
m.historyIndex = -1
m.currentText = ""
}
// Maximize editor responsiveness for printable characters
if msg.Text != "" {
m.textarea, cmd = m.textarea.Update(msg)
@@ -82,12 +130,22 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text, err := strconv.Unquote(`"` + text + `"`)
if err != nil {
slog.Error("Failed to unquote text", "error", err)
m.textarea.InsertRunesFromUserInput([]rune(msg))
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
if _, err := os.Stat(text); err != nil {
slog.Error("Failed to paste file", "error", err)
m.textarea.InsertRunesFromUserInput([]rune(msg))
text := string(msg)
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
@@ -95,7 +153,11 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
attachment := m.createAttachmentFromFile(filePath)
if attachment == nil {
m.textarea.InsertRunesFromUserInput([]rune(msg))
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(msg))
}
return m, nil
}
@@ -103,11 +165,16 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertString(" ")
case tea.ClipboardMsg:
text := string(msg)
m.textarea.InsertRunesFromUserInput([]rune(text))
// Check if the pasted text is long and should be summarized
if m.shouldSummarizePastedText(text) {
m.handleLongPaste(text)
} else {
m.textarea.InsertRunesFromUserInput([]rune(text))
}
case dialog.ThemeSelectedMsg:
m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
return m, m.textarea.Focus()
case dialog.CompletionSelectedMsg:
switch msg.Item.ProviderID {
case "commands":
@@ -151,12 +218,28 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
symbol := msg.Item.RawData.(opencode.Symbol)
parts := strings.Split(symbol.Name, ".")
lastPart := parts[len(parts)-1]
attachment := &textarea.Attachment{
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "symbol",
Display: "@" + lastPart,
URL: msg.Item.Value,
Filename: lastPart,
MediaType: "text/plain",
Source: &attachment.SymbolSource{
Path: symbol.Location.Uri,
Name: symbol.Name,
Kind: int(symbol.Kind),
Range: attachment.SymbolRange{
Start: attachment.Position{
Line: int(symbol.Location.Range.Start.Line),
Char: int(symbol.Location.Range.Start.Character),
},
End: attachment.Position{
Line: int(symbol.Location.Range.End.Line),
Char: int(symbol.Location.Range.End.Character),
},
},
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
@@ -311,28 +394,25 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
}
var cmds []tea.Cmd
attachments := m.textarea.GetAttachments()
fileParts := make([]opencode.FilePartInputParam, 0)
for _, attachment := range attachments {
fileParts = append(fileParts, opencode.FilePartInputParam{
Type: opencode.F(opencode.FilePartInputTypeFile),
Mime: opencode.F(attachment.MediaType),
URL: opencode.F(attachment.URL),
Filename: opencode.F(attachment.Filename),
})
}
prompt := app.Prompt{Text: value, Attachments: attachments}
m.app.State.AddPromptToHistory(prompt)
cmds = append(cmds, m.app.SaveState())
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts}))
cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
m.textarea.Reset()
m.historyIndex = -1
m.currentText = ""
m.pasteCounter = 0
return m, nil
}
@@ -342,12 +422,18 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
attachmentCount := len(m.textarea.GetAttachments())
attachmentIndex := attachmentCount + 1
base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
attachment := &textarea.Attachment{
attachment := &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
MediaType: "image/png",
Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
Source: &attachment.FileSource{
Path: fmt.Sprintf("image-%d.png", attachmentIndex),
Mime: "image/png",
Data: imageBytes,
},
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
@@ -356,7 +442,13 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
textBytes := clipboard.Read(clipboard.FmtText)
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
}
@@ -425,6 +517,48 @@ func (m *editorComponent) getExitKeyText() string {
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 {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
@@ -485,11 +619,44 @@ func NewEditorComponent(app *app.App) EditorComponent {
textarea: ta,
spinner: s,
interruptKeyInDebounce: false,
historyIndex: -1,
pasteCounter: 0,
}
return m
}
// RestoreFromHistory restores a message from history at the given index
func (m *editorComponent) RestoreFromHistory(index int) {
if index < 0 || index >= len(m.app.State.MessageHistory) {
return
}
entry := m.app.State.MessageHistory[index]
m.textarea.Reset()
m.textarea.SetValue(entry.Text)
// Sort attachments by start index in reverse order (process from end to beginning)
// This prevents index shifting issues
attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
copy(attachmentsCopy, entry.Attachments)
for i := 0; i < len(attachmentsCopy)-1; i++ {
for j := i + 1; j < len(attachmentsCopy); j++ {
if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
}
}
}
for _, att := range attachmentsCopy {
m.textarea.SetCursorColumn(att.StartIndex)
m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
m.textarea.InsertAttachment(att)
}
}
func getMediaTypeFromExtension(ext string) string {
switch strings.ToLower(ext) {
case ".jpg":
@@ -503,18 +670,27 @@ func getMediaTypeFromExtension(ext string) string {
}
}
func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.Attachment {
func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
ext := strings.ToLower(filepath.Ext(filePath))
mediaType := getMediaTypeFromExtension(ext)
absolutePath := filePath
if !filepath.IsAbs(filePath) {
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
// For text files, create a simple file reference
if mediaType == "text/plain" {
return &textarea.Attachment{
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", filePath),
Filename: filePath,
MediaType: mediaType,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
},
}
}
@@ -533,25 +709,38 @@ func (m *editorComponent) createAttachmentFromFile(filePath string) *textarea.At
if strings.HasPrefix(mediaType, "image/") {
label = "Image"
}
return &textarea.Attachment{
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
MediaType: mediaType,
Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
URL: url,
Filename: filePath,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
Data: fileBytes,
},
}
}
func (m *editorComponent) createAttachmentFromPath(filePath string) *textarea.Attachment {
func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
extension := filepath.Ext(filePath)
mediaType := getMediaTypeFromExtension(extension)
return &textarea.Attachment{
absolutePath := filePath
if !filepath.IsAbs(filePath) {
absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
}
return &attachment.Attachment{
ID: uuid.NewString(),
Type: "file",
Display: "@" + filePath,
URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
Filename: filePath,
MediaType: mediaType,
Source: &attachment.FileSource{
Path: absolutePath,
Mime: mediaType,
},
}
}

View File

@@ -196,16 +196,20 @@ func renderText(
case opencode.UserMessage:
ts = time.UnixMilli(int64(casted.Time.Created))
base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor)
words := strings.Fields(text)
for i, word := range words {
if strings.HasPrefix(word, "@") {
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
} else {
words[i] = base.Render(word + " ")
}
}
text = strings.Join(words, "")
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 {
if strings.HasPrefix(word, "@") {
words[i] = base.Foreground(t.Secondary()).Render(word + " ")
} else {
words[i] = base.Render(word + " ")
}
}
lines[i] = strings.Join(words, "")
}
text = strings.Join(lines, "\n")
content = base.Width(width - 6).Render(text)
}

View File

@@ -3,10 +3,12 @@ package chat
import (
"fmt"
"log/slog"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
@@ -36,6 +38,7 @@ type messagesComponent struct {
app *app.App
header string
viewport viewport.Model
clipboard []string
cache *PartCache
loading bool
showToolDetails bool
@@ -44,6 +47,43 @@ type messagesComponent struct {
tail bool
partCount int
lineCount int
selection *selection
}
type selection struct {
startX int
endX int
startY int
endY int
}
func (s selection) coords(offset int) *selection {
// selecting backwards
if s.startY > s.endY && s.endY >= 0 {
return &selection{
startX: max(0, s.endX-1),
startY: s.endY - offset,
endX: s.startX + 1,
endY: s.startY - offset,
}
}
// selecting backwards same line
if s.startY == s.endY && s.startX >= s.endX {
return &selection{
startY: s.startY - offset,
startX: max(0, s.endX-1),
endY: s.endY - offset,
endX: s.startX + 1,
}
}
return &selection{
startX: s.startX,
startY: s.startY - offset,
endX: s.endX,
endY: s.endY - offset,
}
}
type ToggleToolDetailsMsg struct{}
@@ -57,6 +97,43 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
defer measure("from", fmt.Sprintf("%T", msg))
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.MouseClickMsg:
slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
y := msg.Y + m.viewport.YOffset
if y > 0 {
m.selection = &selection{
startY: y,
startX: msg.X,
endY: -1,
endX: -1,
}
slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY))
return m, m.renderView()
}
case tea.MouseMotionMsg:
if m.selection != nil {
m.selection = &selection{
startX: m.selection.startX,
startY: m.selection.startY,
endX: msg.X + 1,
endY: msg.Y + m.viewport.YOffset,
}
return m, m.renderView()
}
case tea.MouseReleaseMsg:
if m.selection != nil && len(m.clipboard) > 0 {
content := strings.Join(m.clipboard, "\n")
m.selection = nil
m.clipboard = []string{}
return m, tea.Sequence(
m.renderView(),
app.SetClipboard(content),
toast.NewSuccessToast("Copied to clipboard"),
)
}
case tea.WindowSizeMsg:
effectiveWidth := msg.Width - 4
// Clear cache on resize since width affects rendering
@@ -68,7 +145,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.viewport.SetWidth(m.width)
m.loading = true
return m, m.renderView()
case app.SendMsg:
case app.SendPrompt:
m.viewport.GotoBottom()
m.tail = true
return m, nil
@@ -101,6 +178,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.partCount = msg.partCount
m.lineCount = msg.lineCount
m.rendering = false
m.clipboard = msg.clipboard
m.loading = false
m.tail = m.viewport.AtBottom()
m.viewport = msg.viewport
@@ -120,6 +198,7 @@ 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
@@ -154,6 +233,13 @@ func (m *messagesComponent) renderView() tea.Cmd {
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 {
var content string
var cached bool
@@ -205,14 +291,18 @@ func (m *messagesComponent) renderView() tea.Cmd {
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)
if !cached {
content = renderText(
m.app,
message.Info,
part.Text,
m.app.Config.Username,
author,
m.showToolDetails,
width,
files,
@@ -234,7 +324,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
case opencode.AssistantMessage:
messageMeasure := util.Measure("messages.Render")
hasTextPart := false
for partIndex, p := range message.Parts {
switch part := p.(type) {
@@ -366,7 +455,6 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
}
messageMeasure()
}
error := ""
@@ -403,7 +491,48 @@ func (m *messagesComponent) renderView() tea.Cmd {
}
}
content := "\n" + strings.Join(blocks, "\n\n")
final := []string{}
clipboard := []string{}
var selection *selection
if m.selection != nil {
selection = m.selection.coords(lipgloss.Height(header) + 1)
}
for _, block := range blocks {
lines := strings.Split(block, "\n")
for index, line := range lines {
if selection == nil || index == 0 || index == len(lines)-1 {
final = append(final, line)
continue
}
y := len(final)
if y >= selection.startY && y <= selection.endY {
left := 3
if y == selection.startY {
left = selection.startX - 2
}
left = max(3, left)
width := ansi.StringWidth(line)
right := width - 1
if y == selection.endY {
right = min(selection.endX-2, right)
}
prefix := ansi.Cut(line, 0, left)
middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
suffix := ansi.Cut(line, left+len(middle), width)
clipboard = append(clipboard, middle)
line = prefix + styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Render(ansi.Strip(middle)) + suffix
}
final = append(final, line)
}
y := len(final)
if selection != nil && y >= selection.startY && y < selection.endY {
clipboard = append(clipboard, "")
}
final = append(final, "")
}
content := "\n" + strings.Join(final, "\n")
viewport.SetHeight(m.height - lipgloss.Height(header))
viewport.SetContent(content)
if tail {
@@ -412,6 +541,7 @@ func (m *messagesComponent) renderView() tea.Cmd {
return renderCompleteMsg{
header: header,
clipboard: clipboard,
viewport: viewport,
partCount: partCount,
lineCount: lineCount,
@@ -463,7 +593,11 @@ func (m *messagesComponent) renderHeader() string {
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
headerText := util.ToMarkdown("# "+m.app.Session.Title, headerWidth-len(sessionInfo), t.Background())
headerText := util.ToMarkdown(
"# "+m.app.Session.Title,
headerWidth-len(sessionInfo),
t.Background(),
)
var items []layout.FlexItem
if shareEnabled {
@@ -634,7 +768,7 @@ func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
return m, nil
}
var cmds []tea.Cmd
cmds = append(cmds, m.app.SetClipboard(lastTextPart.Text))
cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
return m, tea.Batch(cmds...)
}

View File

@@ -127,9 +127,9 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if item, ok := msg.Item.(modelItem); ok {
if m.isModelInRecentSection(item.model, msg.Index) {
m.app.State.RemoveModelFromRecentlyUsed(item.model.Provider.ID, item.model.Model.ID)
m.app.SaveState()
items := m.buildDisplayList(m.searchDialog.GetQuery())
m.searchDialog.SetItems(items)
return m, m.app.SaveState()
}
}
return m, nil
@@ -425,7 +425,8 @@ func (m *modelDialog) isModelInRecentSection(model ModelWithProvider, index int)
if index >= 1 && index <= len(recentModels) {
if index-1 < len(recentModels) {
recentModel := recentModels[index-1]
return recentModel.Provider.ID == model.Provider.ID && recentModel.Model.ID == model.Model.ID
return recentModel.Provider.ID == model.Provider.ID &&
recentModel.Model.ID == model.Model.ID
}
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/x/ansi"
rw "github.com/mattn/go-runewidth"
"github.com/rivo/uniseg"
"github.com/sst/opencode/internal/attachment"
)
const (
@@ -32,15 +33,6 @@ const (
maxLines = 10000
)
// Attachment represents a special object within the text, distinct from regular characters.
type Attachment struct {
ID string // A unique identifier for this attachment instance
Display string // e.g., "@filename.txt"
URL string
Filename string
MediaType string
}
// Helper functions for converting between runes and any slices
// runesToInterfaces converts a slice of runes to a slice of interfaces
@@ -59,7 +51,7 @@ func interfacesToRunes(items []any) []rune {
switch val := item.(type) {
case rune:
result = append(result, val)
case *Attachment:
case *attachment.Attachment:
result = append(result, []rune(val.Display)...)
}
}
@@ -80,7 +72,7 @@ func interfacesToString(items []any) string {
switch val := item.(type) {
case rune:
s.WriteRune(val)
case *Attachment:
case *attachment.Attachment:
s.WriteString(val.Display)
}
}
@@ -90,7 +82,7 @@ func interfacesToString(items []any) string {
// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment.
// This allows for proper highlighting even when the cursor is technically at the position
// after the attachment object in the underlying slice.
func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
func (m Model) isAttachmentAtCursor() (*attachment.Attachment, int, int) {
if m.row >= len(m.value) {
return nil, -1, -1
}
@@ -104,7 +96,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
// Check if the cursor is at the same index as an attachment.
if col < len(row) {
if att, ok := row[col].(*Attachment); ok {
if att, ok := row[col].(*attachment.Attachment); ok {
return att, col, col
}
}
@@ -112,7 +104,7 @@ func (m Model) isAttachmentAtCursor() (*Attachment, int, int) {
// Check if the cursor is immediately after an attachment. This is a common
// state, for example, after just inserting one.
if col > 0 && col <= len(row) {
if att, ok := row[col-1].(*Attachment); ok {
if att, ok := row[col-1].(*attachment.Attachment); ok {
return att, col - 1, col - 1
}
}
@@ -132,7 +124,7 @@ func (m Model) renderLineWithAttachments(
switch val := item.(type) {
case rune:
s.WriteString(style.Render(string(val)))
case *Attachment:
case *attachment.Attachment:
// Check if this is the attachment the cursor is currently on
if currentAttachment != nil && currentAttachment.ID == val.ID {
// Cursor is on this attachment, highlight it
@@ -435,7 +427,7 @@ func (w line) Hash() string {
switch v := item.(type) {
case rune:
s.WriteRune(v)
case *Attachment:
case *attachment.Attachment:
s.WriteString(v.ID)
}
}
@@ -661,7 +653,7 @@ func (m *Model) InsertRune(r rune) {
}
// InsertAttachment inserts an attachment at the cursor position.
func (m *Model) InsertAttachment(att *Attachment) {
func (m *Model) InsertAttachment(att *attachment.Attachment) {
if m.CharLimit > 0 {
availSpace := m.CharLimit - m.Length()
// If the char limit's been reached, cancel.
@@ -716,16 +708,36 @@ func (m *Model) CurrentRowLength() int {
return len(m.value[m.row])
}
// GetAttachments returns all attachments in the textarea.
func (m Model) GetAttachments() []*Attachment {
var attachments []*Attachment
for _, row := range m.value {
// GetAttachments returns all attachments in the textarea with accurate position indices.
func (m Model) GetAttachments() []*attachment.Attachment {
var attachments []*attachment.Attachment
position := 0 // Track absolute position in the text
for rowIdx, row := range m.value {
colPosition := 0 // Track position within the current row
for _, item := range row {
if att, ok := item.(*Attachment); ok {
attachments = append(attachments, att)
switch v := item.(type) {
case *attachment.Attachment:
// Clone the attachment to avoid modifying the original
att := *v
att.StartIndex = position + colPosition
att.EndIndex = position + colPosition + len(v.Display)
attachments = append(attachments, &att)
colPosition += len(v.Display)
case rune:
colPosition++
}
}
// Add newline character position (except for last row)
if rowIdx < len(m.value)-1 {
position += colPosition + 1 // +1 for newline
} else {
position += colPosition
}
}
return attachments
}
@@ -829,7 +841,7 @@ func (m Model) Value() string {
switch val := item.(type) {
case rune:
v.WriteRune(val)
case *Attachment:
case *attachment.Attachment:
v.WriteString(val.Display)
}
}
@@ -847,7 +859,7 @@ func (m *Model) Length() int {
switch val := item.(type) {
case rune:
l += rw.RuneWidth(val)
case *Attachment:
case *attachment.Attachment:
l += uniseg.StringWidth(val.Display)
}
}
@@ -911,7 +923,7 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
@@ -952,7 +964,7 @@ func (m *Model) CursorDown() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -988,7 +1000,7 @@ func (m *Model) CursorDown() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1034,7 +1046,7 @@ func (m *Model) CursorUp() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1070,7 +1082,7 @@ func (m *Model) CursorUp() {
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
@@ -1111,6 +1123,10 @@ func (m *Model) CursorEnd() {
m.SetCursorColumn(len(m.value[m.row]))
}
func (m *Model) IsCursorAtEnd() bool {
return m.CursorColumn() == len(m.value[m.row])
}
// Focused returns the focus state on the model.
func (m Model) Focused() bool {
return m.focus
@@ -1414,14 +1430,14 @@ func (m Model) Width() int {
return m.width
}
// moveToBegin moves the cursor to the beginning of the input.
func (m *Model) moveToBegin() {
// MoveToBegin moves the cursor to the beginning of the input.
func (m *Model) MoveToBegin() {
m.row = 0
m.SetCursorColumn(0)
}
// moveToEnd moves the cursor to the end of the input.
func (m *Model) moveToEnd() {
// MoveToEnd moves the cursor to the end of the input.
func (m *Model) MoveToEnd() {
m.row = len(m.value) - 1
m.SetCursorColumn(len(m.value[m.row]))
}
@@ -1610,9 +1626,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft()
case key.Matches(msg, m.KeyMap.InputBegin):
m.moveToBegin()
m.MoveToBegin()
case key.Matches(msg, m.KeyMap.InputEnd):
m.moveToEnd()
m.MoveToEnd()
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
m.lowercaseRight()
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
@@ -1725,7 +1741,7 @@ func (m Model) View() string {
} else if lineInfo.ColumnOffset < len(wrappedLine) {
// Render the item under the cursor
item := wrappedLine[lineInfo.ColumnOffset]
if att, ok := item.(*Attachment); ok {
if att, ok := item.(*attachment.Attachment); ok {
// Item at cursor is an attachment. Render it with the selection style.
// This becomes the "cursor" visually.
s.WriteString(m.Styles.SelectedAttachment.Render(att.Display))
@@ -2023,7 +2039,7 @@ func itemWidth(item any) int {
switch v := item.(type) {
case rune:
return rw.RuneWidth(v)
case *Attachment:
case *attachment.Attachment:
return uniseg.StringWidth(v.Display)
}
return 0
@@ -2052,7 +2068,7 @@ func wrapInterfaces(content []any, width int) [][]any {
isSpace = true
}
itemW = rw.RuneWidth(r)
} else if att, ok := item.(*Attachment); ok {
} else if att, ok := item.(*attachment.Attachment); ok {
itemW = uniseg.StringWidth(att.Display)
}

View File

@@ -25,7 +25,6 @@ import (
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/components/status"
"github.com/sst/opencode/internal/components/toast"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -331,9 +330,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case error:
return a, toast.NewErrorToast(msg.Error())
case app.SendMsg:
case app.SendPrompt:
a.showCompletionDialog = false
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
a.app, cmd = a.app.SendPrompt(context.Background(), msg)
cmds = append(cmds, cmd)
case app.SetEditorContentMsg:
// Set the editor content without sending
@@ -467,15 +466,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case app.ModelSelectedMsg:
a.app.Provider = &msg.Provider
a.app.Model = &msg.Model
a.app.State.ModeModel[a.app.Mode.Name] = config.ModeModel{
a.app.State.ModeModel[a.app.Mode.Name] = app.ModeModel{
ProviderID: msg.Provider.ID,
ModelID: msg.Model.ID,
}
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
cmds = append(cmds, a.app.SaveState())
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
a.app.SaveState()
cmds = append(cmds, a.app.SaveState())
case toast.ShowToastMsg:
tm, cmd := a.toastManager.Update(msg)
a.toastManager = tm
@@ -829,7 +828,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return a, toast.NewErrorToast("Failed to share session")
}
shareUrl := response.Share.URL
cmds = append(cmds, a.app.SetClipboard(shareUrl))
cmds = append(cmds, app.SetClipboard(shareUrl))
cmds = append(cmds, toast.NewSuccessToast("Share URL copied to clipboard!"))
case commands.SessionUnshareCommand:
if a.app.Session.ID == "" {
@@ -891,7 +890,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
tmpfile.Close()
// Open in editor
c := exec.Command(editor, tmpfile.Name())
parts := strings.Fields(editor)
c := exec.Command(parts[0], append(parts[1:], tmpfile.Name())...) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
@@ -927,9 +927,9 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
cmds = append(cmds, cmd)
case commands.FileDiffToggleCommand:
a.fileViewer, cmd = a.fileViewer.ToggleDiff()
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
a.app.SaveState()
cmds = append(cmds, cmd)
a.app.State.SplitDiff = a.fileViewer.DiffStyle() == fileviewer.DiffStyleSplit
cmds = append(cmds, a.app.SaveState())
case commands.FileSearchCommand:
return a, nil
case commands.ProjectInitCommand:
@@ -1000,7 +1000,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
case commands.MessagesLayoutToggleCommand:
a.messagesRight = !a.messagesRight
a.app.State.MessagesRight = a.messagesRight
a.app.SaveState()
cmds = append(cmds, a.app.SaveState())
case commands.MessagesCopyCommand:
updated, cmd := a.messages.CopyLastMessage()
a.messages = updated.(chat.MessagesComponent)

View File

@@ -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
Logs are written to:

View File

@@ -9,7 +9,6 @@ opencode has a list of keybinds that you can customize through the opencode conf
{
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"app_help": "<leader>h",
"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",
"project_init": "<leader>i",
"file_list": "<leader>f",
"file_close": "esc",
"file_diff_toggle": "<leader>v",
"input_clear": "ctrl+c",
"input_paste": "ctrl+v",
"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_half_page_up": "ctrl+alt+u",
"messages_half_page_down": "ctrl+alt+d",
"messages_previous": "ctrl+up",
"messages_next": "ctrl+down",
"messages_first": "ctrl+g",
"messages_last": "ctrl+alt+g",
"messages_layout_toggle": "<leader>p",
"messages_copy": "<leader>y",
"messages_revert": "<leader>r",
"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`.
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",
}
}
```

View File

@@ -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`.
---
### 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).

View File

@@ -62,7 +62,7 @@
"test": "vscode-test"
},
"devDependencies": {
"@types/vscode": "^1.102.0",
"@types/vscode": "^1.94.0",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",
"@typescript-eslint/eslint-plugin": "^8.31.1",