Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5be6b625 | ||
|
|
f95c3f4177 | ||
|
|
d2b1307bff | ||
|
|
b40ba32adc | ||
|
|
ce0cebb7d7 | ||
|
|
f478f89a68 | ||
|
|
85d95f0f2b | ||
|
|
1515efc77c | ||
|
|
6d393759e1 | ||
|
|
a1701678cd | ||
|
|
c411a26d6f | ||
|
|
85dbfeb314 | ||
|
|
085c0e4e2b | ||
|
|
8404a97c3e |
@@ -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) |
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,6 @@ export namespace LSPClient {
|
||||
},
|
||||
}
|
||||
|
||||
if (input.server.onInitialized) input.server.onInitialized(result)
|
||||
l.info("initialized")
|
||||
|
||||
return result
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -50,6 +50,9 @@ type SendMsg struct {
|
||||
Text string
|
||||
Attachments []opencode.FilePartParam
|
||||
}
|
||||
type SetEditorContentMsg struct {
|
||||
Text string
|
||||
}
|
||||
type OptimisticMessageAddedMsg struct {
|
||||
Message opencode.MessageUnion
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user