Merge origin/dev into pr-486-interactive-scrollbar

This commit is contained in:
honeycomb-tech 2025-07-01 14:11:39 -07:00
commit af6ded948e
32 changed files with 911 additions and 393 deletions

View file

@ -4,3 +4,4 @@
| ---------- | ---------------- | --------------- | --------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |

View file

@ -36,6 +36,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",
@ -541,6 +542,8 @@
"astro-expressive-code": ["astro-expressive-code@0.41.2", "", { "dependencies": { "rehype-expressive-code": "^0.41.2" }, "peerDependencies": { "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0" } }, "sha512-HN0jWTnhr7mIV/2e6uu4PPRNNo/k4UEgTLZqbp3MrHU+caCARveG2yZxaZVBmxyiVdYqW5Pd3u3n2zjnshixbw=="],
"async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
@ -633,6 +636,8 @@
"ci-info": ["ci-info@4.2.0", "", {}, "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg=="],
"clean-git-ref": ["clean-git-ref@2.0.1", "", {}, "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw=="],
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
@ -669,6 +674,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@ -725,6 +732,8 @@
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"diff3": ["diff3@0.0.3", "", {}, "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g=="],
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
@ -939,6 +948,8 @@
"ieee754": ["ieee754@1.1.13", "", {}, "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-meta-resolve": ["import-meta-resolve@4.1.0", "", {}, "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@ -987,6 +998,8 @@
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isomorphic-git": ["isomorphic-git@1.32.1", "", { "dependencies": { "async-lock": "^1.4.1", "clean-git-ref": "^2.0.1", "crc-32": "^1.2.0", "diff3": "0.0.3", "ignore": "^5.1.4", "minimisted": "^2.0.0", "pako": "^1.0.10", "path-browserify": "^1.0.1", "pify": "^4.0.1", "readable-stream": "^3.4.0", "sha.js": "^2.4.9", "simple-get": "^4.0.1" }, "bin": { "isogit": "cli.cjs" } }, "sha512-NZCS7qpLkCZ1M/IrujYBD31sM6pd/fMVArK4fz4I7h6m0rUW2AsYU7S7zXeABuHL6HIfW6l53b4UQ/K441CQjg=="],
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
"jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
@ -1169,6 +1182,8 @@
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"minimisted": ["minimisted@2.0.1", "", { "dependencies": { "minimist": "^1.2.5" } }, "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"mri": ["mri@1.1.4", "", {}, "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w=="],
@ -1247,7 +1262,7 @@
"pagefind": ["pagefind@1.3.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.3.0", "@pagefind/darwin-x64": "1.3.0", "@pagefind/linux-arm64": "1.3.0", "@pagefind/linux-x64": "1.3.0", "@pagefind/windows-x64": "1.3.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-8KPLGT5g9s+olKMRTU9LFekLizkVIu9tes90O1/aigJ0T5LmyPqTzGJrETnSw3meSYg58YH7JTzhTTW/3z6VAw=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@ -1257,6 +1272,8 @@
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@ -1267,6 +1284,8 @@
"picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
"pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="],
"pino": ["pino@7.11.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.0.0", "on-exit-leak-free": "^0.2.0", "pino-abstract-transport": "v0.5.0", "pino-std-serializers": "^4.0.0", "process-warning": "^1.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.1.0", "safe-stable-stringify": "^2.1.0", "sonic-boom": "^2.2.1", "thread-stream": "^0.15.1" }, "bin": { "pino": "bin.js" } }, "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg=="],
"pino-abstract-transport": ["pino-abstract-transport@0.5.0", "", { "dependencies": { "duplexify": "^4.1.2", "split2": "^4.0.0" } }, "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ=="],
@ -1417,6 +1436,8 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sha.js": ["sha.js@2.4.11", "", { "dependencies": { "inherits": "^2.0.1", "safe-buffer": "^5.0.1" }, "bin": { "sha.js": "./bin.js" } }, "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ=="],
"sharp": ["sharp@0.32.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ=="],
"shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@ -1793,6 +1814,8 @@
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.1.0", "", {}, "sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw=="],

View file

@ -37,6 +37,7 @@
"env-paths": "3.0.0",
"hono": "4.7.10",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"open": "10.1.2",
"remeda": "2.22.3",
"ts-lsp-client": "1.0.3",

View file

@ -36,12 +36,15 @@ export namespace App {
services: Map<any, { state: any; shutdown?: (input: any) => Promise<void> }>
}>("app")
export const use = ctx.use
const APP_JSON = "app.json"
export type Input = {
cwd: string
}
export const provideExisting = ctx.provide
export async function provide<T>(
input: Input,
cb: (app: App.Info) => Promise<T>,
@ -96,13 +99,16 @@ export namespace App {
}
return ctx.provide(app, async () => {
const result = await cb(app.info)
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
try {
const result = await cb(app.info)
return result
} finally {
for (const [key, entry] of app.services.entries()) {
if (!entry.shutdown) continue
log.info("shutdown", { name: key })
await entry.shutdown?.(await entry.state)
}
}
return result
})
}

View file

@ -1,5 +1,6 @@
import { App } from "../app/app"
import { ConfigHooks } from "../config/hooks"
import { FileWatcher } from "../file/watch"
import { Format } from "../format"
import { LSP } from "../lsp"
import { Share } from "../share/share"
@ -13,6 +14,7 @@ export async function bootstrap<T>(
Format.init()
ConfigHooks.init()
LSP.init()
FileWatcher.init()
return cb(app)
})

View file

@ -0,0 +1,26 @@
import { File } from "../../../file"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import path from "path"
export const FileCommand = cmd({
command: "file",
builder: (yargs) => yargs.command(FileReadCommand).demandCommand(),
async handler() {},
})
const FileReadCommand = cmd({
command: "read <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to read",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const content = await File.read(path.resolve(args.path))
console.log(content)
})
},
})

