Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d84dadc0c | ||
|
|
45c0578b22 | ||
|
|
1ded535175 | ||
|
|
d957ab849b | ||
|
|
4b2e52c834 | ||
|
|
6867658c0f | ||
|
|
b8620395cb | ||
|
|
90d37c98f8 | ||
|
|
c9a40917c2 | ||
|
|
0aa0e740cd | ||
|
|
bb17d14665 |
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user