Compare commits

..

7 Commits

Author SHA1 Message Date
Dax Raad cdb25656d5 improve snapshot speed 2025-07-03 20:16:25 -04:00
Jay V 25c876caa2 docs: share fix last message not expandable 2025-07-03 19:33:55 -04:00
Dax Raad cf83e31f23 add elixir lsp support 2025-07-03 19:29:51 -04:00
Dax Raad 3bc238b58b wip: logs 2025-07-03 19:29:51 -04:00
Jay V b8de69dced docs: fix share page scroll performance 2025-07-03 19:15:38 -04:00
Jay V e7fcb692a4 docs: tweak page title 2025-07-03 16:23:08 -04:00
Timo Clasen dae38574ab chore: add dev script (#666) 2025-07-03 14:43:25 -05:00
9 changed files with 235 additions and 52 deletions
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"packageManager": "bun@1.2.14",
"scripts": {
"dev": "bun run packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"stainless": "bun run ./packages/opencode/src/index.ts serve ",
"postinstall": "./scripts/hooks"
+4
View File
@@ -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,
+12 -4
View File
@@ -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 })
},
}
+1 -1
View File
@@ -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)
+58
View File
@@ -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),
}
},
}
}
+20 -7
View File
@@ -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,
+4 -4
View File
@@ -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)
+4
View File
@@ -36,6 +36,10 @@ if (isDocs) {
}
---
{ slug === "" && (
<title>{title} | AI coding agent built for the terminal</title>
)}
<Default {...Astro.props}><slot /></Default>
{ (isDocs || !slug.startsWith("s")) && (
+131 -36
View File
@@ -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)
@@ -597,6 +608,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 +617,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 +727,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 +745,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 +757,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 +785,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 +897,6 @@ export default function Share(props: {
</span>
)}
</div>
</div>
</div>
@@ -865,7 +906,7 @@ export default function Share(props: {
fallback={<p>Waiting for messages...</p>}
>
<div class={styles.parts}>
<SuspenseList>
<SuspenseList revealOrder="forwards">
<For each={data().messages}>
{(msg, msgIndex) => (
<Suspense>
@@ -880,8 +921,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 +947,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 +967,9 @@ export default function Share(props: {
{/* User text */}
<Match
when={
msg.role === "user" && part.type === "text" && part
msg.role === "user" &&
part.type === "text" &&
part
}
>
{(part) => (
@@ -972,7 +1020,9 @@ export default function Share(props: {
expand={isLastPart()}
text={stripEnclosingTag(part().text)}
/>
<Show when={isLastPart() && data().completed}>
<Show
when={isLastPart() && data().completed}
>
<span
data-part-footer
title={DateTime.fromMillis(
@@ -1041,7 +1091,8 @@ export default function Share(props: {
}
>
{(_part) => {
const matches = () => toolData()?.metadata?.matches
const matches = () =>
toolData()?.metadata?.matches
const splitArgs = () => {
const { pattern, ...rest } = toolData()?.args
return { pattern, rest }
@@ -1066,11 +1117,14 @@ export default function Share(props: {
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>Grep</span>
<b>&ldquo;{splitArgs().pattern}&rdquo;</b>
<b>
&ldquo;{splitArgs().pattern}&rdquo;
</b>
</div>
<Show
when={
Object.keys(splitArgs().rest).length > 0
Object.keys(splitArgs().rest)
.length > 0
}
>
<div data-part-tool-args>
@@ -1299,8 +1353,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 (
<div
@@ -1333,7 +1389,9 @@ export default function Share(props: {
</div>
</Match>
{/* Always try to show CodeBlock if preview is available (even if empty string) */}
<Match when={typeof preview() === 'string'}>
<Match
when={typeof preview() === "string"}
>
<div data-part-tool-result>
<ResultsButton
showCopy="Show preview"
@@ -1346,7 +1404,9 @@ export default function Share(props: {
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
lang={getShikiLang(
filePath(),
)}
code={preview()}
/>
</div>
@@ -1354,7 +1414,12 @@ export default function Share(props: {
</div>
</Match>
{/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
<Match when={typeof preview() !== 'string' && toolData()?.result}>
<Match
when={
typeof preview() !== "string" &&
toolData()?.result
}
>
<div data-part-tool-result>
<ResultsButton
results={showResults()}
@@ -1398,7 +1463,8 @@ export default function Share(props: {
data().rootDir,
),
)
const hasError = () => toolData()?.metadata?.error
const hasError = () =>
toolData()?.metadata?.error
const content = () => toolData()?.args?.content
const diagnostics = createMemo(() =>
getDiagnostics(
@@ -1415,7 +1481,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconDocumentPlus width={18} height={18} />
<IconDocumentPlus
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@@ -1435,7 +1504,7 @@ export default function Share(props: {
<div data-part-tool-result>
<ErrorPart>
{formatErrorString(
toolData()?.result
toolData()?.result,
)}
</ErrorPart>
</div>
@@ -1453,8 +1522,12 @@ export default function Share(props: {
<Show when={showResults()}>
<div data-part-tool-code>
<CodeBlock
lang={getShikiLang(filePath())}
code={toolData()?.args?.content}
lang={getShikiLang(
filePath(),
)}
code={
toolData()?.args?.content
}
/>
</div>
</Show>
@@ -1481,8 +1554,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 +1579,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconPencilSquare width={18} height={18} />
<IconPencilSquare
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@@ -1527,7 +1605,9 @@ export default function Share(props: {
<Match when={diff()}>
<div data-part-tool-edit>
<DiffView
class={styles["diff-code-block"]}
class={
styles["diff-code-block"]
}
diff={diff()}
lang={getShikiLang(filePath())}
/>
@@ -1556,9 +1636,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 +1652,10 @@ export default function Share(props: {
>
<div data-section="decoration">
<AnchorIcon id={anchor()}>
<IconCommandLine width={18} height={18} />
<IconCommandLine
width={18}
height={18}
/>
</AnchorIcon>
<div></div>
</div>
@@ -1604,7 +1690,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 +1758,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 (
<div
@@ -1793,7 +1882,8 @@ export default function Share(props: {
</Match>
<Match
when={
part().toolInvocation.state === "call"
part().toolInvocation.state ===
"call"
}
>
<TextPart
@@ -1839,7 +1929,10 @@ export default function Share(props: {
</Match>
<Match when={msg.role === "user"}>
<IconUserCircle width={18} height={18} />
<IconUserCircle
width={18}
height={18}
/>
</Match>
</Switch>
</AnchorIcon>
@@ -1848,7 +1941,9 @@ export default function Share(props: {
<div data-section="content">
<div data-part-tool-body>
<div data-part-title>
<span data-element-label>{part.type}</span>
<span data-element-label>
{part.type}
</span>
</div>
<TextPart
text={JSON.stringify(part, null, 2)}