View file

@ -0,0 +1,28 @@
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
import { SnapshotCommand } from "./snapshot"
export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)
.command(SnapshotCommand)
.command({
command: "wait",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1_000 * 60 * 60 * 24),
)
})
},
})
.demandCommand(),
async handler() {},
})

View file

@ -0,0 +1,37 @@
import { LSP } from "../../../lsp"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
export const LSPCommand = cmd({
command: "lsp",
builder: (yargs) =>
yargs.command(DiagnosticsCommand).command(SymbolsCommand).demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
export const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
using _ = Log.Default.time("symbols")
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})

View file

@ -1,33 +1,19 @@
import { App } from "../../app/app"
import { Ripgrep } from "../../file/ripgrep"
import { LSP } from "../../lsp"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
import { App } from "../../../app/app"
import { Ripgrep } from "../../../file/ripgrep"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const DebugCommand = cmd({
command: "debug",
export const RipgrepCommand = cmd({
command: "rg",
builder: (yargs) =>
yargs
.command(DiagnosticsCommand)
.command(TreeCommand)
.command(SymbolsCommand)
.command(FilesCommand)
.command(SearchCommand)
.demandCommand(),
async handler() {},
})
const DiagnosticsCommand = cmd({
command: "diagnostics <file>",
builder: (yargs) =>
yargs.positional("file", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
})
},
})
const TreeCommand = cmd({
command: "tree",
builder: (yargs) =>
@ -42,20 +28,6 @@ const TreeCommand = cmd({
},
})
const SymbolsCommand = cmd({
command: "symbols <query>",
builder: (yargs) =>
yargs.positional("query", { type: "string", demandOption: true }),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile("./src/index.ts", true)
await new Promise((resolve) => setTimeout(resolve, 3000))
const results = await LSP.workspaceSymbol(args.query)
console.log(JSON.stringify(results, null, 2))
})
},
})
const FilesCommand = cmd({
command: "files",
builder: (yargs) =>
@ -85,3 +57,31 @@ const FilesCommand = cmd({
})
},
})
const SearchCommand = cmd({
command: "search <pattern>",
builder: (yargs) =>
yargs
.positional("pattern", {
type: "string",
demandOption: true,
description: "Search pattern",
})
.option("glob", {
type: "array",
description: "File glob patterns",
})
.option("limit", {
type: "number",
description: "Limit number of results",
}),
async handler(args) {
const results = await Ripgrep.search({
cwd: process.cwd(),
pattern: args.pattern,
glob: args.glob as string[] | undefined,
limit: args.limit,
})
console.log(JSON.stringify(results, null, 2))
},
})

View file

@ -0,0 +1,39 @@
import { Snapshot } from "../../../snapshot"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SnapshotCommand = cmd({
command: "snapshot",
builder: (yargs) =>
yargs
.command(SnapshotCreateCommand)
.command(SnapshotRestoreCommand)
.demandCommand(),
async handler() {},
})
export const SnapshotCreateCommand = cmd({
command: "create",
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const result = await Snapshot.create("test")
console.log(result)
})
},
})
export const SnapshotRestoreCommand = cmd({
command: "restore <commit>",
builder: (yargs) =>
yargs.positional("commit", {
type: "string",
description: "commit",
demandOption: true,
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await Snapshot.restore("test", args.commit)
console.log("restored")
})
},
})

View file

