Compare commits

...
14 Commits
Author SHA1 Message Date
Dax Raad ba5be6b625 make LSP lazy again
publish / publish (push) Has been cancelled
2025-07-10 09:37:40 -04:00
adamdottv f95c3f4177 fix(tui): fouc in textarea on app load 2025-07-10 08:20:17 -05:00
adamdottv d2b1307bff fix(tui): textarea cursor sync issues with attachments 2025-07-10 07:49:36 -05:00
adamdottv b40ba32adc fix(tui): textarea issues 2025-07-10 07:38:57 -05:00
GitHub Action ce0cebb7d7 ignore: update download stats 2025-07-10 2025-07-10 12:04:15 +00:00
Dax Raad f478f89a68 temporary grok 4 patch
publish / publish (push) Has been cancelled
2025-07-10 07:57:55 -04:00
Dax Raad 85d95f0f2b disable lsp on non-git folders 2025-07-10 07:39:02 -04:00
Dax Raad 1515efc77c fix session is busy error
publish / publish (push) Has been cancelled
2025-07-10 07:27:03 -04:00
Josh MedeskiandGitHub 6d393759e1 feat(tui): subsitute cwd home path on status bar (#808) 2025-07-10 06:12:19 -05:00
Adi YeroslavandGitHub a1701678cd feat(tui): /editor - change the auto-send behavior to put content in input box instead (#827) 2025-07-10 05:57:52 -05:00
Timo ClasenandGitHub c411a26d6f feat(tui): hide cost if using subscription model (#828) 2025-07-10 05:56:36 -05:00
adamdottv 85dbfeb314 feat(tui): @symbol attachments 2025-07-10 05:53:00 -05:00
Dax Raad 085c0e4e2b respect go.work when spawning LSP
publish / publish (push) Has been cancelled
2025-07-09 22:54:47 -04:00
Dax Raad 8404a97c3e better detection of prettier formatter
publish / publish (push) Has been cancelled
2025-07-09 22:37:31 -04:00
17 changed files with 752 additions and 270 deletions
+12 -11
View File
@@ -1,13 +1,14 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | ---------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ---------------- | --------------- | ----------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-10 | 43,796 (+5,744) | 71,402 (+6,934) | 115,198 (+12,678) |
+14 -1
View File
@@ -5,7 +5,8 @@ import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) => yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).command(DocumentSymbolsCommand).demandCommand(),
async handler() {},
})
@@ -31,3 +32,15 @@ export const SymbolsCommand = cmd({
})
},
})
export const DocumentSymbolsCommand = cmd({
command: "document-symbols <uri>",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
using _ = Log.Default.time("document-symbols")
const results = await LSP.documentSymbol(args.uri)
console.log(JSON.stringify(results, null, 2))
})
},
})
+8 -17
View File
@@ -1,5 +1,7 @@
import { App } from "../app/app"
import { BunProc } from "../bun"
import { Filesystem } from "../util/filesystem"
import path from "path"
export interface Info {
name: string
@@ -29,7 +31,7 @@ export const mix: Info = {
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "run", "prettier", "--write", "$FILE"],
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -62,23 +64,12 @@ export const prettier: Info = {
".gql",
],
async enabled() {
// this is more complicated because we only want to use prettier if it's
// being used with the current project
try {
const proc = Bun.spawn({
cmd: [BunProc.which(), "run", "prettier", "--version"],
cwd: App.info().path.cwd,
env: {
BUN_BE_BUN: "1",
},
stdout: "ignore",
stderr: "ignore",
})
const exit = await proc.exited
return exit === 0
} catch {
return false
const app = App.info()
const nms = await Filesystem.findUp("node_modules", app.path.cwd, app.path.root)
for (const item of nms) {
if (await Bun.file(path.join(item, ".bin", "prettier")).exists()) return true
}
return false
},
}
-1
View File
@@ -184,7 +184,6 @@ export namespace LSPClient {
},
}
if (input.server.onInitialized) input.server.onInitialized(result)
l.info("initialized")
return result
+122 -44
View File
@@ -4,27 +4,33 @@ import { LSPClient } from "./client"
import path from "path"
import { LSPServer } from "./server"
import { z } from "zod"
import { Filesystem } from "../util/filesystem"
export namespace LSP {
const log = Log.create({ service: "lsp" })
export const Range = z
.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
})
.openapi({
ref: "Range",
})
export type Range = z.infer<typeof Range>
export const Symbol = z
.object({
name: z.string(),
kind: z.number(),
location: z.object({
uri: z.string(),
range: z.object({
start: z.object({
line: z.number(),
character: z.number(),
}),
end: z.object({
line: z.number(),
character: z.number(),
}),
}),
range: Range,
}),
})
.openapi({
@@ -32,38 +38,25 @@ export namespace LSP {
})
export type Symbol = z.infer<typeof Symbol>
export const DocumentSymbol = z
.object({
name: z.string(),
detail: z.string().optional(),
kind: z.number(),
range: Range,
selectionRange: Range,
})
.openapi({
ref: "DocumentSymbol",
})
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
const state = App.state(
"lsp",
async (app) => {
log.info("initializing")
async () => {
const clients: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
const roots = await server.roots(app)
for (const root of roots) {
if (!Filesystem.overlaps(app.path.cwd, root)) continue
log.info("", {
root,
serverID: server.id,
})
const handle = await server.spawn(App.info(), root)
if (!handle) break
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
handle.process.kill()
log.error("", { error: err })
})
if (!client) break
clients.push(client)
}
}
log.info("initialized")
return {
broken: new Set<string>(),
clients,
}
},
@@ -78,13 +71,43 @@ export namespace LSP {
return state()
}
async function getClients(file: string) {
const s = await state()
const extension = path.parse(file).ext
const result: LSPClient.Info[] = []
for (const server of Object.values(LSPServer)) {
if (!server.extensions.includes(extension)) continue
const root = await server.root(file, App.info())
if (!root) continue
if (s.broken.has(root + server.id)) continue
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}
const handle = await server.spawn(App.info(), root)
if (!handle) continue
const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch((err) => {
s.broken.add(root + server.id)
handle.process.kill()
log.error("", { error: err })
})
if (!client) continue
s.clients.push(client)
result.push(client)
}
return result
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
const clients = await getClients(input)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
if (!clients.includes(client)) return
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
await client.notify.open({ path: input })
return wait
@@ -117,17 +140,72 @@ export namespace LSP {
})
}
enum SymbolKind {
File = 1,
Module = 2,
Namespace = 3,
Package = 4,
Class = 5,
Method = 6,
Property = 7,
Field = 8,
Constructor = 9,
Enum = 10,
Interface = 11,
Function = 12,
Variable = 13,
Constant = 14,
String = 15,
Number = 16,
Boolean = 17,
Array = 18,
Object = 19,
Key = 20,
Null = 21,
EnumMember = 22,
Struct = 23,
Event = 24,
Operator = 25,
TypeParameter = 26,
}
const kinds = [
SymbolKind.Class,
SymbolKind.Function,
SymbolKind.Method,
SymbolKind.Interface,
SymbolKind.Variable,
SymbolKind.Constant,
SymbolKind.Struct,
SymbolKind.Enum,
]
export async function workspaceSymbol(query: string) {
return run((client) =>
client.connection
.sendRequest("workspace/symbol", {
query,
})
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
.then((result: any) => result.slice(0, 10))
.catch(() => []),
).then((result) => result.flat() as LSP.Symbol[])
}
export async function documentSymbol(uri: string) {
return run((client) =>
client.connection
.sendRequest("textDocument/documentSymbol", {
textDocument: {
uri,
},
})
.catch(() => []),
)
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
.then((result) => result.filter(Boolean))
}
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
const clients = await state().then((x) => x.clients)
const tasks = clients.map((x) => input(x))
+23 -44
View File
@@ -6,10 +6,7 @@ import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import fs from "fs/promises"
import { unique } from "remeda"
import { Ripgrep } from "../file/ripgrep"
import type { LSPClient } from "./client"
import { withTimeout } from "../util/timeout"
import { Filesystem } from "../util/filesystem"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -17,19 +14,21 @@ export namespace LSPServer {
export interface Handle {
process: ChildProcessWithoutNullStreams
initialization?: Record<string, any>
onInitialized?: (lsp: LSPClient.Info) => Promise<void>
}
type RootsFunction = (app: App.Info) => Promise<string[]>
type RootFunction = (file: string, app: App.Info) => Promise<string | undefined>
const SimpleRoots = (patterns: string[]): RootsFunction => {
return async (app) => {
const files = await Ripgrep.files({
glob: patterns.map(p => `**/${p}`),
cwd: app.path.root,
const NearestRoot = (patterns: string[]): RootFunction => {
return async (file, app) => {
const files = Filesystem.up({
targets: patterns,
start: path.dirname(file),
stop: app.path.root,
})
const dirs = files.map((file) => path.dirname(file))
return unique(dirs).map((dir) => path.join(app.path.root, dir))
const first = await files.next()
await files.return()
if (!first.value) return app.path.root
return path.dirname(first.value)
}
}
@@ -37,13 +36,13 @@ export namespace LSPServer {
id: string
extensions: string[]
global?: boolean
roots: (app: App.Info) => Promise<string[]>
root: RootFunction
spawn(app: App.Info, root: string): Promise<Handle | undefined>
}
export const Typescript: Info = {
id: "typescript",
roots: async (app) => [app.path.root],
root: NearestRoot(["tsconfig.json", "package.json", "jsconfig.json"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(app, root) {
const tsserver = await Bun.resolve("typescript/lib/tsserver.js", app.path.cwd).catch(() => {})
@@ -62,30 +61,17 @@ export namespace LSPServer {
path: tsserver,
},
},
// tsserver sucks and won't start processing codebase until you open a file
onInitialized: async (lsp) => {
const [hint] = await Ripgrep.files({
cwd: lsp.root,
glob: ["*.ts", "*.tsx", "*.js", "*.jsx", "*.mjs", "*.cjs", "*.mts", "*.cts"],
limit: 1,
})
const wait = new Promise<void>(async (resolve) => {
const notif = lsp.connection.onNotification("$/progress", (params) => {
if (params.value.kind !== "end") return
notif.dispose()
resolve()
})
await lsp.notify.open({ path: path.join(lsp.root, hint) })
})
await withTimeout(wait, 5_000)
},
}
},
}
export const Gopls: Info = {
id: "golang",
roots: SimpleRoots(["go.mod", "go.sum"]),
root: async (file, app) => {
const work = await NearestRoot(["go.work"])(file, app)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file, app)
},
extensions: [".go"],
async spawn(_, root) {
let bin = Bun.which("gopls", {
@@ -121,7 +107,7 @@ export namespace LSPServer {
export const RubyLsp: Info = {
id: "ruby-lsp",
roots: SimpleRoots(["Gemfile"]),
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(_, root) {
let bin = Bun.which("ruby-lsp", {
@@ -162,14 +148,7 @@ export namespace LSPServer {
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
roots: SimpleRoots([
"pyproject.toml",
"setup.py",
"setup.cfg",
"requirements.txt",
"Pipfile",
"pyrightconfig.json",
]),
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(_, root) {
const proc = spawn(BunProc.which(), ["x", "pyright-langserver", "--stdio"], {
cwd: root,
@@ -187,7 +166,7 @@ export namespace LSPServer {
export const ElixirLS: Info = {
id: "elixir-ls",
extensions: [".ex", ".exs"],
roots: SimpleRoots(["mix.exs", "mix.lock"]),
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(_, root) {
let binary = Bun.which("elixir-ls")
if (!binary) {
@@ -242,7 +221,7 @@ export namespace LSPServer {
export const Zls: Info = {
id: "zls",
extensions: [".zig", ".zon"],
roots: SimpleRoots(["build.zig"]),
root: NearestRoot(["build.zig"]),
async spawn(_, root) {
let bin = Bun.which("zls", {
PATH: process.env["PATH"] + ":" + Global.Path.bin,
+53 -13
View File
@@ -36,6 +36,8 @@ import { SystemPrompt } from "./system"
import { FileTime } from "../file/time"
import { MessageV2 } from "./message-v2"
import { Mode } from "./mode"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -285,7 +287,6 @@ export namespace Session {
mode?: string
parts: MessageV2.UserPart[]
}) {
using abort = lock(input.sessionID)
const l = log.clone().tag("session", input.sessionID)
l.info("chatting")
@@ -336,6 +337,8 @@ export namespace Session {
}
}
using abort = lock(input.sessionID)
const lastSummary = msgs.findLast((msg) => msg.role === "assistant" && msg.summary === true)
if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id)
@@ -346,31 +349,68 @@ export namespace Session {
const url = new URL(part.url)
switch (url.protocol) {
case "file:":
const filepath = path.join(app.path.cwd, url.pathname)
let file = Bun.file(filepath)
// have to normalize, symbol search returns absolute paths
const relativePath = url.pathname.replace(app.path.cwd, ".")
const filePath = path.join(app.path.cwd, relativePath)
if (part.mime === "text/plain") {
let text = await file.text()
let offset: number | undefined = undefined
let limit: number | undefined = undefined
const range = {
start: url.searchParams.get("start"),
end: url.searchParams.get("end"),
}
if (range.start != null && part.mime === "text/plain") {
const lines = text.split("\n")
const start = parseInt(range.start)
const end = range.end ? parseInt(range.end) : lines.length
text = lines.slice(start, end).join("\n")
if (range.start != null) {
const filePath = part.url.split("?")[0]
let start = parseInt(range.start)
let end = range.end ? parseInt(range.end) : undefined
// some LSP servers (eg, gopls) don't give full range in
// workspace/symbol searches, so we'll try to find the
// symbol in the document to get the full range
if (start === end) {
const symbols = await LSP.documentSymbol(filePath)
for (const symbol of symbols) {
let range: LSP.Range | undefined
if ("range" in symbol) {
range = symbol.range
} else if ("location" in symbol) {
range = symbol.location.range
}
if (range?.start?.line && range?.start?.line === start) {
start = range.start.line
end = range?.end?.line ?? start
break
}
}
offset = Math.max(start - 2, 0)
if (end) {
limit = end - offset + 2
}
}
}
FileTime.read(input.sessionID, filepath)
const args = { filePath, offset, limit }
const result = await ReadTool.execute(args, {
sessionID: input.sessionID,
abort: abort.signal,
messageID: "", // read tool doesn't use message ID
metadata: async () => {},
})
return [
{
type: "text",
synthetic: true,
text: ["Called the Read tool on " + url.pathname, "<results>", text, "</results>"].join("\n"),
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
{
type: "text",
synthetic: true,
text: result.output,
},
]
}
let file = Bun.file(filePath)
FileTime.read(input.sessionID, filePath)
return [
{
type: "text",
@@ -390,7 +430,7 @@ export namespace Session {
}),
).then((x) => x.flat())
if (true)
if (input.mode === "plan")
input.parts.push({
type: "text",
text: PROMPT_PLAN,
@@ -540,7 +580,7 @@ export namespace Session {
const result = streamText({
onError() {},
maxRetries: 10,
maxOutputTokens: Math.max(0, model.info.limit.output) || undefined,
maxOutputTokens: input.modelID.includes("grok-4") ? undefined : Math.max(0, model.info.limit.output) || undefined,
abortSignal: abort.signal,
stopWhen: stepCountIs(1000),
providerOptions: model.info.options,
+15
View File
@@ -26,6 +26,21 @@ export namespace Filesystem {
return result
}
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
const { targets, start, stop } = options
let current = start
while (true) {
for (const target of targets) {
const search = join(current, target)
if (await exists(search)) yield search
}
if (stop === current) break
const parent = dirname(current)
if (parent === current) break
current = parent
}
}
export async function globUp(pattern: string, start: string, stop?: string) {
let current = start
const result = []
+3
View File
@@ -50,6 +50,9 @@ type SendMsg struct {
Text string
Attachments []opencode.FilePartParam
}
type SetEditorContentMsg struct {
Text string
}
type OptimisticMessageAddedMsg struct {
Message opencode.MessageUnion
}
+16 -7
View File
@@ -29,17 +29,26 @@ func (c *CommandCompletionProvider) GetEmptyMessage() string {
return "no matching commands"
}
func getCommandCompletionItem(cmd commands.Command, space int, t theme.Theme) dialog.CompletionItemI {
func (c *CommandCompletionProvider) getCommandCompletionItem(
cmd commands.Command,
space int,
t theme.Theme,
) dialog.CompletionItemI {
spacer := strings.Repeat(" ", space)
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().Foreground(t.TextMuted()).Render(spacer+cmd.Description)
title := " /" + cmd.PrimaryTrigger() + styles.NewStyle().
Foreground(t.TextMuted()).
Render(spacer+cmd.Description)
value := string(cmd.Name)
return dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
Title: title,
Value: value,
ProviderID: c.GetId(),
})
}
func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.CompletionItemI, error) {
func (c *CommandCompletionProvider) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
t := theme.CurrentTheme()
commands := c.app.Commands
@@ -60,7 +69,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
continue
}
space := space - lipgloss.Width(cmd.PrimaryTrigger())
items = append(items, getCommandCompletionItem(cmd, space, t))
items = append(items, c.getCommandCompletionItem(cmd, space, t))
}
return items, nil
}
@@ -77,7 +86,7 @@ func (c *CommandCompletionProvider) GetChildEntries(query string) ([]dialog.Comp
// Add all triggers as searchable options
for _, trigger := range cmd.Trigger {
commandNames = append(commandNames, trigger)
commandMap[trigger] = getCommandCompletionItem(cmd, space, t)
commandMap[trigger] = c.getCommandCompletionItem(cmd, space, t)
}
}
@@ -14,20 +14,20 @@ import (
"github.com/sst/opencode/internal/theme"
)
type filesAndFoldersContextGroup struct {
type filesContextGroup struct {
app *app.App
gitFiles []dialog.CompletionItemI
}
func (cg *filesAndFoldersContextGroup) GetId() string {
func (cg *filesContextGroup) GetId() string {
return "files"
}
func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string {
func (cg *filesContextGroup) GetEmptyMessage() string {
return "no matching files"
}
func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
func (cg *filesContextGroup) getGitFiles() []dialog.CompletionItemI {
t := theme.CurrentTheme()
items := make([]dialog.CompletionItemI, 0)
base := styles.NewStyle().Background(t.BackgroundElement())
@@ -50,8 +50,10 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
title += red(" -" + strconv.Itoa(int(file.Removed)))
}
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: file.Path,
Title: title,
Value: file.Path,
ProviderID: cg.GetId(),
Raw: file,
})
items = append(items, item)
}
@@ -60,7 +62,7 @@ func (cg *filesAndFoldersContextGroup) getGitFiles() []dialog.CompletionItemI {
return items
}
func (cg *filesAndFoldersContextGroup) GetChildEntries(
func (cg *filesContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
@@ -94,8 +96,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
}
if !exists {
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: file,
Value: file,
Title: file,
Value: file,
ProviderID: cg.GetId(),
Raw: file,
})
items = append(items, item)
}
@@ -104,8 +108,8 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries(
return items, nil
}
func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesAndFoldersContextGroup{
func NewFileContextGroup(app *app.App) dialog.CompletionProvider {
cg := &filesContextGroup{
app: app,
}
go func() {
@@ -0,0 +1,118 @@
package completions
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type symbolsContextGroup struct {
app *app.App
}
func (cg *symbolsContextGroup) GetId() string {
return "symbols"
}
func (cg *symbolsContextGroup) GetEmptyMessage() string {
return "no matching symbols"
}
type SymbolKind int
const (
SymbolKindFile SymbolKind = 1
SymbolKindModule SymbolKind = 2
SymbolKindNamespace SymbolKind = 3
SymbolKindPackage SymbolKind = 4
SymbolKindClass SymbolKind = 5
SymbolKindMethod SymbolKind = 6
SymbolKindProperty SymbolKind = 7
SymbolKindField SymbolKind = 8
SymbolKindConstructor SymbolKind = 9
SymbolKindEnum SymbolKind = 10
SymbolKindInterface SymbolKind = 11
SymbolKindFunction SymbolKind = 12
SymbolKindVariable SymbolKind = 13
SymbolKindConstant SymbolKind = 14
SymbolKindString SymbolKind = 15
SymbolKindNumber SymbolKind = 16
SymbolKindBoolean SymbolKind = 17
SymbolKindArray SymbolKind = 18
SymbolKindObject SymbolKind = 19
SymbolKindKey SymbolKind = 20
SymbolKindNull SymbolKind = 21
SymbolKindEnumMember SymbolKind = 22
SymbolKindStruct SymbolKind = 23
SymbolKindEvent SymbolKind = 24
SymbolKindOperator SymbolKind = 25
SymbolKindTypeParameter SymbolKind = 26
)
func (cg *symbolsContextGroup) GetChildEntries(
query string,
) ([]dialog.CompletionItemI, error) {
items := make([]dialog.CompletionItemI, 0)
query = strings.TrimSpace(query)
if query == "" {
return items, nil
}
symbols, err := cg.app.Client.Find.Symbols(
context.Background(),
opencode.FindSymbolsParams{Query: opencode.F(query)},
)
if err != nil {
slog.Error("Failed to get symbol completion items", "error", err)
return items, err
}
if symbols == nil {
return items, nil
}
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Background(t.BackgroundElement())
base := baseStyle.Render
muted := baseStyle.Foreground(t.TextMuted()).Render
for _, sym := range *symbols {
parts := strings.Split(sym.Name, ".")
lastPart := parts[len(parts)-1]
title := base(lastPart)
uriParts := strings.Split(sym.Location.Uri, "/")
lastTwoParts := uriParts[len(uriParts)-2:]
joined := strings.Join(lastTwoParts, "/")
title += muted(fmt.Sprintf(" %s", joined))
start := int(sym.Location.Range.Start.Line)
end := int(sym.Location.Range.End.Line)
title += muted(fmt.Sprintf(":L%d-%d", start, end))
value := fmt.Sprintf("%s?start=%d&end=%d", sym.Location.Uri, start, end)
item := dialog.NewCompletionItem(dialog.CompletionItem{
Title: title,
Value: value,
ProviderID: cg.GetId(),
Raw: sym,
})
items = append(items, item)
}
return items, nil
}
func NewSymbolsContextGroup(app *app.App) dialog.CompletionProvider {
return &symbolsContextGroup{
app: app,
}
}
+37 -17
View File
@@ -38,6 +38,7 @@ type EditorComponent interface {
Clear() (tea.Model, tea.Cmd)
Paste() (tea.Model, tea.Cmd)
Newline() (tea.Model, tea.Cmd)
SetValue(value string)
SetInterruptKeyInDebounce(inDebounce bool)
SetExitKeyInDebounce(inDebounce bool)
}
@@ -136,13 +137,13 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
text := string(msg)
m.textarea.InsertRunesFromUserInput([]rune(text))
case dialog.ThemeSelectedMsg:
m.textarea = m.resetTextareaStyles()
m.textarea = updateTextareaStyles(m.textarea)
m.spinner = createSpinner()
return m, tea.Batch(m.spinner.Tick, m.textarea.Focus())
case dialog.CompletionSelectedMsg:
switch msg.ProviderID {
switch msg.Item.GetProviderID() {
case "commands":
commandName := strings.TrimPrefix(msg.CompletionValue, "/")
commandName := strings.TrimPrefix(msg.Item.GetValue(), "/")
updated, cmd := m.Clear()
m = updated.(*editorComponent)
cmds = append(cmds, cmd)
@@ -152,7 +153,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.CompletionValue + " ")
m.textarea.InsertString(msg.Item.GetValue() + " ")
return m, nil
}
@@ -163,7 +164,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Now, insert the attachment at the position where the '@' was.
// The cursor is now at `atIndex` after the replacement.
filePath := msg.CompletionValue
filePath := msg.Item.GetValue()
extension := filepath.Ext(filePath)
mediaType := ""
switch extension {
@@ -186,15 +187,32 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
existingValue := m.textarea.Value()
lastSpaceIndex := strings.LastIndex(existingValue, " ")
if lastSpaceIndex == -1 {
m.textarea.SetValue(msg.CompletionValue + " ")
} else {
modifiedValue := existingValue[:lastSpaceIndex+1] + msg.CompletionValue
m.textarea.SetValue(modifiedValue + " ")
case "symbols":
atIndex := m.textarea.LastRuneIndex('@')
if atIndex == -1 {
// Should not happen, but as a fallback, just insert.
m.textarea.InsertString(msg.Item.GetValue() + " ")
return m, nil
}
cursorCol := m.textarea.CursorColumn()
m.textarea.ReplaceRange(atIndex, cursorCol, "")
symbol := msg.Item.GetRaw().(opencode.Symbol)
parts := strings.Split(symbol.Name, ".")
lastPart := parts[len(parts)-1]
attachment := &textarea.Attachment{
ID: uuid.NewString(),
Display: "@" + lastPart,
URL: msg.Item.GetValue(),
Filename: lastPart,
MediaType: "text/plain",
}
m.textarea.InsertAttachment(attachment)
m.textarea.InsertString(" ")
return m, nil
default:
slog.Debug("Unknown provider", "provider", msg.Item.GetProviderID())
return m, nil
}
}
@@ -384,6 +402,10 @@ func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
m.interruptKeyInDebounce = inDebounce
}
func (m *editorComponent) SetValue(value string) {
m.textarea.SetValue(value)
}
func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
m.exitKeyInDebounce = inDebounce
}
@@ -400,14 +422,12 @@ func (m *editorComponent) getExitKeyText() string {
return m.app.Commands[commands.AppExitCommand].Keys()[0]
}
func (m *editorComponent) resetTextareaStyles() textarea.Model {
func updateTextareaStyles(ta textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.BackgroundElement()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := m.textarea
ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
ta.Styles.Blurred.Placeholder = styles.NewStyle().
@@ -455,6 +475,7 @@ func NewEditorComponent(app *app.App) EditorComponent {
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
ta = updateTextareaStyles(ta)
m := &editorComponent{
app: app,
@@ -462,7 +483,6 @@ func NewEditorComponent(app *app.App) EditorComponent {
spinner: s,
interruptKeyInDebounce: false,
}
m.resetTextareaStyles()
return m
}
@@ -2,12 +2,15 @@ package dialog
import (
"log/slog"
"sort"
"strings"
"github.com/charmbracelet/bubbles/v2/key"
"github.com/charmbracelet/bubbles/v2/textarea"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/muesli/reflow/truncate"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
@@ -15,32 +18,35 @@ import (
)
type CompletionItem struct {
Title string
Value string
Title string
Value string
ProviderID string
Raw any
}
type CompletionItemI interface {
list.ListItem
GetValue() string
DisplayValue() string
GetProviderID() string
GetRaw() any
}
func (ci *CompletionItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
truncatedStr := truncate.String(string(ci.DisplayValue()), uint(width-4))
itemStyle := baseStyle.
Background(t.BackgroundElement()).
Width(width).
Padding(0, 1)
if selected {
itemStyle = itemStyle.Foreground(t.Primary())
}
title := itemStyle.Render(
ci.DisplayValue(),
)
title := itemStyle.Render(truncatedStr)
return title
}
@@ -52,6 +58,14 @@ func (ci *CompletionItem) GetValue() string {
return ci.Value
}
func (ci *CompletionItem) GetProviderID() string {
return ci.ProviderID
}
func (ci *CompletionItem) GetRaw() any {
return ci.Raw
}
func NewCompletionItem(completionItem CompletionItem) CompletionItemI {
return &completionItem
}
@@ -63,9 +77,8 @@ type CompletionProvider interface {
}
type CompletionSelectedMsg struct {
SearchString string
CompletionValue string
ProviderID string
Item CompletionItemI
SearchString string
}
type CompletionDialogCompleteItemMsg struct {
@@ -83,7 +96,7 @@ type CompletionDialog interface {
type completionDialogComponent struct {
query string
completionProvider CompletionProvider
providers []CompletionProvider
width int
height int
pseudoSearchTextArea textarea.Model
@@ -109,6 +122,52 @@ func (c *completionDialogComponent) Init() tea.Cmd {
return nil
}
func (c *completionDialogComponent) getAllCompletions(query string) tea.Cmd {
return func() tea.Msg {
allItems := make([]CompletionItemI, 0)
// Collect results from all providers
for _, provider := range c.providers {
items, err := provider.GetChildEntries(query)
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
allItems = append(allItems, items...)
}
// If there's a query, use fuzzy ranking to sort results
if query != "" && len(allItems) > 0 {
// Create a slice of display values for fuzzy matching
displayValues := make([]string, len(allItems))
for i, item := range allItems {
displayValues[i] = item.DisplayValue()
}
// Get fuzzy matches with ranking
matches := fuzzy.RankFindFold(query, displayValues)
// Sort by score (best matches first)
sort.Sort(matches)
// Reorder items based on fuzzy ranking
rankedItems := make([]CompletionItemI, 0, len(matches))
for _, match := range matches {
rankedItems = append(rankedItems, allItems[match.OriginalIndex])
}
return rankedItems
}
return allItems
}
}
func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
@@ -126,14 +185,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if query != c.query {
c.query = query
cmd = func() tea.Msg {
items, err := c.completionProvider.GetChildEntries(query)
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.getAllCompletions(query))
}
u, cmd := c.list.Update(msg)
@@ -149,23 +201,18 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return c, c.complete(item)
case key.Matches(msg, completionDialogKeys.Cancel):
// Only close on backspace when there are no characters left, unless we're back to just the trigger
value := c.pseudoSearchTextArea.Value()
if msg.String() != "backspace" || (len(value) <= len(c.trigger) && value != c.trigger) {
width := lipgloss.Width(value)
triggerWidth := lipgloss.Width(c.trigger)
// Only close on backspace when there are no characters left, unless we're back to just the trigger
if msg.String() != "backspace" || (width <= triggerWidth && value != c.trigger) {
return c, c.close()
}
}
return c, tea.Batch(cmds...)
} else {
cmd := func() tea.Msg {
items, err := c.completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
return items
}
cmds = append(cmds, cmd)
cmds = append(cmds, c.getAllCompletions(""))
cmds = append(cmds, c.pseudoSearchTextArea.Focus())
return c, tea.Batch(cmds...)
}
@@ -177,19 +224,7 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (c *completionDialogComponent) View() string {
t := theme.CurrentTheme()
baseStyle := styles.NewStyle().Foreground(t.Text())
maxWidth := 40
completions := c.list.GetItems()
for _, cmd := range completions {
title := cmd.DisplayValue()
width := lipgloss.Width(title)
if width > maxWidth-4 {
maxWidth = width + 4
}
}
c.list.SetMaxWidth(maxWidth)
c.list.SetMaxWidth(c.width)
return baseStyle.
Padding(0, 0).
@@ -213,12 +248,10 @@ func (c *completionDialogComponent) IsEmpty() bool {
func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd {
value := c.pseudoSearchTextArea.Value()
return tea.Batch(
util.CmdHandler(CompletionSelectedMsg{
SearchString: value,
CompletionValue: item.GetValue(),
ProviderID: c.completionProvider.GetId(),
SearchString: value,
Item: item,
}),
c.close(),
)
@@ -230,32 +263,53 @@ func (c *completionDialogComponent) close() tea.Cmd {
return util.CmdHandler(CompletionDialogCloseMsg{})
}
func NewCompletionDialogComponent(completionProvider CompletionProvider, trigger string) CompletionDialog {
func NewCompletionDialogComponent(
trigger string,
providers ...CompletionProvider,
) CompletionDialog {
ti := textarea.New()
ti.SetValue(trigger)
// Use a generic empty message if we have multiple providers
emptyMessage := "no matching items"
if len(providers) == 1 {
emptyMessage = providers[0].GetEmptyMessage()
}
li := list.NewListComponent(
[]CompletionItemI{},
7,
completionProvider.GetEmptyMessage(),
emptyMessage,
false,
)
go func() {
items, err := completionProvider.GetChildEntries("")
if err != nil {
slog.Error("Failed to get completion items", "error", err)
}
li.SetItems(items)
}()
// Initialize the textarea with the trigger character
ti.SetValue(trigger)
return &completionDialogComponent{
c := &completionDialogComponent{
query: "",
completionProvider: completionProvider,
providers: providers,
pseudoSearchTextArea: ti,
list: li,
trigger: trigger,
}
// Load initial items from all providers
go func() {
allItems := make([]CompletionItemI, 0)
for _, provider := range providers {
items, err := provider.GetChildEntries("")
if err != nil {
slog.Error(
"Failed to get completion items",
"provider",
provider.GetId(),
"error",
err,
)
continue
}
allItems = append(allItems, items...)
}
li.SetItems(allItems)
}()
return c
}
@@ -2,6 +2,7 @@ package status
import (
"fmt"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea/v2"
@@ -20,6 +21,7 @@ type StatusComponent interface {
type statusComponent struct {
app *app.App
width int
cwd string
}
func (m statusComponent) Init() tea.Cmd {
@@ -53,7 +55,7 @@ func (m statusComponent) logo() string {
Render(open + code + version)
}
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) string {
func formatTokensAndCost(tokens float64, contextWindow float64, cost float64, isSubscriptionModel bool) string {
// Format tokens in human-readable format (e.g., 110K, 1.2M)
var formattedTokens string
switch {
@@ -73,10 +75,17 @@ func formatTokensAndCost(tokens float64, contextWindow float64, cost float64) st
formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
}
// Format cost with $ symbol and 2 decimal places
formattedCost := fmt.Sprintf("$%.2f", cost)
percentage := (float64(tokens) / float64(contextWindow)) * 100
if isSubscriptionModel {
return fmt.Sprintf(
"Context: %s (%d%%)",
formattedTokens,
int(percentage),
)
}
formattedCost := fmt.Sprintf("$%.2f", cost)
return fmt.Sprintf(
"Context: %s (%d%%), Cost: %s",
formattedTokens,
@@ -93,7 +102,7 @@ func (m statusComponent) View() string {
Foreground(t.TextMuted()).
Background(t.BackgroundPanel()).
Padding(0, 1).
Render(m.app.Info.Path.Cwd)
Render(m.cwd)
sessionInfo := ""
if m.app.Session.ID != "" {
@@ -119,11 +128,15 @@ func (m statusComponent) View() string {
}
}
// Check if current model is a subscription model (cost is 0 for both input and output)
isSubscriptionModel := m.app.Model != nil &&
m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
sessionInfo = styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement()).
Padding(0, 1).
Render(formatTokensAndCost(tokens, contextWindow, cost))
Render(formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel))
}
// diagnostics := styles.Padded().Background(t.BackgroundElement()).Render(m.projectDiagnostics())
@@ -145,5 +158,12 @@ func NewStatusCmp(app *app.App) StatusComponent {
app: app,
}
homePath, err := os.UserHomeDir()
cwdPath := app.Info.Path.Cwd
if err == nil && homePath != "" && strings.HasPrefix(cwdPath, homePath) {
cwdPath = "~" + cwdPath[len(homePath):]
}
statusComponent.cwd = cwdPath
return statusComponent
}
@@ -931,7 +931,6 @@ func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int {
}
// CursorDown moves the cursor down by one line.
// Returns whether or not the cursor blink should be reset.
func (m *Model) CursorDown() {
li := m.LineInfo()
charOffset := max(m.lastCharOffset, li.CharOffset)
@@ -940,11 +939,73 @@ func (m *Model) CursorDown() {
if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 {
// Move to the next model line
m.row++
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
// We want to land on the first wrapped line of the new model line.
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[0]
// Find position within the first wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundNextLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundNextLine:
m.col = colInLine // startCol is 0 for the first wrapped line
} else if li.RowOffset+1 < li.Height {
// Move to the next wrapped line within the same model line
startOfNextWrappedLine := li.StartColumn + li.Width
m.col = startOfNextWrappedLine + m.mapVisualOffsetToSliceIndex(m.row, charOffset)
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[li.RowOffset+1]
startCol := 0
for i := 0; i < li.RowOffset+1; i++ {
startCol += len(grid[i])
}
// Find position within the target wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundSameLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundSameLine:
m.col = startCol + colInLine
}
m.SetCursorColumn(m.col)
}
@@ -956,22 +1017,77 @@ func (m *Model) CursorUp() {
m.lastCharOffset = charOffset
if li.RowOffset <= 0 && m.row > 0 {
// Move to the previous model line
// Move to the previous model line. We want to land on the last wrapped
// line of the previous model line.
m.row--
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
} else if li.RowOffset > 0 {
// Move to the previous wrapped line within the same model line
// To do this, we need to find the start of the previous wrapped line.
prevLineInfo := m.LineInfo()
// prevLineStart := 0
if prevLineInfo.RowOffset > 0 {
// This is complex, so we'll approximate by moving to the start of the current wrapped line
// and then letting characterLeft handle it. A more precise calculation would
// require re-wrapping to find the previous line's start.
// For now, a simpler approach:
m.col = li.StartColumn - 1
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[len(grid)-1]
// Find start of last wrapped line.
startCol := len(m.value[m.row]) - len(targetLineContent)
// Find position within the last wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundPrevLine
}
offset += itemWidth
}
m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset)
colInLine = len(targetLineContent)
foundPrevLine:
m.col = startCol + colInLine
} else if li.RowOffset > 0 {
// Move to the previous wrapped line within the same model line.
grid := m.memoizedWrap(m.value[m.row], m.width)
targetLineContent := grid[li.RowOffset-1]
startCol := 0
for i := 0; i < li.RowOffset-1; i++ {
startCol += len(grid[i])
}
// Find position within the target wrapped line.
offset := 0
colInLine := 0
for i, item := range targetLineContent {
var itemWidth int
switch v := item.(type) {
case rune:
itemWidth = rw.RuneWidth(v)
case *Attachment:
itemWidth = uniseg.StringWidth(v.Display)
}
if offset+itemWidth > charOffset {
// Decide whether to stick with the previous index or move to the current
// one based on which is closer to the target offset.
if (charOffset - offset) > ((offset + itemWidth) - charOffset) {
colInLine = i + 1
} else {
colInLine = i
}
goto foundSameLine
}
offset += itemWidth
}
colInLine = len(targetLineContent)
foundSameLine:
m.col = startCol + colInLine
}
m.SetCursorColumn(m.col)
}
@@ -1591,7 +1707,7 @@ func (m Model) View() string {
// guaranteed to be a space since any other character would
// have been wrapped.
wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ")
padding -= m.width - strwidth
padding = m.width - uniseg.StringWidth(wrappedLineStr)
}
if m.row == l && lineInfo.RowOffset == wl {
@@ -1903,6 +2019,16 @@ func (m *Model) splitLine(row, col int) {
m.row++
}
func itemWidth(item any) int {
switch v := item.(type) {
case rune:
return rw.RuneWidth(v)
case *Attachment:
return uniseg.StringWidth(v.Display)
}
return 0
}
func wrapInterfaces(content []any, width int) [][]any {
if width <= 0 {
return [][]any{content}
@@ -1945,19 +2071,18 @@ func wrapInterfaces(content []any, width int) [][]any {
}
inSpaces = true
spaceW += itemW
} else {
} else { // It's not a space, it's a character for a word.
if inSpaces {
// End of spaces
if lineW > 0 && lineW+spaceW > width {
lines = append(lines, []any{})
lineW = 0
} else {
lineW += spaceW
}
// Add spaces to current line
// We just finished a block of spaces. Handle them now.
lineW += spaceW
for i := 0; i < spaceW; i++ {
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
}
if lineW > width {
// The spaces made the line overflow. Start a new line for the upcoming word.
lines = append(lines, []any{})
lineW = 0
}
spaceW = 0
}
inSpaces = false
@@ -1966,20 +2091,24 @@ func wrapInterfaces(content []any, width int) [][]any {
}
}
// Handle any remaining word/spaces
// Handle any remaining word/spaces at the end of the content.
if wordW > 0 {
if lineW > 0 && lineW+wordW > width {
lines = append(lines, word)
lineW = wordW
} else {
lines[len(lines)-1] = append(lines[len(lines)-1], word...)
lineW += wordW
}
}
if spaceW > 0 {
if lineW > 0 && lineW+spaceW > width {
lines = append(lines, []any{})
}
// There are trailing spaces. Add them.
for i := 0; i < spaceW; i++ {
lines[len(lines)-1] = append(lines[len(lines)-1], rune(' '))
lineW += 1
}
if lineW > width {
lines = append(lines, []any{})
}
}
+16 -7
View File
@@ -65,6 +65,7 @@ type appModel struct {
completions dialog.CompletionDialog
commandProvider dialog.CompletionProvider
fileProvider dialog.CompletionProvider
symbolsProvider dialog.CompletionProvider
showCompletionDialog bool
fileCompletionActive bool
leaderBinding *key.Binding
@@ -202,7 +203,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
// Set command provider for command completion
a.completions = dialog.NewCompletionDialogComponent(a.commandProvider, "/")
a.completions = dialog.NewCompletionDialogComponent("/", a.commandProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -220,8 +221,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
// Set file provider for file completion
a.completions = dialog.NewCompletionDialogComponent(a.fileProvider, "@")
// Set both file and symbols providers for @ completion
a.completions = dialog.NewCompletionDialogComponent("@", a.fileProvider, a.symbolsProvider)
updated, cmd = a.completions.Update(msg)
a.completions = updated.(dialog.CompletionDialog)
cmds = append(cmds, cmd)
@@ -389,6 +390,12 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.showCompletionDialog = false
a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments)
cmds = append(cmds, cmd)
case app.SetEditorContentMsg:
// Set the editor content without sending
a.editor.SetValue(msg.Text)
updated, cmd := a.editor.Focus()
a.editor = updated.(chat.EditorComponent)
cmds = append(cmds, cmd)
case dialog.CompletionDialogCloseMsg:
a.showCompletionDialog = false
a.fileCompletionActive = false
@@ -857,7 +864,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
return nil
}
os.Remove(tmpfile.Name())
return app.SendMsg{
return app.SetEditorContentMsg{
Text: string(content),
}
})
@@ -922,7 +929,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
a.modal = themeDialog
case commands.FileListCommand:
a.editor.Blur()
provider := completions.NewFileAndFolderContextGroup(a.app)
provider := completions.NewFileContextGroup(a.app)
findDialog := dialog.NewFindDialog(provider)
findDialog.SetWidth(layout.Current.Container.Width - 8)
a.modal = findDialog
@@ -1030,11 +1037,12 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd)
func NewModel(app *app.App) tea.Model {
commandProvider := completions.NewCommandCompletionProvider(app)
fileProvider := completions.NewFileAndFolderContextGroup(app)
fileProvider := completions.NewFileContextGroup(app)
symbolsProvider := completions.NewSymbolsContextGroup(app)
messages := chat.NewMessagesComponent(app)
editor := chat.NewEditorComponent(app)
completions := dialog.NewCompletionDialogComponent(commandProvider, "/")
completions := dialog.NewCompletionDialogComponent("/", commandProvider)
var leaderBinding *key.Binding
if app.Config.Keybinds.Leader != "" {
@@ -1050,6 +1058,7 @@ func NewModel(app *app.App) tea.Model {
completions: completions,
commandProvider: commandProvider,
fileProvider: fileProvider,
symbolsProvider: symbolsProvider,
leaderBinding: leaderBinding,
isLeaderSequence: false,
showCompletionDialog: false,