From e7fcb692a42079c518cc76569352e99a932b4599 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 3 Jul 2025 16:23:02 -0400 Subject: [PATCH 01/24] docs: tweak page title --- packages/web/src/components/Head.astro | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 3d9bc0f5..f6166f58 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -36,6 +36,10 @@ if (isDocs) { } --- +{ slug === "" && ( +{title} | AI coding agent built for the terminal +)} + { (isDocs || !slug.startsWith("s")) && ( From b8de69dceda9486d4cc4bb75738238012f58f197 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 3 Jul 2025 19:15:38 -0400 Subject: [PATCH 02/24] docs: fix share page scroll performance --- packages/web/src/components/Share.tsx | 154 ++++++++++++++++++++------ 1 file changed, 119 insertions(+), 35 deletions(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index f1a1f1aa..fb9ddc99 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -597,6 +597,8 @@ export default function Share(props: { }) { let lastScrollY = 0 let scrollTimeout: number | undefined + let scrollSentinel: HTMLElement | undefined + let scrollObserver: IntersectionObserver | undefined const id = props.id const params = new URLSearchParams(window.location.search) @@ -604,6 +606,7 @@ export default function Share(props: { const [showScrollButton, setShowScrollButton] = createSignal(false) const [isButtonHovered, setIsButtonHovered] = createSignal(false) + const [isNearBottom, setIsNearBottom] = createSignal(false) const [store, setStore] = createStore<{ info?: Session.Info @@ -713,10 +716,9 @@ export default function Share(props: { const currentScrollY = window.scrollY const isScrollingDown = currentScrollY > lastScrollY const scrolled = currentScrollY > 200 // Show after scrolling 200px - const isNearBottom = window.innerHeight + currentScrollY >= document.body.scrollHeight - 100 // Only show when scrolling down, scrolled enough, and not near bottom - const shouldShow = isScrollingDown && scrolled && !isNearBottom + const shouldShow = isScrollingDown && scrolled && !isNearBottom() // Update last scroll position lastScrollY = currentScrollY @@ -732,7 +734,7 @@ export default function Share(props: { if (!isButtonHovered()) { setShowScrollButton(false) } - }, 3000) + }, 1500) } else if (!isButtonHovered()) { // Only hide if not hovered (to prevent disappearing while user is about to click) setShowScrollButton(false) @@ -744,6 +746,26 @@ export default function Share(props: { onMount(() => { lastScrollY = window.scrollY // Initialize scroll position + + // Create sentinel element + const sentinel = document.createElement("div") + sentinel.style.height = "1px" + sentinel.style.position = "absolute" + sentinel.style.bottom = "100px" + sentinel.style.width = "100%" + sentinel.style.pointerEvents = "none" + document.body.appendChild(sentinel) + + // Create intersection observer + const observer = new IntersectionObserver((entries) => { + setIsNearBottom(entries[0].isIntersecting) + }) + observer.observe(sentinel) + + // Store references for cleanup + scrollSentinel = sentinel + scrollObserver = observer + checkScrollNeed() window.addEventListener("scroll", checkScrollNeed) window.addEventListener("resize", checkScrollNeed) @@ -752,6 +774,15 @@ export default function Share(props: { onCleanup(() => { window.removeEventListener("scroll", checkScrollNeed) window.removeEventListener("resize", checkScrollNeed) + + // Clean up observer and sentinel + if (scrollObserver) { + scrollObserver.disconnect() + } + if (scrollSentinel) { + document.body.removeChild(scrollSentinel) + } + if (scrollTimeout) { clearTimeout(scrollTimeout) } @@ -855,7 +886,6 @@ export default function Share(props: { )} - @@ -880,8 +910,11 @@ export default function Share(props: { ) return null - const anchor = createMemo(() => `${msg.id}-${partIndex()}`) - const [showResults, setShowResults] = createSignal(false) + const anchor = createMemo( + () => `${msg.id}-${partIndex()}`, + ) + const [showResults, setShowResults] = + createSignal(false) const isLastPart = createMemo( () => data().messages.length === msgIndex() + 1 && @@ -903,7 +936,9 @@ export default function Share(props: { const duration = DateTime.fromMillis( metadata?.time.end || 0, ) - .diff(DateTime.fromMillis(metadata?.time.start || 0)) + .diff( + DateTime.fromMillis(metadata?.time.start || 0), + ) .toMillis() return { metadata, args, result, duration } @@ -921,7 +956,9 @@ export default function Share(props: { {/* User text */} {(part) => ( @@ -972,7 +1009,9 @@ export default function Share(props: { expand={isLastPart()} text={stripEnclosingTag(part().text)} /> - + {(_part) => { - const matches = () => toolData()?.metadata?.matches + const matches = () => + toolData()?.metadata?.matches const splitArgs = () => { const { pattern, ...rest } = toolData()?.args return { pattern, rest } @@ -1066,11 +1106,14 @@ export default function Share(props: {
Grep - “{splitArgs().pattern}” + + “{splitArgs().pattern}” +
0 + Object.keys(splitArgs().rest) + .length > 0 } >
@@ -1299,8 +1342,10 @@ export default function Share(props: { data().rootDir, ), ) - const hasError = () => toolData()?.metadata?.error - const preview = () => toolData()?.metadata?.preview + const hasError = () => + toolData()?.metadata?.error + const preview = () => + toolData()?.metadata?.preview return (
{/* Always try to show CodeBlock if preview is available (even if empty string) */} - +
@@ -1354,7 +1403,12 @@ export default function Share(props: {
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */} - +
toolData()?.metadata?.error + const hasError = () => + toolData()?.metadata?.error const content = () => toolData()?.args?.content const diagnostics = createMemo(() => getDiagnostics( @@ -1415,7 +1470,10 @@ export default function Share(props: { >
- +
@@ -1435,7 +1493,7 @@ export default function Share(props: {
{formatErrorString( - toolData()?.result + toolData()?.result, )}
@@ -1453,8 +1511,12 @@ export default function Share(props: {
@@ -1481,8 +1543,10 @@ export default function Share(props: { > {(_part) => { const diff = () => toolData()?.metadata?.diff - const message = () => toolData()?.metadata?.message - const hasError = () => toolData()?.metadata?.error + const message = () => + toolData()?.metadata?.message + const hasError = () => + toolData()?.metadata?.error const filePath = createMemo(() => stripWorkingDirectory( toolData()?.args.filePath, @@ -1504,7 +1568,10 @@ export default function Share(props: { >
- +
@@ -1527,7 +1594,9 @@ export default function Share(props: {
@@ -1556,9 +1625,12 @@ export default function Share(props: { } > {(_part) => { - const command = () => toolData()?.metadata?.title - const desc = () => toolData()?.metadata?.description - const result = () => toolData()?.metadata?.stdout + const command = () => + toolData()?.metadata?.title + const desc = () => + toolData()?.metadata?.description + const result = () => + toolData()?.metadata?.stdout const error = () => toolData()?.metadata?.stderr return ( @@ -1569,7 +1641,10 @@ export default function Share(props: { >
- +
@@ -1604,7 +1679,9 @@ export default function Share(props: { > {(_part) => { const todos = createMemo(() => - sortTodosByStatus(toolData()?.args?.todos ?? []), + sortTodosByStatus( + toolData()?.args?.todos ?? [], + ), ) const starting = () => todos().every((t) => t.status === "pending") @@ -1670,7 +1747,8 @@ export default function Share(props: { {(_part) => { const url = () => toolData()?.args.url const format = () => toolData()?.args.format - const hasError = () => toolData()?.metadata?.error + const hasError = () => + toolData()?.metadata?.error return (
- + @@ -1848,7 +1930,9 @@ export default function Share(props: {
- {part.type} + + {part.type} +
Date: Thu, 3 Jul 2025 18:53:17 -0400 Subject: [PATCH 03/24] wip: logs --- packages/opencode/src/cli/cmd/tui.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 04e90978..5cdda83c 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -9,6 +9,7 @@ import fs from "fs/promises" import { Installation } from "../../installation" import { Config } from "../../config/config" import { Bus } from "../../bus" +import { Log } from "../../util/log" export const TuiCommand = cmd({ command: "$0 [project]", @@ -57,6 +58,9 @@ export const TuiCommand = cmd({ cwd = process.cwd() cmd = [binary] } + Log.Default.info("tui", { + cmd, + }) const proc = Bun.spawn({ cmd: [...cmd, ...process.argv.slice(2)], cwd, From cf83e31f231436dacc5a729abaff7a0e66a111c6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 19:29:42 -0400 Subject: [PATCH 04/24] add elixir lsp support --- packages/opencode/src/lsp/server.ts | 58 +++++++++++++++++++++++++++++ packages/opencode/src/tool/edit.ts | 8 ++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index ce7972f5..39a23f0f 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -4,6 +4,8 @@ import path from "path" import { Global } from "../global" import { Log } from "../util/log" import { BunProc } from "../bun" +import { $ } from "bun" +import fs from "fs/promises" export namespace LSPServer { const log = Log.create({ service: "lsp.server" }) @@ -144,4 +146,60 @@ export namespace LSPServer { } }, } + + export const ElixirLS: Info = { + id: "elixir-ls", + extensions: [".ex", ".exs"], + async spawn() { + let binary = Bun.which("elixir-ls") + if (!binary) { + const elixirLsPath = path.join(Global.Path.bin, "elixir-ls") + binary = path.join( + Global.Path.bin, + "elixir-ls-master", + "release", + process.platform === "win32" + ? "language_server.bar" + : "language_server.sh", + ) + + if (!(await Bun.file(binary).exists())) { + const elixir = Bun.which("elixir") + if (!elixir) { + log.error("elixir is required to run elixir-ls") + return + } + + log.info("downloading elixir-ls from GitHub releases") + + const response = await fetch( + "https://github.com/elixir-lsp/elixir-ls/archive/refs/heads/master.zip", + ) + if (!response.ok) return + const zipPath = path.join(Global.Path.bin, "elixir-ls.zip") + await Bun.file(zipPath).write(response) + + await $`unzip -o -q ${zipPath}`.cwd(Global.Path.bin).nothrow() + + await fs.rm(zipPath, { + force: true, + recursive: true, + }) + + await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release` + .quiet() + .cwd(path.join(Global.Path.bin, "elixir-ls-master")) + .env({ MIX_ENV: "prod", ...process.env }) + + log.info(`installed elixir-ls`, { + path: elixirLsPath, + }) + } + } + + return { + process: spawn(binary), + } + }, + } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index fb02a536..8c9043e6 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -489,10 +489,10 @@ export function replace( BlockAnchorReplacer, WhitespaceNormalizedReplacer, IndentationFlexibleReplacer, - EscapeNormalizedReplacer, - TrimmedBoundaryReplacer, - ContextAwareReplacer, - MultiOccurrenceReplacer, + // EscapeNormalizedReplacer, + // TrimmedBoundaryReplacer, + // ContextAwareReplacer, + // MultiOccurrenceReplacer, ]) { for (const search of replacer(content, oldString)) { const index = content.indexOf(search) From 25c876caa2b5f308cf400a8b0747276cba04d177 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 3 Jul 2025 19:33:42 -0400 Subject: [PATCH 05/24] docs: share fix last message not expandable --- packages/web/src/components/Share.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index fb9ddc99..e1981100 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -321,6 +321,7 @@ function TextPart(props: TextPartProps) { createEffect(() => { local.text + local.expand setTimeout(checkOverflow, 0) }) @@ -372,6 +373,7 @@ function ErrorPart(props: ErrorPartProps) { createEffect(() => { local.children + local.expand setTimeout(checkOverflow, 0) }) @@ -425,6 +427,7 @@ function MarkdownPart(props: MarkdownPartProps) { createEffect(() => { local.text + local.expand setTimeout(checkOverflow, 0) }) @@ -486,6 +489,14 @@ function TerminalPart(props: TerminalPartProps) { } } + createEffect(() => { + local.command + local.result + local.error + local.expand + setTimeout(checkOverflow, 0) + }) + onMount(() => { checkOverflow() window.addEventListener("resize", checkOverflow) @@ -895,7 +906,7 @@ export default function Share(props: { fallback={

Waiting for messages...

} >
- + {(msg, msgIndex) => ( From cdb25656d545034ec08aa970c8b1366452f30fd6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 20:16:16 -0400 Subject: [PATCH 06/24] improve snapshot speed --- packages/opencode/src/lsp/client.ts | 16 +++++++++++---- packages/opencode/src/lsp/index.ts | 2 +- packages/opencode/src/snapshot/index.ts | 27 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index f06a8c68..5aff437d 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -92,11 +92,20 @@ export namespace LSPClient { }, }), 5_000, - ).catch(() => { - throw new InitializeError({ serverID }) + ).catch((err) => { + log.error("initialize error", { error: err }) + throw new InitializeError( + { serverID }, + { + cause: err, + }, + ) }) + await connection.sendNotification("initialized", {}) - log.info("initialized") + log.info("initialized", { + serverID, + }) const files: { [path: string]: number @@ -174,7 +183,6 @@ export namespace LSPClient { log.info("shutting down", { serverID }) connection.end() connection.dispose() - server.process.kill("SIGTERM") log.info("shutdown", { serverID }) }, } diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 2c73feb8..88e549bb 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -47,7 +47,7 @@ export namespace LSP { const handle = await server.spawn(App.info()) if (!handle) break const client = await LSPClient.create(server.id, handle).catch( - () => {}, + (err) => log.error("", { error: err }), ) if (!client) break clients.set(server.id, client) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index bf8ea05f..fcf77c45 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -16,12 +16,14 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) export async function create(sessionID: string) { + log.info("creating snapshot") const app = App.info() const git = gitdir(sessionID) const files = await Ripgrep.files({ cwd: app.path.cwd, limit: app.git ? undefined : 1000, }) + log.info("found files", { count: files.length }) // not a git repo and too big to snapshot if (!app.git && files.length === 1000) return await init({ @@ -29,19 +31,17 @@ export namespace Snapshot { gitdir: git, fs, }) + log.info("initialized") const status = await statusMatrix({ fs, gitdir: git, dir: app.path.cwd, }) - await add({ - fs, - gitdir: git, - parallel: true, - dir: app.path.cwd, - filepath: files, + log.info("matrix", { + count: status.length, }) - for (const [file, _head, workdir, stage] of status) { + const added = [] + for (const [file, head, workdir, stage] of status) { if (workdir === 0 && stage === 1) { log.info("remove", { file }) await remove({ @@ -50,8 +50,21 @@ export namespace Snapshot { dir: app.path.cwd, filepath: file, }) + continue + } + if (workdir !== head) { + added.push(file) } } + log.info("removed files") + await add({ + fs, + gitdir: git, + parallel: true, + dir: app.path.cwd, + filepath: added, + }) + log.info("added files") const result = await commit({ fs, gitdir: git, From 37327259cb3182f2e8594d0b95d6f189cc6a2d0a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 20:30:02 -0400 Subject: [PATCH 07/24] ci: ignore --- bun.lock | 26 ++++++++++++++------------ package.json | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index a14065e0..200e0587 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "opencode", "devDependencies": { "prettier": "3.5.3", - "sst": "3.17.6", + "sst": "3.17.8", }, }, "packages/function": { @@ -462,7 +462,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], - "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -492,6 +492,8 @@ "@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="], + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], "@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="], @@ -602,7 +604,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -1476,23 +1478,23 @@ "split2": ["split2@3.2.2", "", { "dependencies": { "readable-stream": "^3.0.0" } }, "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg=="], - "sst": ["sst@3.17.6", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.6", "sst-darwin-x64": "3.17.6", "sst-linux-arm64": "3.17.6", "sst-linux-x64": "3.17.6", "sst-linux-x86": "3.17.6", "sst-win32-arm64": "3.17.6", "sst-win32-x64": "3.17.6", "sst-win32-x86": "3.17.6" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-p+AcqwfYQUdkxeRjCikQoTMviPCBiGoU7M0vcV6GDVmVis8hzhVw4EFfHTafZC+aWfy1Ke2UQi66vZlEVWuEqA=="], + "sst": ["sst@3.17.8", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.8", "sst-darwin-x64": "3.17.8", "sst-linux-arm64": "3.17.8", "sst-linux-x64": "3.17.8", "sst-linux-x86": "3.17.8", "sst-win32-arm64": "3.17.8", "sst-win32-x64": "3.17.8", "sst-win32-x86": "3.17.8" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-P/a9/ZsjtQRrTBerBMO1ODaVa5HVTmNLrQNJiYvu2Bgd0ov+vefQeHv6oima8HLlPwpDIPS2gxJk8BZrTZMfCA=="], - "sst-darwin-arm64": ["sst-darwin-arm64@3.17.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6tb7KlcPR7PTi3ofQv8dX/n6Jf7pNP9VfrnYL4HBWnWrcYaZeJ5MWobILfIJ/y2jHgoqmg9e5C3266Eds0JQyw=="], + "sst-darwin-arm64": ["sst-darwin-arm64@3.17.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-50P6YRMnZVItZUfB0+NzqMww2mmm4vB3zhTVtWUtGoXeiw78g1AEnVlmS28gYXPHM1P987jTvR7EON9u9ig/Dg=="], - "sst-darwin-x64": ["sst-darwin-x64@3.17.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-lFakq6/EgTuBSjbl8Kry4pfgAPEIyn6o7ZkyRz3hz5331wUaX88yfjs3tL9JQ8Ey6jBUYxwhP/Q1n7fzIG046g=="], + "sst-darwin-x64": ["sst-darwin-x64@3.17.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-P0pnMHCmpkpcsxkWpilmeoD79LkbkoIcv6H0aeM9ArT/71/JBhvqH+HjMHSJCzni/9uR6er+nH5F+qol0UO6Bw=="], - "sst-linux-arm64": ["sst-linux-arm64@3.17.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-SdTxXMbTEdiwOqp37w31kXv97vHqSx3oK9h/76lKg7V9k5JxPJ6JMefPLhoKWwK0Zh6AndY2zo2oRoEv4SIaDw=="], + "sst-linux-arm64": ["sst-linux-arm64@3.17.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-vun54YA/UzprCu9p8BC4rMwFU5Cj9xrHAHYLYUp/yq4H0pfmBIiQM62nsfIKizRThe/TkBFy60EEi9myf6raYA=="], - "sst-linux-x64": ["sst-linux-x64@3.17.6", "", { "os": "linux", "cpu": "x64" }, "sha512-qneh7uWDiTUYx8X1Y3h2YVw3SJ0ybBBlRrVybIvCM09JqQ8+qq/XjKXGzA/3/EF0Jr7Ug8cARSn9CwxhdQGN7Q=="], + "sst-linux-x64": ["sst-linux-x64@3.17.8", "", { "os": "linux", "cpu": "x64" }, "sha512-HqByCaLE2gEJbM20P1QRd+GqDMAiieuU53FaZA1F+AGxQi+kR82NWjrPqFcMj4dMYg8w/TWXuV+G5+PwoUmpDw=="], - "sst-linux-x86": ["sst-linux-x86@3.17.6", "", { "os": "linux", "cpu": "none" }, "sha512-pU3D5OeqnmfxGqN31DxuwWnc1OayxhkErnITHhZ39D0MTiwbIgCapH26FuLW8B08/uxJWG8djUlOboCRhSBvWA=="], + "sst-linux-x86": ["sst-linux-x86@3.17.8", "", { "os": "linux", "cpu": "none" }, "sha512-bCd6QM3MejfSmdvg8I/k+aUJQIZEQJg023qmN78fv00vwlAtfECvY7tjT9E2m3LDp33pXrcRYbFOQzPu+tWFfA=="], - "sst-win32-arm64": ["sst-win32-arm64@3.17.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-Rr3RTYWAsH9sM9CbM/sAZCk7dB1OsSAljjJuuHMvdSAYW3RDpXEza0PBJGxnBID2eOrpswEchzMPL2d8LtL7oA=="], + "sst-win32-arm64": ["sst-win32-arm64@3.17.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-pilx0n8gm4aHJae/vNiqIwZkWF3tdwWzD/ON7hkytw+CVSZ0FXtyFW/yO/+2u3Yw0Kj0lSWPnUqYgm/eHPLwQA=="], - "sst-win32-x64": ["sst-win32-x64@3.17.6", "", { "os": "win32", "cpu": "x64" }, "sha512-yZ3roxwI0Wve9PFzdrrF1kfzCmIMFCCoa8qKeXY7LxCJ4QQIqHbCOccLK1Wv/MIU/mcZHWXTQVCLHw77uaa0GQ=="], + "sst-win32-x64": ["sst-win32-x64@3.17.8", "", { "os": "win32", "cpu": "x64" }, "sha512-Jb0FVRyiOtESudF1V8ucW65PuHrx/iOHUamIO0JnbujWNHZBTRPB2QHN1dbewgkueYDaCmyS8lvuIImLwYJnzQ=="], - "sst-win32-x86": ["sst-win32-x86@3.17.6", "", { "os": "win32", "cpu": "none" }, "sha512-zV7TJWPJN9PmIXr15iXFSs0tbGsa52oBR3+xiKrUj2qj9XsZe7HBFwskRnHyiFq0durZY9kk9ZtoVlpuUuzr1g=="], + "sst-win32-x86": ["sst-win32-x86@3.17.8", "", { "os": "win32", "cpu": "none" }, "sha512-oVmFa/PoElQmfnGJlB0w6rPXiYuldiagO6AbrLMT/6oAnWerLQ8Uhv9tJWfMh3xtPLImQLTjxDo1v0AIzEv9QA=="], "stacktracey": ["stacktracey@2.1.8", "", { "dependencies": { "as-table": "^1.0.36", "get-source": "^2.0.12" } }, "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw=="], diff --git a/package.json b/package.json index 93482498..09248dcf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "prettier": "3.5.3", - "sst": "3.17.6" + "sst": "3.17.8" }, "repository": { "type": "git", From 167a9dcaf312c2ceda2ed43e0adecf33d5e98c60 Mon Sep 17 00:00:00 2001 From: Jay V Date: Thu, 3 Jul 2025 20:30:17 -0400 Subject: [PATCH 08/24] docs: share fix scroll to anchor --- packages/web/src/components/Share.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index e1981100..ff838dab 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -957,7 +957,12 @@ export default function Share(props: { onMount(() => { const hash = window.location.hash.slice(1) - if (hash !== "" && hash === anchor()) { + // Wait till all parts are loaded + if ( + hash !== "" + && msg.parts.length === partIndex() + 1 + && data().messages.length === msgIndex() + 1 + ) { scrollToAnchor(hash) } }) From 571d60182a011cc2c71c451d3ddb3243b72cbbd8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 3 Jul 2025 21:17:15 -0400 Subject: [PATCH 09/24] improve snapshotting speed further --- bun.lock | 5 ++ packages/opencode/.gitignore | 1 - packages/opencode/src/snapshot/index.ts | 113 +++++++++--------------- 3 files changed, 46 insertions(+), 73 deletions(-) diff --git a/bun.lock b/bun.lock index 200e0587..a723e36b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", + "air": "0.4.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -516,6 +517,8 @@ "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], + "air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1700,6 +1703,8 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + "zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="], + "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 66857d89..e057ca61 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -1,4 +1,3 @@ -node_modules research dist gen diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index fcf77c45..95d7776c 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,14 +1,7 @@ import { App } from "../app/app" -import { - add, - commit, - init, - checkout, - statusMatrix, - remove, -} from "isomorphic-git" +import { $ } from "bun" import path from "path" -import fs from "fs" +import fs from "fs/promises" import { Ripgrep } from "../file/ripgrep" import { Log } from "../util/log" @@ -19,76 +12,52 @@ export namespace Snapshot { log.info("creating snapshot") const app = App.info() const git = gitdir(sessionID) - const files = await Ripgrep.files({ - cwd: app.path.cwd, - limit: app.git ? undefined : 1000, - }) - log.info("found files", { count: files.length }) - // not a git repo and too big to snapshot - if (!app.git && files.length === 1000) return - await init({ - dir: app.path.cwd, - gitdir: git, - fs, - }) - log.info("initialized") - const status = await statusMatrix({ - fs, - gitdir: git, - dir: app.path.cwd, - }) - log.info("matrix", { - count: status.length, - }) - const added = [] - for (const [file, head, workdir, stage] of status) { - if (workdir === 0 && stage === 1) { - log.info("remove", { file }) - await remove({ - fs, - gitdir: git, - dir: app.path.cwd, - filepath: file, - }) - continue - } - if (workdir !== head) { - added.push(file) - } + + // not a git repo, check if too big to snapshot + if (!app.git) { + const files = await Ripgrep.files({ + cwd: app.path.cwd, + limit: 1000, + }) + log.info("found files", { count: files.length }) + if (files.length > 1000) return } - log.info("removed files") - await add({ - fs, - gitdir: git, - parallel: true, - dir: app.path.cwd, - filepath: added, - }) + + if (await fs.mkdir(git, { recursive: true })) { + await $`git init` + .env({ + ...process.env, + GIT_DIR: git, + GIT_WORK_TREE: app.path.root, + }) + .quiet() + .nothrow() + log.info("initialized") + } + + await $`git --git-dir ${git} add .`.quiet().cwd(app.path.cwd).nothrow() log.info("added files") - const result = await commit({ - fs, - gitdir: git, - dir: app.path.cwd, - message: "snapshot", - author: { - name: "opencode", - email: "mail@opencode.ai", - }, - }) - log.info("commit", { result }) - return result + + const result = + await $`git --git-dir ${git} commit --allow-empty -m "snapshot" --author="opencode "` + .quiet() + .cwd(app.path.cwd) + .nothrow() + log.info("commit") + + // Extract commit hash from output like "[main abc1234] snapshot" + const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/) + if (!match) throw new Error("Failed to extract commit hash") + return match[1] } export async function restore(sessionID: string, commit: string) { log.info("restore", { commit }) const app = App.info() - await checkout({ - fs, - gitdir: gitdir(sessionID), - dir: app.path.cwd, - ref: commit, - force: true, - }) + const git = gitdir(sessionID) + await $`git --git-dir=${git} checkout ${commit} --force` + .quiet() + .cwd(app.path.root) } function gitdir(sessionID: string) { From 121eb24e73ff8121f2f797a8679b842678a5af58 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 4 Jul 2025 12:26:16 +0000 Subject: [PATCH 10/24] Update download stats 2025-07-04 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index e73da4b4..256ffb5d 100644 --- a/STATS.md +++ b/STATS.md @@ -7,3 +7,4 @@ | 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) | From 23788674c81184d3d5ea85cc00b29756102de326 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 08:44:07 -0400 Subject: [PATCH 11/24] disable snapshots temporarily --- packages/opencode/src/snapshot/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 95d7776c..1bbb870f 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,6 +9,7 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) export async function create(sessionID: string) { + return log.info("creating snapshot") const app = App.info() const git = gitdir(sessionID) @@ -45,10 +46,9 @@ export namespace Snapshot { .nothrow() log.info("commit") - // Extract commit hash from output like "[main abc1234] snapshot" const match = result.stdout.toString().match(/\[.+ ([a-f0-9]+)\]/) - if (!match) throw new Error("Failed to extract commit hash") - return match[1] + if (!match) return + return match![1] } export async function restore(sessionID: string, commit: string) { From 4a0be45d3d685ad952f51ef875c798ec4b3061de Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:22:45 -0500 Subject: [PATCH 12/24] chore: document `instructions` configuration option (#670) --- packages/web/src/content/docs/docs/rules.mdx | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/web/src/content/docs/docs/rules.mdx b/packages/web/src/content/docs/docs/rules.mdx index aed08535..b7818d71 100644 --- a/packages/web/src/content/docs/docs/rules.mdx +++ b/packages/web/src/content/docs/docs/rules.mdx @@ -73,3 +73,29 @@ So when opencode starts, it looks for: 2. **Global file** by checking `~/.config/opencode/AGENTS.md` If you have both global and project-specific rules, opencode will combine them together. + +--- + +## Custom Instructions + +You can also specify custom instruction files using the `instructions` configuration in your `opencode.json` or global `~/.config/opencode/config.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +You can specify multiple files like `CONTRIBUTING.md` and `docs/guidelines.md`, and use glob patterns to match multiple files. + +For example, to reuse your existing Cursor rules: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".cursor/rules/*.md"] +} +``` + +All instruction files are combined with your `AGENTS.md` files. From f13b0af4912ba062d89b1599281982455de54662 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 4 Jul 2025 19:24:13 +0400 Subject: [PATCH 13/24] docs: Fix invalid json in the mcp example config (#645) --- packages/web/src/content/docs/docs/mcp-servers.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/docs/mcp-servers.mdx b/packages/web/src/content/docs/docs/mcp-servers.mdx index 72c33e8a..0496e31c 100644 --- a/packages/web/src/content/docs/docs/mcp-servers.mdx +++ b/packages/web/src/content/docs/docs/mcp-servers.mdx @@ -30,6 +30,7 @@ Add a local MCP servers under `mcp.localmcp`. "enabled": true, "environment": { "MY_ENV_VAR": "my_env_var_value" + } } } } From 163e23a68b4a21e8939f4d280594fc084d3ea4de Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 11:32:12 -0400 Subject: [PATCH 14/24] removed banned command concept --- packages/opencode/src/tool/bash.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 3ef44bd5..620a8c8d 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -4,25 +4,6 @@ import DESCRIPTION from "./bash.txt" import { App } from "../app/app" const MAX_OUTPUT_LENGTH = 30000 -const BANNED_COMMANDS = [ - "alias", - "curl", - "curlie", - "wget", - "axel", - "aria2c", - "nc", - "telnet", - "lynx", - "w3m", - "links", - "httpie", - "xh", - "http-prompt", - "chrome", - "firefox", - "safari", -] const DEFAULT_TIMEOUT = 1 * 60 * 1000 const MAX_TIMEOUT = 10 * 60 * 1000 @@ -45,8 +26,6 @@ export const BashTool = Tool.define({ }), async execute(params, ctx) { const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT) - if (BANNED_COMMANDS.some((item) => params.command.startsWith(item))) - throw new Error(`Command '${params.command}' is not allowed`) const process = Bun.spawn({ cmd: ["bash", "-c", params.command], From 891ed6ebc006703d5a26f89ecc85bd86f9b2133e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:04:45 -0500 Subject: [PATCH 15/24] fix(tui): slower startup due to file.status --- packages/tui/internal/completions/files-folders.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tui/internal/completions/files-folders.go b/packages/tui/internal/completions/files-folders.go index 8d6b9958..ec298af9 100644 --- a/packages/tui/internal/completions/files-folders.go +++ b/packages/tui/internal/completions/files-folders.go @@ -16,12 +16,11 @@ import ( type filesAndFoldersContextGroup struct { app *app.App - prefix string gitFiles []dialog.CompletionItemI } func (cg *filesAndFoldersContextGroup) GetId() string { - return cg.prefix + return "files" } func (cg *filesAndFoldersContextGroup) GetEmptyMessage() string { @@ -107,9 +106,10 @@ func (cg *filesAndFoldersContextGroup) GetChildEntries( func NewFileAndFolderContextGroup(app *app.App) dialog.CompletionProvider { cg := &filesAndFoldersContextGroup{ - app: app, - prefix: "file", + app: app, } - cg.gitFiles = cg.getGitFiles() + go func() { + cg.gitFiles = cg.getGitFiles() + }() return cg } From f9abc7c84f2544f5844d795bf835064114734817 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:29:40 -0500 Subject: [PATCH 16/24] feat(tui): file attachments --- packages/tui/go.mod | 1 + packages/tui/go.sum | 2 + packages/tui/internal/app/app.go | 67 +- .../tui/internal/components/chat/editor.go | 104 ++- .../internal/components/dialog/complete.go | 15 +- .../tui/internal/components/dialog/find.go | 4 +- .../tui/internal/components/dialog/models.go | 8 +- packages/tui/internal/components/list/list.go | 14 +- .../tui/internal/components/modal/modal.go | 4 +- packages/tui/internal/components/qr/qr.go | 2 +- .../internal/components/textarea/textarea.go | 812 +++++++++++++----- .../tui/internal/layout/flex_example_test.go | 41 - packages/tui/internal/layout/flex_test.go | 90 -- packages/tui/internal/tui/tui.go | 37 +- 14 files changed, 794 insertions(+), 407 deletions(-) delete mode 100644 packages/tui/internal/layout/flex_example_test.go delete mode 100644 packages/tui/internal/layout/flex_test.go diff --git a/packages/tui/go.mod b/packages/tui/go.mod index 043d9fcd..74047af1 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -37,6 +37,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/goccy/go-yaml v1.17.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/invopop/yaml v0.3.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 29548273..fdc5bbb0 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -92,6 +92,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/packages/tui/internal/app/app.go b/packages/tui/internal/app/app.go index 6b59acae..469857ab 100644 --- a/packages/tui/internal/app/app.go +++ b/packages/tui/internal/app/app.go @@ -44,7 +44,7 @@ type SessionClearedMsg struct{} type CompactSessionMsg struct{} type SendMsg struct { Text string - Attachments []Attachment + Attachments []opencode.FilePartParam } type OptimisticMessageAddedMsg struct { Message opencode.Message @@ -217,13 +217,6 @@ func getDefaultModel( return nil } -type Attachment struct { - FilePath string - FileName string - MimeType string - Content []byte -} - func (a *App) IsBusy() bool { if len(a.Messages) == 0 { return false @@ -296,24 +289,40 @@ func (a *App) CreateSession(ctx context.Context) (*opencode.Session, error) { return session, nil } -func (a *App) SendChatMessage(ctx context.Context, text string, attachments []Attachment) tea.Cmd { +func (a *App) SendChatMessage( + ctx context.Context, + text string, + attachments []opencode.FilePartParam, +) (*App, tea.Cmd) { var cmds []tea.Cmd if a.Session.ID == "" { session, err := a.CreateSession(ctx) if err != nil { - return toast.NewErrorToast(err.Error()) + return a, toast.NewErrorToast(err.Error()) } a.Session = session cmds = append(cmds, util.CmdHandler(SessionSelectedMsg(session))) } + optimisticParts := []opencode.MessagePart{{ + Type: opencode.MessagePartTypeText, + Text: text, + }} + if len(attachments) > 0 { + for _, attachment := range attachments { + optimisticParts = append(optimisticParts, opencode.MessagePart{ + Type: opencode.MessagePartTypeFile, + Filename: attachment.Filename.Value, + MediaType: attachment.MediaType.Value, + URL: attachment.URL.Value, + }) + } + } + optimisticMessage := opencode.Message{ - ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), - Role: opencode.MessageRoleUser, - Parts: []opencode.MessagePart{{ - Type: opencode.MessagePartTypeText, - Text: text, - }}, + ID: fmt.Sprintf("optimistic-%d", time.Now().UnixNano()), + Role: opencode.MessageRoleUser, + Parts: optimisticParts, Metadata: opencode.MessageMetadata{ SessionID: a.Session.ID, Time: opencode.MessageMetadataTime{ @@ -326,13 +335,25 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At cmds = append(cmds, util.CmdHandler(OptimisticMessageAddedMsg{Message: optimisticMessage})) cmds = append(cmds, func() tea.Msg { + parts := []opencode.MessagePartUnionParam{ + opencode.TextPartParam{ + Type: opencode.F(opencode.TextPartTypeText), + Text: opencode.F(text), + }, + } + if len(attachments) > 0 { + for _, attachment := range attachments { + parts = append(parts, opencode.FilePartParam{ + MediaType: attachment.MediaType, + Type: attachment.Type, + URL: attachment.URL, + Filename: attachment.Filename, + }) + } + } + _, err := a.Client.Session.Chat(ctx, a.Session.ID, opencode.SessionChatParams{ - Parts: opencode.F([]opencode.MessagePartUnionParam{ - opencode.TextPartParam{ - Type: opencode.F(opencode.TextPartTypeText), - Text: opencode.F(text), - }, - }), + Parts: opencode.F(parts), ProviderID: opencode.F(a.Provider.ID), ModelID: opencode.F(a.Model.ID), }) @@ -346,7 +367,7 @@ func (a *App) SendChatMessage(ctx context.Context, text string, attachments []At // The actual response will come through SSE // For now, just return success - return tea.Batch(cmds...) + return a, tea.Batch(cmds...) } func (a *App) Cancel(ctx context.Context, sessionID string) error { diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 669ef47d..595fd4d5 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -3,11 +3,14 @@ package chat import ( "fmt" "log/slog" + "path/filepath" "strings" "github.com/charmbracelet/bubbles/v2/spinner" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/google/uuid" + "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/commands" "github.com/sst/opencode/internal/components/dialog" @@ -37,7 +40,6 @@ type EditorComponent interface { type editorComponent struct { app *app.App textarea textarea.Model - attachments []app.Attachment spinner spinner.Model interruptKeyInDebounce bool } @@ -66,17 +68,43 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner = createSpinner() return m, tea.Batch(m.spinner.Tick, m.textarea.Focus()) case dialog.CompletionSelectedMsg: - if msg.IsCommand { + switch msg.ProviderID { + case "commands": commandName := strings.TrimPrefix(msg.CompletionValue, "/") updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) return m, tea.Batch(cmds...) - } else { - existingValue := m.textarea.Value() + case "files": + atIndex := m.textarea.LastRuneIndex('@') + if atIndex == -1 { + // Should not happen, but as a fallback, just insert. + m.textarea.InsertString(msg.CompletionValue + " ") + return m, nil + } - // Replace the current token (after last space) + // The range to replace is from the '@' up to the current cursor position. + // Replace the search term (e.g., "@search") with an empty string first. + cursorCol := m.textarea.CursorColumn() + m.textarea.ReplaceRange(atIndex, cursorCol, "") + + // Now, insert the attachment at the position where the '@' was. + // The cursor is now at `atIndex` after the replacement. + filePath := msg.CompletionValue + fileName := filepath.Base(filePath) + attachment := &textarea.Attachment{ + ID: uuid.NewString(), + Display: "@" + fileName, + URL: fmt.Sprintf("file://%s", filePath), + Filename: fileName, + MediaType: "text/plain", + } + 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 + " ") @@ -128,7 +156,15 @@ func (m *editorComponent) Content(width int) string { if m.app.IsBusy() { keyText := m.getInterruptKeyText() if m.interruptKeyInDebounce { - hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText+" again") + muted(" interrupt") + hint = muted( + "working", + ) + m.spinner.View() + muted( + " ", + ) + base( + keyText+" again", + ) + muted( + " interrupt", + ) } else { hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt") } @@ -195,14 +231,23 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } var cmds []tea.Cmd + + attachments := m.textarea.GetAttachments() + fileParts := make([]opencode.FilePartParam, 0) + for _, attachment := range attachments { + fileParts = append(fileParts, opencode.FilePartParam{ + Type: opencode.F(opencode.FilePartTypeFile), + MediaType: opencode.F(attachment.MediaType), + URL: opencode.F(attachment.URL), + Filename: opencode.F(attachment.Filename), + }) + } + updated, cmd := m.Clear() m = updated.(*editorComponent) cmds = append(cmds, cmd) - attachments := m.attachments - m.attachments = nil - - cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: attachments})) + cmds = append(cmds, util.CmdHandler(app.SendMsg{Text: value, Attachments: fileParts})) return m, tea.Batch(cmds...) } @@ -212,18 +257,23 @@ func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { } func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - imageBytes, text, err := image.GetImageFromClipboard() + _, text, err := image.GetImageFromClipboard() if err != nil { slog.Error(err.Error()) return m, nil } - if len(imageBytes) != 0 { - attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) - attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"} - m.attachments = append(m.attachments, attachment) - } else { - m.textarea.SetValue(m.textarea.Value() + text) - } + // if len(imageBytes) != 0 { + // attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments)) + // attachment := app.Attachment{ + // FilePath: attachmentName, + // FileName: attachmentName, + // Content: imageBytes, + // MimeType: "image/png", + // } + // m.attachments = append(m.attachments, attachment) + // } else { + m.textarea.SetValue(m.textarea.Value() + text) + // } return m, nil } @@ -254,12 +304,26 @@ func createTextArea(existing *textarea.Model) textarea.Model { 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().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Blurred.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Focused.Placeholder = styles.NewStyle().Foreground(textMutedColor).Background(bgColor).Lipgloss() + ta.Styles.Focused.Placeholder = styles.NewStyle(). + Foreground(textMutedColor). + Background(bgColor). + Lipgloss() ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() + ta.Styles.Attachment = styles.NewStyle(). + Foreground(t.Secondary()). + Background(bgColor). + Lipgloss() + ta.Styles.SelectedAttachment = styles.NewStyle(). + Foreground(t.Text()). + Background(t.Secondary()). + Lipgloss() ta.Styles.Cursor.Color = t.Primary() ta.Prompt = " " diff --git a/packages/tui/internal/components/dialog/complete.go b/packages/tui/internal/components/dialog/complete.go index caf754c7..7ba91dc5 100644 --- a/packages/tui/internal/components/dialog/complete.go +++ b/packages/tui/internal/components/dialog/complete.go @@ -64,7 +64,7 @@ type CompletionProvider interface { type CompletionSelectedMsg struct { SearchString string CompletionValue string - IsCommand bool + ProviderID string } type CompletionDialogCompleteItemMsg struct { @@ -121,9 +121,6 @@ func (c *completionDialogComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var query string query = c.pseudoSearchTextArea.Value() - if query != "" { - query = query[1:] - } if query != c.query { c.query = query @@ -183,8 +180,9 @@ func (c *completionDialogComponent) View() string { for _, cmd := range completions { title := cmd.DisplayValue() - if len(title) > maxWidth-4 { - maxWidth = len(title) + 4 + width := lipgloss.Width(title) + if width > maxWidth-4 { + maxWidth = width + 4 } } @@ -213,14 +211,11 @@ func (c *completionDialogComponent) IsEmpty() bool { func (c *completionDialogComponent) complete(item CompletionItemI) tea.Cmd { value := c.pseudoSearchTextArea.Value() - // Check if this is a command completion - isCommand := c.completionProvider.GetId() == "commands" - return tea.Batch( util.CmdHandler(CompletionSelectedMsg{ SearchString: value, CompletionValue: item.GetValue(), - IsCommand: isCommand, + ProviderID: c.completionProvider.GetId(), }), c.close(), ) diff --git a/packages/tui/internal/components/dialog/find.go b/packages/tui/internal/components/dialog/find.go index 489b9f29..3fc6e599 100644 --- a/packages/tui/internal/components/dialog/find.go +++ b/packages/tui/internal/components/dialog/find.go @@ -124,7 +124,7 @@ func (f *findDialogComponent) View() string { f.list.SetMaxWidth(f.width - 4) inputView := f.textInput.View() inputView = styles.NewStyle(). - Background(t.BackgroundPanel()). + Background(t.BackgroundElement()). Height(1). Width(f.width-4). Padding(0, 0). @@ -171,7 +171,7 @@ func (f *findDialogComponent) Close() tea.Cmd { func createTextInput(existing *textinput.Model) textinput.Model { t := theme.CurrentTheme() - bgColor := t.BackgroundPanel() + bgColor := t.BackgroundElement() textColor := t.Text() textMutedColor := t.TextMuted() diff --git a/packages/tui/internal/components/dialog/models.go b/packages/tui/internal/components/dialog/models.go index 4ebf572e..f8cda82a 100644 --- a/packages/tui/internal/components/dialog/models.go +++ b/packages/tui/internal/components/dialog/models.go @@ -56,24 +56,24 @@ func (m ModelItem) Render(selected bool, width int) string { displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName) return styles.NewStyle(). Background(t.Primary()). - Foreground(t.BackgroundElement()). + Foreground(t.BackgroundPanel()). Width(width). PaddingLeft(1). Render(displayText) } else { modelStyle := styles.NewStyle(). Foreground(t.Text()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) providerStyle := styles.NewStyle(). Foreground(t.TextMuted()). - Background(t.BackgroundElement()) + Background(t.BackgroundPanel()) modelPart := modelStyle.Render(m.ModelName) providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName)) combinedText := modelPart + providerPart return styles.NewStyle(). - Background(t.BackgroundElement()). + Background(t.BackgroundPanel()). PaddingLeft(1). Render(combinedText) } diff --git a/packages/tui/internal/components/list/list.go b/packages/tui/internal/components/list/list.go index a7ea3458..16bc73ca 100644 --- a/packages/tui/internal/components/list/list.go +++ b/packages/tui/internal/components/list/list.go @@ -158,7 +158,12 @@ func (c *listComponent[T]) View() string { return strings.Join(listItems, "\n") } -func NewListComponent[T ListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[T] { +func NewListComponent[T ListItem]( + items []T, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[T] { return &listComponent[T]{ fallbackMsg: fallbackMsg, items: items, @@ -194,7 +199,12 @@ func (s StringItem) Render(selected bool, width int) string { } // NewStringList creates a new list component with string items -func NewStringList(items []string, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) List[StringItem] { +func NewStringList( + items []string, + maxVisibleItems int, + fallbackMsg string, + useAlphaNumericKeys bool, +) List[StringItem] { stringItems := make([]StringItem, len(items)) for i, item := range items { stringItems[i] = StringItem(item) diff --git a/packages/tui/internal/components/modal/modal.go b/packages/tui/internal/components/modal/modal.go index aa81a83e..5c2fbf8b 100644 --- a/packages/tui/internal/components/modal/modal.go +++ b/packages/tui/internal/components/modal/modal.go @@ -90,7 +90,7 @@ func (m *Modal) Render(contentView string, background string) string { innerWidth := outerWidth - 4 - baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundElement()) + baseStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()) var finalContent string if m.title != "" { @@ -140,6 +140,6 @@ func (m *Modal) Render(contentView string, background string) string { modalView, background, layout.WithOverlayBorder(), - layout.WithOverlayBorderColor(t.BorderActive()), + layout.WithOverlayBorderColor(t.Primary()), ) } diff --git a/packages/tui/internal/components/qr/qr.go b/packages/tui/internal/components/qr/qr.go index ccf28200..233bcf52 100644 --- a/packages/tui/internal/components/qr/qr.go +++ b/packages/tui/internal/components/qr/qr.go @@ -23,7 +23,7 @@ func Generate(text string) (string, int, error) { } // Create lipgloss style for QR code with theme colors - qrStyle := styles.NewStyleWithColors(t.Text(), t.Background()) + qrStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background()) var result strings.Builder diff --git a/packages/tui/internal/components/textarea/textarea.go b/packages/tui/internal/components/textarea/textarea.go index 2ca08bb8..c2c92ea7 100644 --- a/packages/tui/internal/components/textarea/textarea.go +++ b/packages/tui/internal/components/textarea/textarea.go @@ -9,6 +9,8 @@ import ( "time" "unicode" + "slices" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/v2/cursor" "github.com/charmbracelet/bubbles/v2/key" @@ -17,7 +19,6 @@ import ( "github.com/charmbracelet/x/ansi" rw "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" - "slices" ) const ( @@ -32,6 +33,145 @@ const ( maxLines = 10000 ) +// Attachment represents a special object within the text, distinct from regular characters. +type Attachment struct { + ID string // A unique identifier for this attachment instance + Display string // e.g., "@filename.txt" + URL string + Filename string + MediaType string +} + +// Helper functions for converting between runes and any slices + +// runesToInterfaces converts a slice of runes to a slice of interfaces +func runesToInterfaces(runes []rune) []any { + result := make([]any, len(runes)) + for i, r := range runes { + result[i] = r + } + return result +} + +// interfacesToRunes converts a slice of interfaces to a slice of runes (for display purposes) +func interfacesToRunes(items []any) []rune { + var result []rune + for _, item := range items { + switch val := item.(type) { + case rune: + result = append(result, val) + case *Attachment: + result = append(result, []rune(val.Display)...) + } + } + return result +} + +// copyInterfaceSlice creates a copy of an any slice +func copyInterfaceSlice(src []any) []any { + dst := make([]any, len(src)) + copy(dst, src) + return dst +} + +// interfacesToString converts a slice of interfaces to a string for display +func interfacesToString(items []any) string { + var s strings.Builder + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteRune(val) + case *Attachment: + s.WriteString(val.Display) + } + } + return s.String() +} + +// isAttachmentAtCursor checks if the cursor is positioned on or immediately after an attachment. +// This allows for proper highlighting even when the cursor is technically at the position +// after the attachment object in the underlying slice. +func (m Model) isAttachmentAtCursor() (*Attachment, int, int) { + if m.row >= len(m.value) { + return nil, -1, -1 + } + + row := m.value[m.row] + col := m.col + + if col < 0 || col > len(row) { + return nil, -1, -1 + } + + // Check if the cursor is at the same index as an attachment. + if col < len(row) { + if att, ok := row[col].(*Attachment); ok { + return att, col, col + } + } + + // Check if the cursor is immediately after an attachment. This is a common + // state, for example, after just inserting one. + if col > 0 && col <= len(row) { + if att, ok := row[col-1].(*Attachment); ok { + return att, col - 1, col - 1 + } + } + + return nil, -1, -1 +} + +// renderLineWithAttachments renders a line with proper attachment highlighting +func (m Model) renderLineWithAttachments( + items []any, + style lipgloss.Style, +) string { + var s strings.Builder + currentAttachment, _, _ := m.isAttachmentAtCursor() + + for _, item := range items { + switch val := item.(type) { + case rune: + s.WriteString(style.Render(string(val))) + case *Attachment: + // Check if this is the attachment the cursor is currently on + if currentAttachment != nil && currentAttachment.ID == val.ID { + // Cursor is on this attachment, highlight it + s.WriteString(m.Styles.SelectedAttachment.Render(val.Display)) + } else { + s.WriteString(m.Styles.Attachment.Render(val.Display)) + } + } + } + return s.String() +} + +// getRuneAt safely gets a rune at a specific position, returns 0 if not a rune +func getRuneAt(items []any, index int) rune { + if index < 0 || index >= len(items) { + return 0 + } + if r, ok := items[index].(rune); ok { + return r + } + return 0 +} + +// isSpaceAt checks if the item at index is a space rune +func isSpaceAt(items []any, index int) bool { + r := getRuneAt(items, index) + return r != 0 && unicode.IsSpace(r) +} + +// setRuneAt safely sets a rune at a specific position if it's a rune +func setRuneAt(items []any, index int, r rune) { + if index >= 0 && index < len(items) { + if _, ok := items[index].(rune); ok { + items[index] = r + } + } +} + // Internal messages for clipboard operations. type ( pasteMsg string @@ -70,30 +210,96 @@ type KeyMap struct { // upon the textarea. func DefaultKeyMap() KeyMap { return KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), - Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + CharacterForward: key.NewBinding( + key.WithKeys("right", "ctrl+f"), + key.WithHelp("right", "character forward"), + ), + CharacterBackward: key.NewBinding( + key.WithKeys("left", "ctrl+b"), + key.WithHelp("left", "character backward"), + ), + WordForward: key.NewBinding( + key.WithKeys("alt+right", "alt+f"), + key.WithHelp("alt+right", "word forward"), + ), + WordBackward: key.NewBinding( + key.WithKeys("alt+left", "alt+b"), + key.WithHelp("alt+left", "word backward"), + ), + LineNext: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("down", "next line"), + ), + LinePrevious: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("up", "previous line"), + ), + DeleteWordBackward: key.NewBinding( + key.WithKeys("alt+backspace", "ctrl+w"), + key.WithHelp("alt+backspace", "delete word backward"), + ), + DeleteWordForward: key.NewBinding( + key.WithKeys("alt+delete", "alt+d"), + key.WithHelp("alt+delete", "delete word forward"), + ), + DeleteAfterCursor: key.NewBinding( + key.WithKeys("ctrl+k"), + key.WithHelp("ctrl+k", "delete after cursor"), + ), + DeleteBeforeCursor: key.NewBinding( + key.WithKeys("ctrl+u"), + key.WithHelp("ctrl+u", "delete before cursor"), + ), + InsertNewline: key.NewBinding( + key.WithKeys("enter", "ctrl+m"), + key.WithHelp("enter", "insert newline"), + ), + DeleteCharacterBackward: key.NewBinding( + key.WithKeys("backspace", "ctrl+h"), + key.WithHelp("backspace", "delete character backward"), + ), + DeleteCharacterForward: key.NewBinding( + key.WithKeys("delete", "ctrl+d"), + key.WithHelp("delete", "delete character forward"), + ), + LineStart: key.NewBinding( + key.WithKeys("home", "ctrl+a"), + key.WithHelp("home", "line start"), + ), + LineEnd: key.NewBinding( + key.WithKeys("end", "ctrl+e"), + key.WithHelp("end", "line end"), + ), + Paste: key.NewBinding( + key.WithKeys("ctrl+v"), + key.WithHelp("ctrl+v", "paste"), + ), + InputBegin: key.NewBinding( + key.WithKeys("alt+<", "ctrl+home"), + key.WithHelp("alt+<", "input begin"), + ), + InputEnd: key.NewBinding( + key.WithKeys("alt+>", "ctrl+end"), + key.WithHelp("alt+>", "input end"), + ), - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), + CapitalizeWordForward: key.NewBinding( + key.WithKeys("alt+c"), + key.WithHelp("alt+c", "capitalize word forward"), + ), + LowercaseWordForward: key.NewBinding( + key.WithKeys("alt+l"), + key.WithHelp("alt+l", "lowercase word forward"), + ), + UppercaseWordForward: key.NewBinding( + key.WithKeys("alt+u"), + key.WithHelp("alt+u", "uppercase word forward"), + ), - TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + TransposeCharacterBackward: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "transpose character backward"), + ), } } @@ -160,9 +366,11 @@ type CursorStyle struct { // states. The appropriate styles will be chosen based on the focus state of // the textarea. type Styles struct { - Focused StyleState - Blurred StyleState - Cursor CursorStyle + Focused StyleState + Blurred StyleState + Cursor CursorStyle + Attachment lipgloss.Style + SelectedAttachment lipgloss.Style } // StyleState that will be applied to the text area. @@ -217,13 +425,22 @@ func (s StyleState) computedText() lipgloss.Style { // line is the input to the text wrapping function. This is stored in a struct // so that it can be hashed and memoized. type line struct { - runes []rune - width int + content []any // Contains runes and *Attachment + width int } // Hash returns a hash of the line. func (w line) Hash() string { - v := fmt.Sprintf("%s:%d", string(w.runes), w.width) + var s strings.Builder + for _, item := range w.content { + switch v := item.(type) { + case rune: + s.WriteRune(v) + case *Attachment: + s.WriteString(v.ID) + } + } + v := fmt.Sprintf("%s:%d", s.String(), w.width) return fmt.Sprintf("%x", sha256.Sum256([]byte(v))) } @@ -232,7 +449,7 @@ type Model struct { Err error // General settings. - cache *MemoCache[line, [][]rune] + cache *MemoCache[line, [][]any] // Prompt is printed at the beginning of each line. // @@ -295,14 +512,14 @@ type Model struct { // if there are more lines than the permitted height. height int - // Underlying text value. - value [][]rune + // Underlying text value. Contains either rune or *Attachment types. + value [][]any // focus indicates whether user input focus should be on this input // component. When false, ignore keyboard input and hide the cursor. focus bool - // Cursor column. + // Cursor column (slice index). col int // Cursor row. @@ -328,14 +545,14 @@ func New() Model { MaxWidth: defaultMaxWidth, Prompt: lipgloss.ThickBorder().Left + " ", Styles: styles, - cache: NewMemoCache[line, [][]rune](maxLines), + cache: NewMemoCache[line, [][]any](maxLines), EndOfBufferCharacter: ' ', ShowLineNumbers: true, VirtualCursor: true, virtualCursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), + value: make([][]any, minHeight, maxLines), focus: false, col: 0, row: 0, @@ -354,25 +571,40 @@ func DefaultStyles(isDark bool) Styles { var s Styles s.Focused = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle(), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Background(lightDark(lipgloss.Color("255"), lipgloss.Color("0"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("240"), lipgloss.Color("240"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(), } s.Blurred = StyleState{ - Base: lipgloss.NewStyle(), - CursorLine: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), - CursorLineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - EndOfBuffer: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), - LineNumber: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), - Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), - Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), - Text: lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + Base: lipgloss.NewStyle(), + CursorLine: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), + CursorLineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + EndOfBuffer: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("254"), lipgloss.Color("0"))), + LineNumber: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("249"), lipgloss.Color("7"))), + Placeholder: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Prompt: lipgloss.NewStyle().Foreground(lipgloss.Color("7")), + Text: lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("245"), lipgloss.Color("7"))), } + s.Attachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) + s.SelectedAttachment = lipgloss.NewStyle(). + Background(lipgloss.Color("11")). + Foreground(lipgloss.Color("0")) s.Cursor = CursorStyle{ Color: lipgloss.Color("7"), Shape: tea.CursorBlock, @@ -429,6 +661,75 @@ func (m *Model) InsertRune(r rune) { m.insertRunesFromUserInput([]rune{r}) } +// InsertAttachment inserts an attachment at the cursor position. +func (m *Model) InsertAttachment(att *Attachment) { + if m.CharLimit > 0 { + availSpace := m.CharLimit - m.Length() + // If the char limit's been reached, cancel. + if availSpace <= 0 { + return + } + } + + // Insert the attachment at the current cursor position + m.value[m.row] = append( + m.value[m.row][:m.col], + append([]any{att}, m.value[m.row][m.col:]...)...) + m.col++ + m.SetCursorColumn(m.col) +} + +// ReplaceRange replaces text from startCol to endCol on the current row with the given string. +// This preserves attachments outside the replaced range. +func (m *Model) ReplaceRange(startCol, endCol int, replacement string) { + if m.row >= len(m.value) || startCol < 0 || endCol < startCol { + return + } + + // Ensure bounds are within the current row + rowLen := len(m.value[m.row]) + startCol = max(0, min(startCol, rowLen)) + endCol = max(startCol, min(endCol, rowLen)) + + // Create new row content: before + replacement + after + before := m.value[m.row][:startCol] + after := m.value[m.row][endCol:] + replacementRunes := runesToInterfaces([]rune(replacement)) + + // Combine the parts + newRow := make([]any, 0, len(before)+len(replacementRunes)+len(after)) + newRow = append(newRow, before...) + newRow = append(newRow, replacementRunes...) + newRow = append(newRow, after...) + + m.value[m.row] = newRow + + // Position cursor at end of replacement + m.col = startCol + len(replacementRunes) + m.SetCursorColumn(m.col) +} + +// CurrentRowLength returns the length of the current row. +func (m *Model) CurrentRowLength() int { + if m.row >= len(m.value) { + return 0 + } + return len(m.value[m.row]) +} + +// GetAttachments returns all attachments in the textarea. +func (m Model) GetAttachments() []*Attachment { + var attachments []*Attachment + for _, row := range m.value { + for _, item := range row { + if att, ok := item.(*Attachment); ok { + attachments = append(attachments, att) + } + } + } + return attachments +} + // insertRunesFromUserInput inserts runes at the current cursor position. func (m *Model) insertRunesFromUserInput(runes []rune) { // Clean up any special characters in the input provided by the @@ -481,23 +782,22 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Save the remainder of the original line at the current // cursor position. - tail := make([]rune, len(m.value[m.row][m.col:])) - copy(tail, m.value[m.row][m.col:]) + tail := copyInterfaceSlice(m.value[m.row][m.col:]) // Paste the first line at the current cursor position. - m.value[m.row] = append(m.value[m.row][:m.col], lines[0]...) + m.value[m.row] = append(m.value[m.row][:m.col], runesToInterfaces(lines[0])...) m.col += len(lines[0]) if numExtraLines := len(lines) - 1; numExtraLines > 0 { // Add the new lines. // We try to reuse the slice if there's already space. - var newGrid [][]rune + var newGrid [][]any if cap(m.value) >= len(m.value)+numExtraLines { // Can reuse the extra space. newGrid = m.value[:len(m.value)+numExtraLines] } else { // No space left; need a new slice. - newGrid = make([][]rune, len(m.value)+numExtraLines) + newGrid = make([][]any, len(m.value)+numExtraLines) copy(newGrid, m.value[:m.row+1]) } // Add all the rows that were after the cursor in the original @@ -507,7 +807,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Insert all the new lines in the middle. for _, l := range lines[1:] { m.row++ - m.value[m.row] = l + m.value[m.row] = runesToInterfaces(l) m.col = len(l) } } @@ -526,7 +826,14 @@ func (m Model) Value() string { var v strings.Builder for _, l := range m.value { - v.WriteString(string(l)) + for _, item := range l { + switch val := item.(type) { + case rune: + v.WriteRune(val) + case *Attachment: + v.WriteString(val.Display) + } + } v.WriteByte('\n') } @@ -537,7 +844,14 @@ func (m Model) Value() string { func (m *Model) Length() int { var l int for _, row := range m.value { - l += uniseg.StringWidth(string(row)) + for _, item := range row { + switch val := item.(type) { + case rune: + l += rw.RuneWidth(val) + case *Attachment: + l += uniseg.StringWidth(val.Display) + } + } } // We add len(m.value) to include the newline characters. return l + len(m.value) - 1 @@ -553,6 +867,29 @@ func (m Model) Line() int { return m.row } +// CursorColumn returns the cursor's column position (slice index). +func (m Model) CursorColumn() int { + return m.col +} + +// LastRuneIndex returns the index of the last occurrence of a rune on the current line, +// searching backwards from the current cursor position. +// Returns -1 if the rune is not found before the cursor. +func (m Model) LastRuneIndex(r rune) int { + if m.row >= len(m.value) { + return -1 + } + // Iterate backwards from just before the cursor position + for i := m.col - 1; i >= 0; i-- { + if i < len(m.value[m.row]) { + if item, ok := m.value[m.row][i].(rune); ok && item == r { + return i + } + } + } + return -1 +} + func (m *Model) Newline() { if m.MaxHeight > 0 && len(m.value) >= m.MaxHeight { return @@ -561,6 +898,39 @@ func (m *Model) Newline() { m.splitLine(m.row, m.col) } +// mapVisualOffsetToSliceIndex converts a visual column offset to a slice index. +// This is used to maintain the cursor's horizontal position when moving vertically. +func (m *Model) mapVisualOffsetToSliceIndex(row int, charOffset int) int { + if row < 0 || row >= len(m.value) { + return 0 + } + + offset := 0 + // Find the slice index that corresponds to the visual offset. + for i, item := range m.value[row] { + var itemWidth int + switch v := item.(type) { + case rune: + itemWidth = rw.RuneWidth(v) + case *Attachment: + itemWidth = uniseg.StringWidth(v.Display) + } + + // If the target offset falls within the current item, this is our index. + 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) { + return i + 1 + } + return i + } + offset += itemWidth + } + + return len(m.value[row]) +} + // CursorDown moves the cursor down by one line. // Returns whether or not the cursor blink should be reset. func (m *Model) CursorDown() { @@ -569,31 +939,15 @@ func (m *Model) CursorDown() { m.lastCharOffset = charOffset if li.RowOffset+1 >= li.Height && m.row < len(m.value)-1 { + // Move to the next model line m.row++ - m.col = 0 - } else { - // Move the cursor to the start of the next line so that we can get - // the line information. We need to add 2 columns to account for the - // trailing space wrapping. - const trailingSpace = 2 - m.col = min(li.StartColumn+li.Width+trailingSpace, len(m.value[m.row])-1) - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.row >= len(m.value) || m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break - } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) + } 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) } + m.SetCursorColumn(m.col) } // CursorUp moves the cursor up by one line. @@ -603,32 +957,24 @@ func (m *Model) CursorUp() { m.lastCharOffset = charOffset if li.RowOffset <= 0 && m.row > 0 { + // Move to the previous model line m.row-- - m.col = len(m.value[m.row]) - } else { - // Move the cursor to the end of the previous line. - // This can be done by moving the cursor to the start of the line and - // then subtracting 2 to account for the trailing space we keep on - // soft-wrapped lines. - const trailingSpace = 2 - m.col = li.StartColumn - trailingSpace - } - - nli := m.LineInfo() - m.col = nli.StartColumn - - if nli.Width <= 0 { - return - } - - offset := 0 - for offset < charOffset { - if m.col >= len(m.value[m.row]) || offset >= nli.CharWidth-1 { - break + 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 } - offset += rw.RuneWidth(m.value[m.row][m.col]) - m.col++ + m.col = m.mapVisualOffsetToSliceIndex(m.row, charOffset) } + m.SetCursorColumn(m.col) } // SetCursorColumn moves the cursor to the given position. If the position is @@ -680,7 +1026,7 @@ func (m *Model) Blur() { // Reset sets the input to its default state with no input. func (m *Model) Reset() { - m.value = make([][]rune, minHeight, maxLines) + m.value = make([][]any, minHeight, maxLines) m.col = 0 m.row = 0 m.SetCursorColumn(0) @@ -741,7 +1087,7 @@ func (m *Model) deleteWordLeft() { oldCol := m.col //nolint:ifshort m.SetCursorColumn(m.col - 1) - for unicode.IsSpace(m.value[m.row][m.col]) { + for isSpaceAt(m.value[m.row], m.col) { if m.col <= 0 { break } @@ -750,7 +1096,7 @@ func (m *Model) deleteWordLeft() { } for m.col > 0 { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col - 1) } else { if m.col > 0 { @@ -776,13 +1122,13 @@ func (m *Model) deleteWordRight() { oldCol := m.col - for m.col < len(m.value[m.row]) && unicode.IsSpace(m.value[m.row][m.col]) { + for m.col < len(m.value[m.row]) && isSpaceAt(m.value[m.row], m.col) { // ignore series of whitespace after cursor m.SetCursorColumn(m.col + 1) } for m.col < len(m.value[m.row]) { - if !unicode.IsSpace(m.value[m.row][m.col]) { + if !isSpaceAt(m.value[m.row], m.col) { m.SetCursorColumn(m.col + 1) } else { break @@ -832,13 +1178,13 @@ func (m *Model) characterLeft(insideLine bool) { func (m *Model) wordLeft() { for { m.characterLeft(true /* insideLine */) - if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) { + if m.col < len(m.value[m.row]) && !isSpaceAt(m.value[m.row], m.col) { break } } for m.col > 0 { - if unicode.IsSpace(m.value[m.row][m.col-1]) { + if isSpaceAt(m.value[m.row], m.col-1) { break } m.SetCursorColumn(m.col - 1) @@ -854,7 +1200,7 @@ func (m *Model) wordRight() { func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // Skip spaces forward. - for m.col >= len(m.value[m.row]) || unicode.IsSpace(m.value[m.row][m.col]) { + for m.col >= len(m.value[m.row]) || isSpaceAt(m.value[m.row], m.col) { if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) { // End of text. break @@ -864,7 +1210,7 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { charIdx := 0 for m.col < len(m.value[m.row]) { - if unicode.IsSpace(m.value[m.row][m.col]) { + if isSpaceAt(m.value[m.row], m.col) { break } fn(charIdx, m.col) @@ -876,14 +1222,18 @@ func (m *Model) doWordRight(fn func(charIdx int, pos int)) { // uppercaseRight changes the word to the right to uppercase. func (m *Model) uppercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToUpper(r) + } }) } // lowercaseRight changes the word to the right to lowercase. func (m *Model) lowercaseRight() { m.doWordRight(func(_ int, i int) { - m.value[m.row][i] = unicode.ToLower(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToLower(r) + } }) } @@ -891,7 +1241,9 @@ func (m *Model) lowercaseRight() { func (m *Model) capitalizeRight() { m.doWordRight(func(charIdx int, i int) { if charIdx == 0 { - m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i]) + if r, ok := m.value[m.row][i].(rune); ok { + m.value[m.row][i] = unicode.ToTitle(r) + } } }) } @@ -905,34 +1257,39 @@ func (m Model) LineInfo() LineInfo { // m.col and counting the number of runes that we need to skip. var counter int for i, line := range grid { - // We've found the line that we are on - if counter+len(line) == m.col && i+1 < len(grid) { - // We wrap around to the next line if we are at the end of the - // previous line so that we can be at the very beginning of the row - return LineInfo{ - CharOffset: 0, - ColumnOffset: 0, - Height: len(grid), - RowOffset: i + 1, - StartColumn: m.col, - Width: len(grid[i+1]), - CharWidth: uniseg.StringWidth(string(line)), - } - } + start := counter + end := counter + len(line) + + if m.col >= start && m.col <= end { + // This is the wrapped line the cursor is on. + + // Special case: if the cursor is at the end of a wrapped line, + // and there's another wrapped line after it, the cursor should + // be considered at the beginning of the next line. + if m.col == end && i < len(grid)-1 { + nextLine := grid[i+1] + return LineInfo{ + CharOffset: 0, + ColumnOffset: 0, + Height: len(grid), + RowOffset: i + 1, + StartColumn: end, + Width: len(nextLine), + CharWidth: uniseg.StringWidth(interfacesToString(nextLine)), + } + } - if counter+len(line) >= m.col { return LineInfo{ - CharOffset: uniseg.StringWidth(string(line[:max(0, m.col-counter)])), - ColumnOffset: m.col - counter, + CharOffset: uniseg.StringWidth(interfacesToString(line[:max(0, m.col-start)])), + ColumnOffset: m.col - start, Height: len(grid), RowOffset: i, - StartColumn: counter, + StartColumn: start, Width: len(line), - CharWidth: uniseg.StringWidth(string(line)), + CharWidth: uniseg.StringWidth(interfacesToString(line)), } } - - counter += len(line) + counter = end } return LineInfo{} } @@ -1060,12 +1417,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd + if m.row >= len(m.value) { + m.value = append(m.value, make([]any, 0)) + } if m.value[m.row] == nil { - m.value[m.row] = make([]rune, 0) + m.value[m.row] = make([]any, 0) } if m.MaxHeight > 0 && m.MaxHeight != m.cache.Capacity() { - m.cache = NewMemoCache[line, [][]rune](m.MaxHeight) + m.cache = NewMemoCache[line, [][]any](m.MaxHeight) } switch msg := msg.(type) { @@ -1093,11 +1453,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.mergeLineAbove(m.row) break } - if len(m.value[m.row]) > 0 { - m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...) - if m.col > 0 { - m.SetCursorColumn(m.col - 1) - } + if len(m.value[m.row]) > 0 && m.col > 0 { + m.value[m.row] = slices.Delete(m.value[m.row], m.col-1, m.col) + m.SetCursorColumn(m.col - 1) } case key.Matches(msg, m.KeyMap.DeleteCharacterForward): if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) { @@ -1154,7 +1512,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.transposeLeft() default: - m.insertRunesFromUserInput([]rune(msg.Text)) + m.insertRunesFromUserInput([]rune{msg.Code}) } case pasteMsg: @@ -1226,7 +1584,8 @@ func (m Model) View() string { widestLineNumber = lnw } - strwidth := uniseg.StringWidth(string(wrappedLine)) + wrappedLineStr := interfacesToString(wrappedLine) + strwidth := uniseg.StringWidth(wrappedLineStr) padding := m.width - strwidth // If the trailing space causes the line to be wider than the // width, we should not draw it to the screen since it will result @@ -1236,22 +1595,46 @@ func (m Model) View() string { // The character causing the line to be wider than the width is // guaranteed to be a space since any other character would // have been wrapped. - wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) + wrappedLineStr = strings.TrimSuffix(wrappedLineStr, " ") padding -= m.width - strwidth } + if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) + // Render the part of the line before the cursor + s.WriteString( + m.renderLineWithAttachments( + wrappedLine[:lineInfo.ColumnOffset], + style, + ), + ) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.virtualCursor.SetChar(" ") s.WriteString(m.virtualCursor.View()) + } else if lineInfo.ColumnOffset < len(wrappedLine) { + // Render the item under the cursor + item := wrappedLine[lineInfo.ColumnOffset] + if att, ok := item.(*Attachment); ok { + // Item at cursor is an attachment. Render it with the selection style. + // This becomes the "cursor" visually. + s.WriteString(m.Styles.SelectedAttachment.Render(att.Display)) + } else { + // Item at cursor is a rune. Render it with the virtual cursor. + m.virtualCursor.SetChar(string(item.(rune))) + s.WriteString(style.Render(m.virtualCursor.View())) + } + + // Render the part of the line after the cursor + s.WriteString(m.renderLineWithAttachments(wrappedLine[lineInfo.ColumnOffset+1:], style)) } else { - m.virtualCursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + // Cursor is at the end of the line + m.virtualCursor.SetChar(" ") s.WriteString(style.Render(m.virtualCursor.View())) - s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { - s.WriteString(style.Render(string(wrappedLine))) + s.WriteString(m.renderLineWithAttachments(wrappedLine, style)) } + s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) s.WriteRune('\n') newLines++ @@ -1443,12 +1826,12 @@ func (m Model) Cursor() *tea.Cursor { return c } -func (m Model) memoizedWrap(runes []rune, width int) [][]rune { - input := line{runes: runes, width: width} +func (m Model) memoizedWrap(content []any, width int) [][]any { + input := line{content: content, width: width} if v, ok := m.cache.Get(input); ok { return v } - v := wrap(runes, width) + v := wrapInterfaces(content, width) m.cache.Set(input, v) return v } @@ -1514,8 +1897,7 @@ func (m *Model) splitLine(row, col int) { // the cursor, take the content after the cursor and make it the content of // the line underneath, and shift the remaining lines down by one head, tailSrc := m.value[row][:col], m.value[row][col:] - tail := make([]rune, len(tailSrc)) - copy(tail, tailSrc) + tail := copyInterfaceSlice(tailSrc) m.value = append(m.value[:row+1], m.value[row:]...) @@ -1535,66 +1917,84 @@ func Paste() tea.Msg { return pasteMsg(str) } -func wrap(runes []rune, width int) [][]rune { +func wrapInterfaces(content []any, width int) [][]any { + if width <= 0 { + return [][]any{content} + } + var ( - lines = [][]rune{{}} - word = []rune{} - row int - spaces int + lines = [][]any{{}} + word = []any{} + wordW int + lineW int + spaceW int + inSpaces bool ) - // Word wrap the runes - for _, r := range runes { - if unicode.IsSpace(r) { - spaces++ - } else { - word = append(word, r) + for _, item := range content { + itemW := 0 + isSpace := false + + if r, ok := item.(rune); ok { + if unicode.IsSpace(r) { + isSpace = true + } + itemW = rw.RuneWidth(r) + } else if att, ok := item.(*Attachment); ok { + itemW = uniseg.StringWidth(att.Display) } - if spaces > 0 { //nolint:nestif - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces > width { - row++ - lines = append(lines, []rune{}) - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } else { - lines[row] = append(lines[row], word...) - lines[row] = append(lines[row], repeatSpaces(spaces)...) - spaces = 0 - word = nil - } - } else { - // If the last character is a double-width rune, then we may not be able to add it to this line - // as it might cause us to go past the width. - lastCharLen := rw.RuneWidth(word[len(word)-1]) - if uniseg.StringWidth(string(word))+lastCharLen > width { - // If the current line has any content, let's move to the next - // line because the current word fills up the entire line. - if len(lines[row]) > 0 { - row++ - lines = append(lines, []rune{}) + if isSpace { + if !inSpaces { + // End of a word + 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 } - lines[row] = append(lines[row], word...) word = nil + wordW = 0 } + inSpaces = true + spaceW += itemW + } else { + 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 + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } + spaceW = 0 + } + inSpaces = false + word = append(word, item) + wordW += itemW } } - if uniseg.StringWidth(string(lines[row]))+uniseg.StringWidth(string(word))+spaces >= width { - lines = append(lines, []rune{}) - lines[row+1] = append(lines[row+1], word...) - // We add an extra space at the end of the line to account for the - // trailing space at the end of the previous soft-wrapped lines so that - // behaviour when navigating is consistent and so that we don't need to - // continually add edges to handle the last line of the wrapped input. - spaces++ - lines[row+1] = append(lines[row+1], repeatSpaces(spaces)...) - } else { - lines[row] = append(lines[row], word...) - spaces++ - lines[row] = append(lines[row], repeatSpaces(spaces)...) + // Handle any remaining word/spaces + if wordW > 0 { + if lineW > 0 && lineW+wordW > width { + lines = append(lines, word) + } else { + lines[len(lines)-1] = append(lines[len(lines)-1], word...) + } + } + if spaceW > 0 { + if lineW > 0 && lineW+spaceW > width { + lines = append(lines, []any{}) + } + for i := 0; i < spaceW; i++ { + lines[len(lines)-1] = append(lines[len(lines)-1], rune(' ')) + } } return lines diff --git a/packages/tui/internal/layout/flex_example_test.go b/packages/tui/internal/layout/flex_example_test.go deleted file mode 100644 index a03346eb..00000000 --- a/packages/tui/internal/layout/flex_example_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package layout_test - -import ( - "fmt" - "github.com/sst/opencode/internal/layout" -) - -func ExampleRender_withGap() { - // Create a horizontal layout with 3px gap between items - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 3, - }, - layout.FlexItem{View: "Item1"}, - layout.FlexItem{View: "Item2"}, - layout.FlexItem{View: "Item3"}, - ) - fmt.Println(result) - // Output: Item1 Item2 Item3 -} - -func ExampleRender_withGapAndJustify() { - // Create a horizontal layout with gap and space-between justification - result := layout.Render( - layout.FlexOptions{ - Direction: layout.Row, - Width: 30, - Height: 1, - Gap: 2, - Justify: layout.JustifySpaceBetween, - }, - layout.FlexItem{View: "A"}, - layout.FlexItem{View: "B"}, - layout.FlexItem{View: "C"}, - ) - fmt.Println(result) - // Output: A B C -} diff --git a/packages/tui/internal/layout/flex_test.go b/packages/tui/internal/layout/flex_test.go deleted file mode 100644 index cad38dc8..00000000 --- a/packages/tui/internal/layout/flex_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package layout - -import ( - "strings" - "testing" -) - -func TestFlexGap(t *testing.T) { - tests := []struct { - name string - opts FlexOptions - items []FlexItem - expected string - }{ - { - name: "Row with gap", - opts: FlexOptions{ - Direction: Row, - Width: 20, - Height: 1, - Gap: 2, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "Column with gap", - opts: FlexOptions{ - Direction: Column, - Width: 1, - Height: 5, - Gap: 1, - Align: AlignStart, - }, - items: []FlexItem{ - {View: "A", FixedSize: 1}, - {View: "B", FixedSize: 1}, - {View: "C", FixedSize: 1}, - }, - expected: "A\n \nB\n \nC", - }, - { - name: "Row with gap and justify space between", - opts: FlexOptions{ - Direction: Row, - Width: 15, - Height: 1, - Gap: 1, - Justify: JustifySpaceBetween, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "A B C", - }, - { - name: "No gap specified", - opts: FlexOptions{ - Direction: Row, - Width: 10, - Height: 1, - }, - items: []FlexItem{ - {View: "A"}, - {View: "B"}, - {View: "C"}, - }, - expected: "ABC", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := Render(tt.opts, tt.items...) - // Trim any trailing spaces for comparison - result = strings.TrimRight(result, " ") - expected := strings.TrimRight(tt.expected, " ") - - if result != expected { - t.Errorf("Render() = %q, want %q", result, expected) - } - }) - } -} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 29235229..150a1b26 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -52,7 +52,9 @@ type appModel struct { messages chat.MessagesComponent completions dialog.CompletionDialog commandProvider dialog.CompletionProvider + fileProvider dialog.CompletionProvider showCompletionDialog bool + fileCompletionActive bool leaderBinding *key.Binding isLeaderSequence bool toastManager *toast.ToastManager @@ -180,11 +182,33 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { !a.showCompletionDialog && a.editor.Value() == "" { a.showCompletionDialog = true + a.fileCompletionActive = false updated, cmd := a.editor.Update(msg) a.editor = updated.(chat.EditorComponent) cmds = append(cmds, cmd) + // Set command provider for command completion + a.completions = dialog.NewCompletionDialogComponent(a.commandProvider) + updated, cmd = a.completions.Update(msg) + a.completions = updated.(dialog.CompletionDialog) + cmds = append(cmds, cmd) + + return a, tea.Sequence(cmds...) + } + + // Handle file completions trigger + if keyString == "@" && + !a.showCompletionDialog { + a.showCompletionDialog = true + a.fileCompletionActive = true + + updated, cmd := a.editor.Update(msg) + a.editor = updated.(chat.EditorComponent) + cmds = append(cmds, cmd) + + // Set file provider for file completion + a.completions = dialog.NewCompletionDialogComponent(a.fileProvider) updated, cmd = a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -194,7 +218,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showCompletionDialog { switch keyString { - case "tab", "enter", "esc", "ctrl+c": + case "tab", "enter", "esc", "ctrl+c", "up", "down": updated, cmd := a.completions.Update(msg) a.completions = updated.(dialog.CompletionDialog) cmds = append(cmds, cmd) @@ -326,10 +350,11 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, toast.NewErrorToast(msg.Error()) case app.SendMsg: a.showCompletionDialog = false - cmd := a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) + a.app, cmd = a.app.SendChatMessage(context.Background(), msg.Text, msg.Attachments) cmds = append(cmds, cmd) case dialog.CompletionDialogCloseMsg: a.showCompletionDialog = false + a.fileCompletionActive = false case opencode.EventListResponseEventInstallationUpdated: return a, toast.NewSuccessToast( "opencode updated to "+msg.Properties.Version+", restart to apply.", @@ -778,11 +803,8 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) return nil } os.Remove(tmpfile.Name()) - // attachments := m.attachments - // m.attachments = nil return app.SendMsg{ - Text: string(content), - Attachments: []app.Attachment{}, // attachments, + Text: string(content), } }) cmds = append(cmds, cmd) @@ -954,6 +976,7 @@ 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) messages := chat.NewMessagesComponent(app) editor := chat.NewEditorComponent(app) @@ -972,9 +995,11 @@ func NewModel(app *app.App) tea.Model { messages: messages, completions: completions, commandProvider: commandProvider, + fileProvider: fileProvider, leaderBinding: leaderBinding, isLeaderSequence: false, showCompletionDialog: false, + fileCompletionActive: false, toastManager: toast.NewToastManager(), interruptKeyState: InterruptKeyIdle, fileViewer: fileviewer.New(app), From 94ef341c9dfd59a070ed4c855e973f99009bcf7e Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 10:54:53 -0500 Subject: [PATCH 17/24] feat(tui): render attachments --- .../tui/internal/components/chat/message.go | 7 ++- .../tui/internal/components/chat/messages.go | 47 ++++++++++++++++++- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go index 4dde09ea..9e245c8b 100644 --- a/packages/tui/internal/components/chat/message.go +++ b/packages/tui/internal/components/chat/message.go @@ -223,6 +223,7 @@ func renderText( showToolDetails bool, highlight bool, width int, + extra string, toolCalls ...opencode.ToolInvocationPart, ) string { t := theme.CurrentTheme() @@ -269,7 +270,11 @@ func renderText( } } - content = strings.Join([]string{content, info}, "\n") + sections := []string{content, info} + if extra != "" { + sections = append(sections, "\n"+extra) + } + content = strings.Join(sections, "\n") switch message.Role { case opencode.MessageRoleUser: diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 52288078..d8060c7d 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -9,6 +9,7 @@ import ( "github.com/sst/opencode-sdk-go" "github.com/sst/opencode/internal/app" "github.com/sst/opencode/internal/components/dialog" + "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" @@ -133,10 +134,49 @@ func (m *messagesComponent) renderView(width int) { switch message.Role { case opencode.MessageRoleUser: - for _, part := range message.Parts { + for partIndex, part := range message.Parts { switch part := part.AsUnion().(type) { case opencode.TextPart: - key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount) + remainingParts := message.Parts[partIndex+1:] + fileParts := make([]opencode.FilePart, 0) + for _, part := range remainingParts { + switch part := part.AsUnion().(type) { + case opencode.FilePart: + fileParts = append(fileParts, part) + } + } + flexItems := []layout.FlexItem{} + if len(fileParts) > 0 { + fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) + mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) + for _, filePart := range fileParts { + mediaType := "" + switch filePart.MediaType { + case "text/plain": + mediaType = "txt" + case "image/png", "image/jpeg", "image/gif", "image/webp": + mediaType = "img" + case "application/pdf": + mediaType = "pdf" + } + + flexItems = append(flexItems, layout.FlexItem{ + View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), + }) + } + } + bgColor := t.BackgroundPanel() + files := layout.Render( + layout.FlexOptions{ + Background: &bgColor, + Width: width - 6, + Direction: layout.Row, + Gap: 3, + }, + flexItems..., + ) + + key := m.cache.GenerateKey(message.ID, part.Text, width, m.selectedPart == m.partCount, files) content, cached = m.cache.Get(key) if !cached { content = renderText( @@ -147,6 +187,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + files, ) m.cache.Set(key, content) } @@ -206,6 +247,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) m.cache.Set(key, content) @@ -219,6 +261,7 @@ func (m *messagesComponent) renderView(width int) { m.showToolDetails, m.partCount == m.selectedPart, width, + "", toolCallParts..., ) } From f6108b7be87c06e8fbebb7f52c71ad54438742af Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:13:09 -0500 Subject: [PATCH 18/24] fix(tui): handle pdf and image @ files --- packages/tui/internal/components/chat/editor.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 595fd4d5..427fcc3c 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -93,12 +93,24 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // The cursor is now at `atIndex` after the replacement. filePath := msg.CompletionValue fileName := filepath.Base(filePath) + extension := filepath.Ext(filePath) + mediaType := "" + switch extension { + case ".jpg": + mediaType = "image/jpeg" + case ".png", ".jpeg", ".gif", ".webp": + mediaType = "image/" + extension[1:] + case ".pdf": + mediaType = "application/pdf" + default: + mediaType = "text/plain" + } attachment := &textarea.Attachment{ ID: uuid.NewString(), Display: "@" + fileName, URL: fmt.Sprintf("file://%s", filePath), Filename: fileName, - MediaType: "text/plain", + MediaType: mediaType, } m.textarea.InsertAttachment(attachment) m.textarea.InsertString(" ") From 32d5db4f0a0b0c1a90ba4301cbf0bb7bc2519613 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:16:38 -0500 Subject: [PATCH 19/24] fix(tui): markdown wrapping off sometimes --- packages/tui/internal/util/file.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/internal/util/file.go b/packages/tui/internal/util/file.go index 2c0987dc..b079f24c 100644 --- a/packages/tui/internal/util/file.go +++ b/packages/tui/internal/util/file.go @@ -83,7 +83,7 @@ func Extension(path string) string { } func ToMarkdown(content string, width int, backgroundColor compat.AdaptiveColor) string { - r := styles.GetMarkdownRenderer(width-7, backgroundColor) + r := styles.GetMarkdownRenderer(width-6, backgroundColor) content = strings.ReplaceAll(content, RootPath+"/", "") rendered, _ := r.Render(content) lines := strings.Split(rendered, "\n") From ee01f01271f1e8c04a0efeacad0c36a44fd18515 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:16:55 -0400 Subject: [PATCH 20/24] file attachments --- packages/opencode/src/session/index.ts | 64 +++++++++++++++---- .../tui/internal/components/chat/editor.go | 2 +- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5a2c1b5e..437ce09b 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -1,4 +1,4 @@ -import path from "path" +import path from "node:path" import { App } from "../app/app" import { Identifier } from "../id/id" import { Storage } from "../storage/storage" @@ -15,6 +15,7 @@ import { type UIMessage, type ProviderMetadata, wrapLanguageModel, + type Attachment, } from "ai" import { z, ZodSchema } from "zod" import { Decimal } from "decimal.js" @@ -187,7 +188,6 @@ export namespace Session { export async function unshare(id: string) { const share = await getShare(id) if (!share) return - console.log("share", share) await Storage.remove("session/share/" + id) await update(id, (draft) => { draft.share = undefined @@ -361,6 +361,36 @@ export namespace Session { if (lastSummary) msgs = msgs.filter((msg) => msg.id >= lastSummary.id) const app = App.info() + input.parts = await Promise.all( + input.parts.map(async (part) => { + if (part.type === "file") { + const url = new URL(part.url) + switch (url.protocol) { + case "file:": + let content = await Bun.file( + path.join(app.path.cwd, url.pathname), + ).text() + const range = { + start: url.searchParams.get("start"), + end: url.searchParams.get("end"), + } + if (range.start != null) { + const lines = content.split("\n") + const start = parseInt(range.start) + const end = range.end ? parseInt(range.end) : lines.length + content = lines.slice(start, end).join("\n") + } + return { + type: "file", + url: "data:text/plain;base64," + btoa(content), + mediaType: "text/plain", + filename: part.filename, + } + } + } + return part + }), + ) if (msgs.length === 0 && !session.parentID) { generateText({ maxTokens: input.providerID === "google" ? 1024 : 20, @@ -376,7 +406,7 @@ export namespace Session { { role: "user", content: "", - parts: toParts(input.parts), + ...toParts(input.parts), }, ]), ], @@ -1028,7 +1058,7 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "assistant", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } @@ -1037,35 +1067,41 @@ function toUIMessage(msg: Message.Info): UIMessage { id: msg.id, role: "user", content: "", - parts: toParts(msg.parts), + ...toParts(msg.parts), } } throw new Error("not implemented") } -function toParts(parts: Message.MessagePart[]): UIMessage["parts"] { - const result: UIMessage["parts"] = [] +function toParts(parts: Message.MessagePart[]) { + const result: { + parts: UIMessage["parts"] + experimental_attachments: Attachment[] + } = { + parts: [], + experimental_attachments: [], + } for (const part of parts) { switch (part.type) { case "text": - result.push({ type: "text", text: part.text }) + result.parts.push({ type: "text", text: part.text }) break case "file": - result.push({ - type: "file", - data: part.url, - mimeType: part.mediaType, + result.experimental_attachments.push({ + url: part.url, + contentType: part.mediaType, + name: part.filename, }) break case "tool-invocation": - result.push({ + result.parts.push({ type: "tool-invocation", toolInvocation: part.toolInvocation, }) break case "step-start": - result.push({ + result.parts.push({ type: "step-start", }) break diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 427fcc3c..99925e16 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -108,7 +108,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { attachment := &textarea.Attachment{ ID: uuid.NewString(), Display: "@" + fileName, - URL: fmt.Sprintf("file://%s", filePath), + URL: fmt.Sprintf("file://./%s", filePath), Filename: fileName, MediaType: mediaType, } From b8d276a0494457dd59cd74ae57813ad23e432563 Mon Sep 17 00:00:00 2001 From: adamdottv <2363879+adamdottv@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:42:22 -0500 Subject: [PATCH 21/24] fix(tui): full paths for attachments --- packages/tui/internal/components/chat/editor.go | 7 ++++--- packages/tui/internal/components/chat/messages.go | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go index 99925e16..1516c0c2 100644 --- a/packages/tui/internal/components/chat/editor.go +++ b/packages/tui/internal/components/chat/editor.go @@ -109,7 +109,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ID: uuid.NewString(), Display: "@" + fileName, URL: fmt.Sprintf("file://./%s", filePath), - Filename: fileName, + Filename: filePath, MediaType: mediaType, } m.textarea.InsertAttachment(attachment) @@ -238,7 +238,8 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { } if len(value) > 0 && value[len(value)-1] == '\\' { // If the last character is a backslash, remove it and add a newline - m.textarea.SetValue(value[:len(value)-1] + "\n") + m.textarea.ReplaceRange(len(value)-1, len(value), "") + m.textarea.InsertString("\n") return m, nil } @@ -284,7 +285,7 @@ func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { // } // m.attachments = append(m.attachments, attachment) // } else { - m.textarea.SetValue(m.textarea.Value() + text) + m.textarea.InsertString(text) // } return m, nil } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index d8060c7d..49fdf723 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -156,10 +156,11 @@ func (m *messagesComponent) renderView(width int) { mediaType = "txt" case "image/png", "image/jpeg", "image/gif", "image/webp": mediaType = "img" + mediaTypeStyle = mediaTypeStyle.Background(t.Accent()) case "application/pdf": mediaType = "pdf" + mediaTypeStyle = mediaTypeStyle.Background(t.Primary()) } - flexItems = append(flexItems, layout.FlexItem{ View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), }) @@ -170,8 +171,7 @@ func (m *messagesComponent) renderView(width int) { layout.FlexOptions{ Background: &bgColor, Width: width - 6, - Direction: layout.Row, - Gap: 3, + Direction: layout.Column, }, flexItems..., ) From 06dba28bd69134535ad4a1482b7bbda9f26f96d6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 4 Jul 2025 12:50:41 -0400 Subject: [PATCH 22/24] wip: fix media type --- packages/opencode/src/session/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 437ce09b..2afba471 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -374,7 +374,7 @@ export namespace Session { start: url.searchParams.get("start"), end: url.searchParams.get("end"), } - if (range.start != null) { + if (range.start != null && part.mediaType === "text/plain") { const lines = content.split("\n") const start = parseInt(range.start) const end = range.end ? parseInt(range.end) : lines.length @@ -382,8 +382,8 @@ export namespace Session { } return { type: "file", - url: "data:text/plain;base64," + btoa(content), - mediaType: "text/plain", + url: `data:${part.mediaType};base64,` + btoa(content), + mediaType: part.mediaType, filename: part.filename, } } @@ -406,7 +406,7 @@ export namespace Session { { role: "user", content: "", - ...toParts(input.parts), + parts: toParts(input.parts).parts, }, ]), ], From 143fd8e07635274403874479a53f0b124ac5f433 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:33:23 -0400 Subject: [PATCH 23/24] docs: share improve markdown rendering of ai responses --- bun.lock | 8 ++-- packages/web/package.json | 1 + packages/web/src/components/MarkdownView.tsx | 32 ++++++++++--- packages/web/src/components/Share.tsx | 9 +--- .../src/components/markdownview.module.css | 48 +++++++++++++++++-- packages/web/src/components/share.module.css | 7 ++- 6 files changed, 78 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index a723e36b..0fec03e8 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,6 @@ "@openauthjs/openauth": "0.4.3", "@standard-schema/spec": "1.0.0", "ai": "catalog:", - "air": "0.4.14", "decimal.js": "10.5.0", "diff": "8.0.2", "env-paths": "3.0.0", @@ -79,6 +78,7 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", @@ -517,8 +517,6 @@ "ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="], - "air": ["air@0.4.14", "", { "dependencies": { "zephyr": "~1.3.5" } }, "sha512-E8bl9LlSGSQqjxxjeGIrpYpf8jVyJplsdK1bTobh61F7ks+3aLeXL4KbGSJIFsiaSSz5ZExLU51DGztmQSlZTQ=="], - "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1055,6 +1053,8 @@ "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "marked-shiki": ["marked-shiki@1.2.0", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-N924hp8veE6Mc91g5/kCNVoTU7TkeJfB2G2XEWb+k1fVA0Bck2T0rVt93d39BlOYH6ohP4Q9BFlPk+UkblhXbg=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], @@ -1703,8 +1703,6 @@ "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], - "zephyr": ["zephyr@1.3.6", "", {}, "sha512-oYH52DGZzIbXNrkijskaR8YpVKnXAe8jNgH1KirglVBnTFOn6mK9/0SVCxGn+73l0Hjhr4UYNzYkO07LXSWy6w=="], - "zod": ["zod@3.24.2", "", {}, "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="], "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], diff --git a/packages/web/package.json b/packages/web/package.json index 383b979f..c1722b2b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -24,6 +24,7 @@ "lang-map": "0.4.0", "luxon": "3.6.1", "marked": "15.0.12", + "marked-shiki": "1.2.0", "rehype-autolink-headings": "7.1.0", "sharp": "0.32.5", "shiki": "3.4.2", diff --git a/packages/web/src/components/MarkdownView.tsx b/packages/web/src/components/MarkdownView.tsx index 5e21c0d7..7a63bc0c 100644 --- a/packages/web/src/components/MarkdownView.tsx +++ b/packages/web/src/components/MarkdownView.tsx @@ -1,21 +1,39 @@ import { type JSX, splitProps, createResource } from "solid-js" import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" +import { transformerNotationDiff } from "@shikijs/transformers" import styles from "./markdownview.module.css" interface MarkdownViewProps extends JSX.HTMLAttributes { markdown: string } +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + function MarkdownView(props: MarkdownViewProps) { const [local, rest] = splitProps(props, ["markdown"]) - const [html] = createResource(() => local.markdown, async (markdown) => { - return marked.parse(markdown) - }) - - return ( -
+ const [html] = createResource( + () => local.markdown, + async (markdown) => { + return markedWithShiki.parse(markdown) + }, ) + + return
} export default MarkdownView - diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index ff838dab..7f2c45b1 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -294,15 +294,11 @@ function ResultsButton(props: ResultsButtonProps) { interface TextPartProps extends JSX.HTMLAttributes { text: string expand?: boolean - invert?: boolean - highlight?: boolean } function TextPart(props: TextPartProps) { const [local, rest] = splitProps(props, [ "text", "expand", - "invert", - "highlight", ]) const [expanded, setExpanded] = createSignal(false) const [overflowed, setOverflowed] = createSignal(false) @@ -332,8 +328,6 @@ function TextPart(props: TextPartProps) { return (
@@ -991,9 +985,9 @@ export default function Share(props: {
@@ -1021,7 +1015,6 @@ export default function Share(props: {
diff --git a/packages/web/src/components/markdownview.module.css b/packages/web/src/components/markdownview.module.css index a4360bde..9524c5cd 100644 --- a/packages/web/src/components/markdownview.module.css +++ b/packages/web/src/components/markdownview.module.css @@ -40,11 +40,17 @@ } pre { - white-space: pre-wrap; - border-radius: 0.25rem; - border: 1px solid rgba(0, 0, 0, 0.2); + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; padding: 0.5rem 0.75rem; + line-height: 1.6; font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } } code { @@ -61,4 +67,40 @@ } } } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } } diff --git a/packages/web/src/components/share.module.css b/packages/web/src/components/share.module.css index dafbdd8a..d8eac0e5 100644 --- a/packages/web/src/components/share.module.css +++ b/packages/web/src/components/share.module.css @@ -493,9 +493,8 @@ } } - &[data-highlight="true"] { - background-color: var(--sl-color-blue-low); - } + &[data-background="none"] { background-color: transparent; } + &[data-background="blue"] { background-color: var(--sl-color-blue-low); } &[data-expanded="true"] { pre { @@ -669,7 +668,7 @@ } .message-markdown { - background-color: var(--sl-color-bg-surface); + border: 1px solid var(--sl-color-blue-high); padding: 0.5rem calc(0.5rem + 3px); border-radius: 0.25rem; display: flex; From 994368de15f580d02f54fa244bac6375aece9a46 Mon Sep 17 00:00:00 2001 From: Jay V Date: Fri, 4 Jul 2025 13:53:20 -0400 Subject: [PATCH 24/24] docs: share fix scrolling again --- packages/web/src/components/Share.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 7f2c45b1..e2e880f6 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -601,6 +601,7 @@ export default function Share(props: { messages: Record }) { let lastScrollY = 0 + let hasScrolledToAnchor = false let scrollTimeout: number | undefined let scrollSentinel: HTMLElement | undefined let scrollObserver: IntersectionObserver | undefined @@ -954,9 +955,11 @@ export default function Share(props: { // Wait till all parts are loaded if ( hash !== "" + && !hasScrolledToAnchor && msg.parts.length === partIndex() + 1 && data().messages.length === msgIndex() + 1 ) { + hasScrolledToAnchor = true scrollToAnchor(hash) } })