@ -1,7 +1,7 @@
import { App } from "../../app/app"
import { Provider } from "../../provider/provider"
import { Server } from "../../server/server"
import { Share } from "../../share/share"
import { bootstrap } from "../bootstrap"
import { cmd } from "./cmd"
export const ServeCommand = cmd({
@ -23,7 +23,7 @@ export const ServeCommand = cmd({
describe: "starts a headless opencode server",
handler: async (args) => {
const cwd = process.cwd()
await App.provide({ cwd }, async () => {
await bootstrap({ cwd }, async () => {
const providers = await Provider.list()
if (Object.keys(providers).length === 0) {
return "needs_provider"

View file

@ -1,5 +1,8 @@
import { z } from "zod"
import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
export namespace File {
export const Event = {
@ -10,4 +13,26 @@ export namespace File {
}),
),
}
export async function read(file: string) {
const content = await Bun.file(file).text()
const gitDiff = await $`git diff HEAD -- ${file}`
.cwd(path.dirname(file))
.quiet()
.nothrow()
.text()
if (gitDiff.trim()) {
const relativePath = path.relative(process.cwd(), file)
const originalContent = await $`git show HEAD:./${relativePath}`
.cwd(process.cwd())
.quiet()
.nothrow()
.text()
if (originalContent.trim()) {
const patch = createPatch(file, originalContent, content)
return patch
}
}
return content.trim()
}
}

View file

@ -1,3 +1,4 @@
// Ripgrep utility functions
import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
@ -8,6 +9,82 @@ import { $ } from "bun"
import { Fzf } from "./fzf"
export namespace Ripgrep {
const Stats = z.object({
elapsed: z.object({
secs: z.number(),
nanos: z.number(),
human: z.string(),
}),
searches: z.number(),
searches_with_match: z.number(),
bytes_searched: z.number(),
bytes_printed: z.number(),
matched_lines: z.number(),
matches: z.number(),
})
const Begin = z.object({
type: z.literal("begin"),
data: z.object({
path: z.object({
text: z.string(),
}),
}),
})
const Match = z.object({
type: z.literal("match"),
data: z.object({
path: z.object({
text: z.string(),
}),
lines: z.object({
text: z.string(),
}),
line_number: z.number(),
absolute_offset: z.number(),
submatches: z.array(
z.object({
match: z.object({
text: z.string(),
}),
start: z.number(),
end: z.number(),
}),
),
}),
})
const End = z.object({
type: z.literal("end"),
data: z.object({
path: z.object({
text: z.string(),
}),
binary_offset: z.number().nullable(),
stats: Stats,
}),
})
const Summary = z.object({
type: z.literal("summary"),
data: z.object({
elapsed_total: z.object({
human: z.string(),
nanos: z.number(),
secs: z.number(),
}),
stats: Stats,
}),
})
const Result = z.union([Begin, Match, End, Summary])
export type Result = z.infer<typeof Result>
export type Match = z.infer<typeof Match>
export type Begin = z.infer<typeof Begin>
export type End = z.infer<typeof End>
export type Summary = z.infer<typeof Summary>
const PLATFORM = {
darwin: { platform: "apple-darwin", extension: "tar.gz" },
linux: { platform: "unknown-linux-musl", extension: "tar.gz" },
@ -229,4 +306,45 @@ export namespace Ripgrep {
return lines.join("\n")
}
export async function search(input: {
cwd: string
pattern: string
glob?: string[]
limit?: number
}) {
const args = [
`${await filepath()}`,
"--json",
"--hidden",
"--glob='!.git/*'",
]
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
if (input.limit) {
args.push(`--max-count=${input.limit}`)
}
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
return []
}
const lines = result.text().trim().split("\n").filter(Boolean)
// Parse JSON lines from ripgrep output
return lines
.map((line) => JSON.parse(line))
.map((parsed) => Result.parse(parsed))
.filter((r) => r.type === "match")
.map((r) => r.data)
}
}

View file

@ -0,0 +1,50 @@
import { z } from "zod"
import { Bus } from "../bus"
import fs from "fs"
import { App } from "../app/app"
import { Log } from "../util/log"
export namespace FileWatcher {
const log = Log.create({ service: "file.watcher" })
export const Event = {
Updated: Bus.event(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("rename"), z.literal("change")]),
}),
),
}
export function init() {
App.state(
"file.watcher",
() => {
const app = App.use()
const watcher = fs.watch(
app.info.path.cwd,
{ recursive: true },
(event, file) => {
log.info("change", { file, event })
if (!file) return
// for some reason async local storage is lost here
// https://github.com/oven-sh/bun/issues/20754
App.provideExisting(app, async () => {
Bus.publish(Event.Updated, {
file,
event,
})
})
},
)
return {
watcher,
}
},
async (state) => {
state.watcher.close()
},
)()
}
}

View file

@ -12,6 +12,7 @@ import { Bus } from "../bus"
import z from "zod"
import type { LSPServer } from "./server"
import { NamedError } from "../util/error"
import { withTimeout } from "../util/timeout"
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" })
@ -52,7 +53,9 @@ export namespace LSPClient {
log.info("textDocument/publishDiagnostics", {
path,
})
const exists = diagnostics.has(path)
diagnostics.set(path, params.diagnostics)
if (!exists && serverID === "typescript") return
Bus.publish(Event.Diagnostics, { path, serverID })
})
connection.onRequest("workspace/configuration", async () => {
@ -61,7 +64,7 @@ export namespace LSPClient {
connection.listen()
log.info("sending initialize", { id: serverID })
await Promise.race([
await withTimeout(
connection.sendRequest("initialize", {
processId: server.process.pid,
workspaceFolders: [
@ -88,12 +91,10 @@ export namespace LSPClient {
},
},
}),
new Promise((_, reject) => {
setTimeout(() => {
reject(new InitializeError({ serverID }))
}, 5_000)
}),
])
5_000,
).catch(() => {
throw new InitializeError({ serverID })
})
await connection.sendNotification("initialized", {})
log.info("initialized")
@ -116,36 +117,28 @@ export namespace LSPClient {
const file = Bun.file(input.path)
const text = await file.text()
const version = files[input.path]
if (version === undefined) {
log.info("textDocument/didOpen", input)
if (version !== undefined) {
diagnostics.delete(input.path)
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
await connection.sendNotification("textDocument/didClose", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
return
}
log.info("textDocument/didChange", input)
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didChange", {
const extension = path.extname(input.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
version: ++files[input.path],
languageId,
version: 0,
text,
},
contentChanges: [
{
text,
},
],
})
files[input.path] = 0
return
},
},
get diagnostics() {
@ -157,35 +150,32 @@ export namespace LSPClient {
: path.resolve(app.path.cwd, input.path)
log.info("waiting for diagnostics", input)
let unsub: () => void
let timeout: NodeJS.Timeout
return await Promise.race([
new Promise<void>(async (resolve) => {
return await withTimeout(
new Promise<void>((resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.serverID
) {
log.info("got diagnostics", input)
clearTimeout(timeout)
unsub?.()
resolve()
}
})
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input)
unsub?.()
resolve()
}, 5000)
}),
])
3000,
)
.catch(() => {})
.finally(() => {
unsub?.()
})
},
async shutdown() {
log.info("shutting down")
log.info("shutting down", { serverID })
connection.end()
connection.dispose()
server.process.kill("SIGTERM")
log.info("shutdown", { serverID })
},
}

View file

@ -10,13 +10,29 @@ export namespace LSP {
const state = App.state(
"lsp",
async () => {
async (app) => {
log.info("initializing")
const clients = new Map<string, LSPClient.Info>()
const skip = new Set<string>()
for (const server of Object.values(LSPServer)) {
for (const extension of server.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
const handle = await server.spawn(App.info())
if (!handle) break
const client = await LSPClient.create(server.id, handle).catch(
() => {},
)
if (!client) break
clients.set(server.id, client)
break
}
}
log.info("initialized")
return {
clients,
skip,
}
},
async (state) => {
@ -27,49 +43,22 @@ export namespace LSP {
)
export async function init() {
log.info("init")
const app = App.info()
const result = Object.values(LSPServer).map(async (x) => {
for (const extension of x.extensions) {
const [file] = await Ripgrep.files({
cwd: app.path.cwd,
glob: "*" + extension,
})
if (!file) continue
await LSP.touchFile(file, true)
break
}
})
return Promise.all(result)
return state()
}
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
const extension = path.parse(input).ext
const s = await state()
const matches = Object.values(LSPServer).filter((x) =>
x.extensions.includes(extension),
)
for (const match of matches) {
const existing = s.clients.get(match.id)
if (existing) continue
if (s.skip.has(match.id)) continue
s.skip.add(match.id)
const handle = await match.spawn(App.info())
if (!handle) continue
const client = await LSPClient.create(match.id, handle).catch(() => {})
if (!client) {
s.skip.add(match.id)
continue
}
s.clients.set(match.id, client)
}
if (waitForDiagnostics) {
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input })
await client.notify.open({ path: input })
return wait
})
}
const matches = Object.values(LSPServer)
.filter((x) => x.extensions.includes(extension))
.map((x) => x.id)
await run(async (client) => {
if (!matches.includes(client.serverID)) return
const wait = waitForDiagnostics
? client.waitForDiagnostics({ path: input })
: Promise.resolve()
await client.notify.open({ path: input })
return wait
})
}
export async function diagnostics() {

View file

@ -123,4 +123,24 @@ export namespace LSPServer {
}
},
}
export const Pyright: Info = {
id: "pyright",
extensions: [".py", ".pyi"],
async spawn() {
const proc = spawn(
BunProc.which(),
["x", "pyright-langserver", "--stdio"],
{
env: {
...process.env,
BUN_BE_BUN: "1",
},
},
)
return {
process: proc,
}
},
}
}

View file

@ -246,6 +246,7 @@ export namespace Provider {
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
@ -288,9 +289,14 @@ export namespace Provider {
// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
if (provider.env.some((item) => process.env[item])) {
mergeProvider(providerID, {}, "env")
}
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}
// load apikeys

View file

@ -188,6 +188,11 @@ export namespace Message {
}),
})
.optional(),
user: z
.object({
snapshot: z.string().optional(),
})
.optional(),
})
.openapi({ ref: "MessageMetadata" }),
})

View file

@ -0,0 +1,85 @@
import { App } from "../app/app"
import {
add,
commit,
init,
checkout,
statusMatrix,
remove,
} from "isomorphic-git"
import path from "path"
import fs from "fs"
import { Ripgrep } from "../file/ripgrep"
import { Log } from "../util/log"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
export async function create(sessionID: string) {
const app = App.info()
const git = gitdir(sessionID)
const files = await Ripgrep.files({
cwd: app.path.cwd,
limit: app.git ? undefined : 1000,
})
// 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,
})
const status = await statusMatrix({
fs,
gitdir: git,
dir: app.path.cwd,
})
await add({
fs,
gitdir: git,
parallel: true,
dir: app.path.cwd,
filepath: files,
})
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,
})
}
}
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
}
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,
})
}
function gitdir(sessionID: string) {
const app = App.info()
return path.join(app.path.data, "snapshot", sessionID)
}
}

View file

@ -89,7 +89,7 @@ export const ReadTool = Tool.define({
output += "\n</file>"
// just warms the lsp client
await LSP.touchFile(filePath, true)
await LSP.touchFile(filePath, false)
FileTime.read(ctx.sessionID, filePath)
return {

View file

@ -0,0 +1,14 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.then((result) => {
clearTimeout(timeout)
return result
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
}, ms)
}),
])
}

View file

@ -21,9 +21,10 @@ import (
type EditorComponent interface {
tea.Model
tea.ViewModel
// tea.ViewModel
SetSize(width, height int) tea.Cmd
Content() string
View(width int, align lipgloss.Position) string
Content(width int, align lipgloss.Position) string
Lines() int
Value() string
Focused() bool
@ -105,7 +106,7 @@ func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
func (m *editorComponent) Content() string {
func (m *editorComponent) Content(width int, align lipgloss.Position) string {
t := theme.CurrentTheme()
base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
@ -121,7 +122,7 @@ func (m *editorComponent) Content() string {
)
textarea = styles.NewStyle().
Background(t.BackgroundElement()).
Width(m.width).
Width(width).
PaddingTop(1).
PaddingBottom(1).
BorderStyle(lipgloss.ThickBorder()).
@ -156,19 +157,19 @@ func (m *editorComponent) Content() string {
return content
}
func (m *editorComponent) View() string {
func (m *editorComponent) View(width int, align lipgloss.Position) string {
if m.Lines() > 1 {
t := theme.CurrentTheme()
return lipgloss.Place(
m.width,
width,
m.height,
lipgloss.Center,
align,
lipgloss.Center,
"",
styles.WhitespaceStyle(t.Background()),
)
}
return m.Content()
return m.Content(width, align)
}
func (m *editorComponent) Focused() bool {
@ -343,7 +344,6 @@ func createTextArea(existing *textarea.Model) textarea.Model {
ta.SetHeight(existing.Height())
}
// ta.Focus()
return ta
}

View file

@ -2,9 +2,7 @@ package chat
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
@ -20,6 +18,7 @@ import (
type MessagesComponent interface {
tea.Model
tea.ViewModel
// View(width int) string
SetSize(width, height int) tea.Cmd
PageUp() (tea.Model, tea.Cmd)
PageDown() (tea.Model, tea.Cmd)
@ -36,7 +35,6 @@ type messagesComponent struct {
width, height int
app *app.App
viewport viewport.Model
spinner spinner.Model
attachments viewport.Model
cache *MessageCache
rendering bool
@ -49,7 +47,7 @@ type renderFinishedMsg struct{}
type ToggleToolDetailsMsg struct{}
func (m *messagesComponent) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
return tea.Batch(m.viewport.Init())
}
func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -113,10 +111,6 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tail = m.viewport.AtBottom()
cmds = append(cmds, cmd)
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
@ -129,14 +123,15 @@ func (m *messagesComponent) renderView() {
defer measure("messageCount", len(m.app.Messages))
t := theme.CurrentTheme()
blocks := make([]string, 0)
align := lipgloss.Center
width := layout.Current.Container.Width
for _, message := range m.app.Messages {
sb := strings.Builder{}
util.WriteStringsPar(&sb, m.app.Messages, func(message opencode.Message) string {
var content string
var cached bool
blocks := make([]string, 0)
switch message.Role {
case opencode.MessageRoleUser:
@ -249,7 +244,6 @@ func (m *messagesComponent) renderView() {
}
}
}
}
error := ""
@ -272,20 +266,14 @@ func (m *messagesComponent) renderView() {
)
blocks = append(blocks, error)
}
}
centered := []string{}
for _, block := range blocks {
centered = append(centered, lipgloss.PlaceHorizontal(
m.width,
lipgloss.Center,
block+"\n",
styles.WhitespaceStyle(t.Background()),
))
}
return strings.Join(blocks, "\n\n")
})
content := sb.String()
m.viewport.SetHeight(m.height - lipgloss.Height(m.header()) + 1)
m.viewport.SetContent("\n" + strings.Join(centered, "\n"))
m.viewport.SetContent("\n" + content)
}
func (m *messagesComponent) header() string {
@ -464,10 +452,10 @@ func (m *messagesComponent) View() string {
if m.rendering {
return lipgloss.Place(
m.width,
m.height,
m.height+1,
lipgloss.Center,
lipgloss.Center,
"Loading session...",
styles.NewStyle().Background(t.Background()).Render("Loading session..."),
styles.WhitespaceStyle(t.Background()),
)
}
@ -557,12 +545,6 @@ func (m *messagesComponent) ToolDetailsVisible() bool {
}
func NewMessagesComponent(app *app.App) MessagesComponent {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New()
attachments := viewport.New()
// Don't disable the viewport's key bindings - this allows mouse scrolling to work
@ -571,7 +553,6 @@ func NewMessagesComponent(app *app.App) MessagesComponent {
return &messagesComponent{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolDetails: true,
cache: NewMessageCache(),

View file

@ -3,13 +3,11 @@ package dialog
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"sort"
"time"
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
@ -21,8 +19,9 @@ import (
)
const (
numVisibleModels = 6
maxDialogWidth = 40
numVisibleModels = 10
minDialogWidth = 40
maxDialogWidth = 80
)
// ModelDialog interface for the model selection dialog
@ -31,33 +30,61 @@ type ModelDialog interface {
}
type modelDialog struct {
app *app.App
availableProviders []opencode.Provider
provider opencode.Provider
width int
height int
hScrollOffset int
hScrollPossible bool
modal *modal.Modal
modelList list.List[list.StringItem]
app *app.App
allModels []ModelWithProvider
width int
height int
modal *modal.Modal
modelList list.List[ModelItem]
dialogWidth int
}
type ModelWithProvider struct {
Model opencode.Model
Provider opencode.Provider
}
type ModelItem struct {
ModelName string
ProviderName string
}
func (m ModelItem) Render(selected bool, width int) string {
t := theme.CurrentTheme()
if selected {
displayText := fmt.Sprintf("%s (%s)", m.ModelName, m.ProviderName)
return styles.NewStyle().
Background(t.Primary()).
Foreground(t.BackgroundElement()).
Width(width).
PaddingLeft(1).
Render(displayText)
} else {
modelStyle := styles.NewStyle().
Foreground(t.Text()).
Background(t.BackgroundElement())
providerStyle := styles.NewStyle().
Foreground(t.TextMuted()).
Background(t.BackgroundElement())
modelPart := modelStyle.Render(m.ModelName)
providerPart := providerStyle.Render(fmt.Sprintf(" (%s)", m.ProviderName))
combinedText := modelPart + providerPart
return styles.NewStyle().
Background(t.BackgroundElement()).
PaddingLeft(1).
Render(combinedText)
}
}
type modelKeyMap struct {
Left key.Binding
Right key.Binding
Enter key.Binding
Escape key.Binding
}
var modelKeys = modelKeyMap{
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "scroll left"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→", "scroll right"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "select model"),
@ -69,7 +96,7 @@ var modelKeys = modelKeyMap{
}
func (m *modelDialog) Init() tea.Cmd {
m.setupModelsForProvider(m.provider.ID)
m.setupAllModels()
return nil
}
@ -77,34 +104,20 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, modelKeys.Left):
if m.hScrollPossible {
m.switchProvider(-1)
}
return m, nil
case key.Matches(msg, modelKeys.Right):
if m.hScrollPossible {
m.switchProvider(1)
}
return m, nil
case key.Matches(msg, modelKeys.Enter):
selectedItem, _ := m.modelList.GetSelectedItem()
models := m.models()
var selectedModel opencode.Model
for _, model := range models {
if model.Name == string(selectedItem) {
selectedModel = model
break
}
_, selectedIndex := m.modelList.GetSelectedItem()
if selectedIndex >= 0 && selectedIndex < len(m.allModels) {
selectedModel := m.allModels[selectedIndex]
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: selectedModel.Provider,
Model: selectedModel.Model,
}),
)
}
return m, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(
app.ModelSelectedMsg{
Provider: m.provider,
Model: selectedModel,
}),
)
return m, util.CmdHandler(modal.CloseModalMsg{})
case key.Matches(msg, modelKeys.Escape):
return m, util.CmdHandler(modal.CloseModalMsg{})
}
@ -115,74 +128,124 @@ func (m *modelDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Update the list component
updatedList, cmd := m.modelList.Update(msg)
m.modelList = updatedList.(list.List[list.StringItem])
m.modelList = updatedList.(list.List[ModelItem])
return m, cmd
}
func (m *modelDialog) models() []opencode.Model {
models := slices.SortedFunc(maps.Values(m.provider.Models), func(a, b opencode.Model) int {
return strings.Compare(a.Name, b.Name)
})
return models
}
func (m *modelDialog) switchProvider(offset int) {
newOffset := m.hScrollOffset + offset
if newOffset < 0 {
newOffset = len(m.availableProviders) - 1
}
if newOffset >= len(m.availableProviders) {
newOffset = 0
}
m.hScrollOffset = newOffset
m.provider = m.availableProviders[m.hScrollOffset]
m.modal.SetTitle(fmt.Sprintf("Select %s Model", m.provider.Name))
m.setupModelsForProvider(m.provider.ID)
}
func (m *modelDialog) View() string {
listView := m.modelList.View()
scrollIndicator := m.getScrollIndicators(maxDialogWidth)
return strings.Join([]string{listView, scrollIndicator}, "\n")
return m.modelList.View()
}
func (m *modelDialog) getScrollIndicators(maxWidth int) string {
var indicator string
if m.hScrollPossible {
indicator = "← → (switch provider) "
}
if indicator == "" {
return ""
}
func (m *modelDialog) calculateOptimalWidth(modelItems []ModelItem) int {
maxWidth := minDialogWidth
t := theme.CurrentTheme()
return styles.NewStyle().
Foreground(t.TextMuted()).
Width(maxWidth).
Align(lipgloss.Right).
Render(indicator)
}
func (m *modelDialog) setupModelsForProvider(providerId string) {
models := m.models()
modelNames := make([]string, len(models))
for i, model := range models {
modelNames[i] = model.Name
}
m.modelList = list.NewStringList(modelNames, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(maxDialogWidth)
if m.app.Provider != nil && m.app.Model != nil && m.app.Provider.ID == providerId {
for i, model := range models {
if model.ID == m.app.Model.ID {
m.modelList.SetSelectedIndex(i)
break
}
for _, item := range modelItems {
// Calculate the width needed for this item: "ModelName (ProviderName)"
// Add 4 for the parentheses, space, and some padding
itemWidth := len(item.ModelName) + len(item.ProviderName) + 4
if itemWidth > maxWidth {
maxWidth = itemWidth
}
}
if maxWidth > maxDialogWidth {
maxWidth = maxDialogWidth
}
return maxWidth
}
func (m *modelDialog) setupAllModels() {
providers, _ := m.app.ListProviders(context.Background())
m.allModels = make([]ModelWithProvider, 0)
for _, provider := range providers {
for _, model := range provider.Models {
m.allModels = append(m.allModels, ModelWithProvider{
Model: model,
Provider: provider,
})
}
}
m.sortModels()
modelItems := make([]ModelItem, len(m.allModels))
for i, modelWithProvider := range m.allModels {
modelItems[i] = ModelItem{
ModelName: modelWithProvider.Model.Name,
ProviderName: modelWithProvider.Provider.Name,
}
}
m.dialogWidth = m.calculateOptimalWidth(modelItems)
m.modelList = list.NewListComponent(modelItems, numVisibleModels, "No models available", true)
m.modelList.SetMaxWidth(m.dialogWidth)
if len(m.allModels) > 0 {
m.modelList.SetSelectedIndex(0)
}
}
func (m *modelDialog) sortModels() {
sort.Slice(m.allModels, func(i, j int) bool {
modelA := m.allModels[i]
modelB := m.allModels[j]
usageA := m.getModelUsageTime(modelA.Provider.ID, modelA.Model.ID)
usageB := m.getModelUsageTime(modelB.Provider.ID, modelB.Model.ID)
// If both have usage times, sort by most recent first
if !usageA.IsZero() && !usageB.IsZero() {
return usageA.After(usageB)
}
// If only one has usage time, it goes first
if !usageA.IsZero() && usageB.IsZero() {
return true
}
if usageA.IsZero() && !usageB.IsZero() {
return false
}
// If neither has usage time, sort by release date desc if available
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate != "" {
dateA := m.parseReleaseDate(modelA.Model.ReleaseDate)
dateB := m.parseReleaseDate(modelB.Model.ReleaseDate)
if !dateA.IsZero() && !dateB.IsZero() {
return dateA.After(dateB)
}
}
// If only one has release date, it goes first
if modelA.Model.ReleaseDate != "" && modelB.Model.ReleaseDate == "" {
return true
}
if modelA.Model.ReleaseDate == "" && modelB.Model.ReleaseDate != "" {
return false
}
// If neither has usage time nor release date, fall back to alphabetical sorting
return modelA.Model.Name < modelB.Model.Name
})
}
func (m *modelDialog) parseReleaseDate(dateStr string) time.Time {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
return parsed
}
return time.Time{}
}
func (m *modelDialog) getModelUsageTime(providerID, modelID string) time.Time {
for _, usage := range m.app.State.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
return usage.LastUsed
}
}
return time.Time{}
}
func (m *modelDialog) Render(background string) string {
@ -194,32 +257,16 @@ func (s *modelDialog) Close() tea.Cmd {
}
func NewModelDialog(app *app.App) ModelDialog {
availableProviders, _ := app.ListProviders(context.Background())
currentProvider := availableProviders[0]
hScrollOffset := 0
if app.Provider != nil {
for i, provider := range availableProviders {
if provider.ID == app.Provider.ID {
currentProvider = provider
hScrollOffset = i
break
}
}
}
dialog := &modelDialog{
app: app,
availableProviders: availableProviders,
hScrollOffset: hScrollOffset,
hScrollPossible: len(availableProviders) > 1,
provider: currentProvider,
modal: modal.New(
modal.WithTitle(fmt.Sprintf("Select %s Model", currentProvider.Name)),
modal.WithMaxWidth(maxDialogWidth+4),
),
app: app,
}
dialog.setupModelsForProvider(currentProvider.ID)
dialog.setupAllModels()
dialog.modal = modal.New(
modal.WithTitle("Select Model"),
modal.WithMaxWidth(dialog.dialogWidth+4),
)
return dialog
}

View file

@ -1,6 +1,7 @@
package diff
import (
"bufio"
"bytes"
"fmt"
"image/color"
@ -148,101 +149,87 @@ func WithWidth(width int) UnifiedOption {
func ParseUnifiedDiff(diff string) (DiffResult, error) {
var result DiffResult
var currentHunk *Hunk
result.Hunks = make([]Hunk, 0, 10) // Pre-allocate with a reasonable capacity
hunkHeaderRe := regexp.MustCompile(`^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@`)
lines := strings.Split(diff, "\n")
scanner := bufio.NewScanner(strings.NewReader(diff))
var oldLine, newLine int
inFileHeader := true
for _, line := range lines {
// Parse file headers
for scanner.Scan() {
line := scanner.Text()
if inFileHeader {
if strings.HasPrefix(line, "--- a/") {
result.OldFile = strings.TrimPrefix(line, "--- a/")
result.OldFile = line[6:]
continue
}
if strings.HasPrefix(line, "+++ b/") {
result.NewFile = strings.TrimPrefix(line, "+++ b/")
result.NewFile = line[6:]
inFileHeader = false
continue
}
}
// Parse hunk headers
if matches := hunkHeaderRe.FindStringSubmatch(line); matches != nil {
if strings.HasPrefix(line, "@@") {
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
currentHunk = &Hunk{
Header: line,
Lines: []DiffLine{},
Lines: make([]DiffLine, 0, 10), // Pre-allocate
}
oldStart, _ := strconv.Atoi(matches[1])
newStart, _ := strconv.Atoi(matches[3])
oldLine = oldStart
newLine = newStart
// Manual parsing of hunk header is faster than regex
parts := strings.Split(line, " ")
if len(parts) > 2 {
oldRange := strings.Split(parts[1][1:], ",")
newRange := strings.Split(parts[2][1:], ",")
oldLine, _ = strconv.Atoi(oldRange[0])
newLine, _ = strconv.Atoi(newRange[0])
}
continue
}
// Ignore "No newline at end of file" markers
if strings.HasPrefix(line, "\\ No newline at end of file") {
if strings.HasPrefix(line, "\\ No newline at end of file") || currentHunk == nil {
continue
}
if currentHunk == nil {
continue
}
// Process the line based on its prefix
var dl DiffLine
dl.Content = line
if len(line) > 0 {
switch line[0] {
case '+':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: 0,
NewLineNo: newLine,
Kind: LineAdded,
Content: line[1:],
})
dl.Kind = LineAdded
dl.NewLineNo = newLine
dl.Content = line[1:]
newLine++
case '-':
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: 0,
Kind: LineRemoved,
Content: line[1:],
})
dl.Kind = LineRemoved
dl.OldLineNo = oldLine
dl.Content = line[1:]
oldLine++
default:
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: line,
})
default: // context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
} else {
// Handle empty lines
currentHunk.Lines = append(currentHunk.Lines, DiffLine{
OldLineNo: oldLine,
NewLineNo: newLine,
Kind: LineContext,
Content: "",
})
} else { // empty context line
dl.Kind = LineContext
dl.OldLineNo = oldLine
dl.NewLineNo = newLine
oldLine++
newLine++
}
currentHunk.Lines = append(currentHunk.Lines, dl)
}
// Add the last hunk if there is one
if currentHunk != nil {
result.Hunks = append(result.Hunks, *currentHunk)
}
return result, nil
return result, scanner.Err()
}
// HighlightIntralineChanges updates lines in a hunk to show character-level differences
@ -744,8 +731,6 @@ func renderLineContent(fileName string, dl DiffLine, bgStyle stylesi.Style, high
content,
width,
"...",
// stylesi.NewStyleWithColors(t.TextMuted(), bgStyle.GetBackground()).Render("..."),
// stylesi.WithForeground(stylesi.NewStyle().Background(bgStyle.GetBackground()), t.TextMuted()).Render("..."),
),
)
}
@ -912,10 +897,11 @@ func RenderUnifiedHunk(fileName string, h Hunk, opts ...UnifiedOption) string {
HighlightIntralineChanges(&hunkCopy)
var sb strings.Builder
for _, line := range hunkCopy.Lines {
sb.WriteString(renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()))
sb.WriteString("\n")
}
sb.Grow(len(hunkCopy.Lines) * config.Width)
util.WriteStringsPar(&sb, hunkCopy.Lines, func(line DiffLine) string {
return renderUnifiedLine(fileName, line, config.Width, theme.CurrentTheme()) + "\n"
})
return sb.String()
}
@ -969,32 +955,22 @@ func FormatUnifiedDiff(filename string, diffText string, opts ...UnifiedOption)
}
var sb strings.Builder
for _, h := range diffResult.Hunks {
unifiedDiff := RenderUnifiedHunk(filename, h, opts...)
sb.WriteString(unifiedDiff)
}
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
return RenderUnifiedHunk(filename, h, opts...)
})
return sb.String(), nil
}
// FormatDiff creates a side-by-side formatted view of a diff
func FormatDiff(filename string, diffText string, opts ...SideBySideOption) (string, error) {
// t := theme.CurrentTheme()
diffResult, err := ParseUnifiedDiff(diffText)
if err != nil {
return "", err
}
var sb strings.Builder
// config := NewSideBySideConfig(opts...)
util.WriteStringsPar(&sb, diffResult.Hunks, func(h Hunk) string {
// sb.WriteString(
// lipgloss.NewStyle().
// Background(t.DiffHunkHeader()).
// Foreground(t.Background()).
// Width(config.TotalWidth).
// Render(h.Header) + "\n",
// )
return RenderSideBySideHunk(filename, h, opts...)
})

View file

@ -5,19 +5,56 @@ import (
"fmt"
"log/slog"
"os"
"time"
"github.com/BurntSushi/toml"
)
type ModelUsage struct {
ProviderID string `toml:"provider_id"`
ModelID string `toml:"model_id"`
LastUsed time.Time `toml:"last_used"`
}
type State struct {
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
Theme string `toml:"theme"`
Provider string `toml:"provider"`
Model string `toml:"model"`
RecentlyUsedModels []ModelUsage `toml:"recently_used_models"`
}
func NewState() *State {
return &State{
Theme: "opencode",
Theme: "opencode",
RecentlyUsedModels: make([]ModelUsage, 0),
}
}
// UpdateModelUsage updates the recently used models list with the specified model
func (s *State) UpdateModelUsage(providerID, modelID string) {
now := time.Now()
// Check if this model is already in the list
for i, usage := range s.RecentlyUsedModels {
if usage.ProviderID == providerID && usage.ModelID == modelID {
s.RecentlyUsedModels[i].LastUsed = now
usage := s.RecentlyUsedModels[i]
copy(s.RecentlyUsedModels[1:i+1], s.RecentlyUsedModels[0:i])
s.RecentlyUsedModels[0] = usage
return
}
}
newUsage := ModelUsage{
ProviderID: providerID,
ModelID: modelID,
LastUsed: now,
}
// Prepend to slice and limit to last 50 entries
s.RecentlyUsedModels = append([]ModelUsage{newUsage}, s.RecentlyUsedModels...)
if len(s.RecentlyUsedModels) > 50 {
s.RecentlyUsedModels = s.RecentlyUsedModels[:50]
}
}

View file

@ -27,6 +27,10 @@ type LoadedTheme struct {
name string
}
func (t *LoadedTheme) Name() string {
return t.name
}
type colorRef struct {
value any
resolved bool

View file

@ -27,6 +27,10 @@ func NewSystemTheme(terminalBg color.Color, isDark bool) *SystemTheme {
return theme
}
func (t *SystemTheme) Name() string {
return "system"
}
// initializeColors sets up all theme colors
func (t *SystemTheme) initializeColors() {
// Generate gray scale based on terminal background

View file

@ -8,6 +8,8 @@ import (
// All colors must be defined as compat.AdaptiveColor to support
// both light and dark terminal backgrounds.
type Theme interface {
Name() string
// Background colors
Background() compat.AdaptiveColor // Radix 1
BackgroundPanel() compat.AdaptiveColor // Radix 2

View file

@ -381,6 +381,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.app.Model = &msg.Model
a.app.State.Provider = msg.Provider.ID
a.app.State.Model = msg.Model.ID
a.app.State.UpdateModelUsage(msg.Provider.ID, msg.Model.ID)
a.app.SaveState()
case dialog.ThemeSelectedMsg:
a.app.State.Theme = msg.ThemeName
@ -431,7 +432,19 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (a appModel) View() string {
editorView := a.editor.View()
mainLayout := a.chat(layout.Current.Container.Width, lipgloss.Center)
if a.modal != nil {
mainLayout = a.modal.Render(mainLayout)
}
mainLayout = a.toastManager.RenderOverlay(mainLayout)
if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
}
return mainLayout + "\n" + a.status.View()
}
func (a appModel) chat(width int, align lipgloss.Position) string {
editorView := a.editor.View(width, align)
lines := a.editor.Lines()
messagesView := a.messages.View()
if a.app.Session.ID == "" {
@ -442,7 +455,7 @@ func (a appModel) View() string {
t := theme.CurrentTheme()
centeredEditorView := lipgloss.PlaceHorizontal(
a.width,
lipgloss.Center,
align,
editorView,
styles.WhitespaceStyle(t.Background()),
)
@ -461,10 +474,6 @@ func (a appModel) View() string {
View: centeredEditorView,
FixedSize: 5,
},
// layout.FlexItem{
// View: a.status.View(),
// FixedSize: 1,
// },
)
if lines > 1 {
@ -474,7 +483,7 @@ func (a appModel) View() string {
mainLayout = layout.PlaceOverlay(
editorX,
editorY,
a.editor.Content(),
a.editor.Content(width, align),
mainLayout,
)
}
@ -495,14 +504,7 @@ func (a appModel) View() string {
)
}
if a.modal != nil {
mainLayout = a.modal.Render(mainLayout)
}
mainLayout = a.toastManager.RenderOverlay(mainLayout)
if theme.CurrentThemeUsesAnsiColors() {
mainLayout = util.ConvertRGBToAnsi16Colors(mainLayout)
}
return mainLayout + "\n" + a.status.View()
return mainLayout
}
func (a appModel) home() string {

View file

@ -42,7 +42,7 @@ export default defineConfig({
},
],
editLink: {
baseUrl: `${github}/edit/master/www/`,
baseUrl: `${github}/edit/dev/packages/web/`,
},
markdown: {
headingLinks: false,