Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 69 additions & 47 deletions bun.lock

Large diffs are not rendered by default.

43 changes: 35 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"worktree"
],
"files": [
"src",
"dist",
"tsconfig.json",
"bun.lock",
"README.md",
Expand All @@ -37,42 +37,69 @@
},
"scripts": {
"plugin:install": "bun scripts/install-plugin.ts dev",
"plugin:install:prod": "bun scripts/install-plugin.ts prod",
"plugin:reset": "bun scripts/install-plugin.ts reset",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "oxlint --ignore-pattern node_modules .",
"typecheck": "tsc --noEmit -p tsconfig.test.json",
"test": "bun test ./test/*.test.ts ./test/*.test.tsx --conditions browser",
"build": "bun scripts/build.ts",
"prepack": "bun scripts/build.ts",
"package:check": "bun scripts/package-check.ts",
"check": "prettier --check . && oxlint --ignore-pattern node_modules . && tsc --noEmit -p tsconfig.test.json && bun run test && bun run package:check",
"check": "prettier --check . && oxlint --ignore-pattern node_modules . && tsc --noEmit -p tsconfig.test.json && bun run test && bun run build && bun run package:check",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"setup": "node scripts/setup.mjs"
},
"exports": {
"./tui": "./src/tui.tsx",
"./server": "./src/server.ts"
"./tui": "./dist/tui.js",
"./server": "./dist/server.js"
},
"prettier": {
"semi": false,
"printWidth": 120
},
"dependencies": {
"@opencode-ai/plugin": "1.17.13",
"@opentui/core": "0.4.2",
"@opentui/keymap": "0.4.2",
"@opentui/solid": "0.4.2",
"solid-js": "1.9.13",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.4.3",
"@opentui/keymap": ">=0.4.3",
"@opentui/solid": ">=0.4.3",
"solid-js": ">=1.9.10"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/keymap": {
"optional": true
},
"@opentui/solid": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/preset-typescript": "7.27.1",
"@opencode-ai/sdk": "1.17.13",
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"@tsconfig/bun": "1.0.10",
"@types/bun": "1.3.13",
"babel-plugin-module-resolver": "5.0.2",
"babel-preset-solid": "1.9.12",
"oxlint": "1.60.0",
"prettier": "3.6.2",
"semantic-release": "25.0.5",
"solid-js": "1.9.10",
"typescript": "5.8.2",
"vitepress": "1.6.4"
}
Expand Down
83 changes: 83 additions & 0 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { join, relative, resolve } from "node:path"
import { transformAsync } from "@babel/core"
import { runtimeModuleIdForSpecifier } from "@opentui/core/runtime-plugin"
// @ts-expect-error untyped preset
import solidPreset from "babel-preset-solid"
// @ts-expect-error untyped preset
import tsPreset from "@babel/preset-typescript"
// @ts-expect-error untyped plugin
import moduleResolver from "babel-plugin-module-resolver"

const repoRoot = resolve(import.meta.dir, "..")
const srcDir = join(repoRoot, "src")
const distDir = join(repoRoot, "dist")
const hostRuntimeSpecifiers = new Set([
"@opentui/core",
"@opentui/core/testing",
"@opentui/keymap",
"@opentui/keymap/extras",
"@opentui/keymap/extras/graph",
"@opentui/keymap/addons",
"@opentui/keymap/addons/opentui",
"@opentui/keymap/html",
"@opentui/keymap/opentui",
"@opentui/keymap/react",
"@opentui/keymap/solid",
"@opentui/solid",
"@opentui/solid/components",
"@opentui/solid/jsx-runtime",
"@opentui/solid/jsx-dev-runtime",
"solid-js",
"solid-js/store",
])

function resolveImportPath(specifier: string): string | null {
if (hostRuntimeSpecifiers.has(specifier)) return runtimeModuleIdForSpecifier(specifier)
if (!specifier.startsWith(".")) return null
if (/\.(json|js|mjs|cjs)$/.test(specifier)) return specifier
if (/\.tsx?$/.test(specifier)) return specifier.replace(/\.tsx?$/, ".js")
return `${specifier}.js`
}

async function transformSolidSource(code: string, filename: string): Promise<string> {
const cleanFilename = filename.replace(/[?#].*$/, "")
const presets: unknown[] = []
if (/\.[cm]?[jt]sx$/.test(cleanFilename)) {
presets.push([solidPreset, { moduleName: runtimeModuleIdForSpecifier("@opentui/solid"), generate: "universal" }])
}
if (/\.[cm]?tsx?$/.test(cleanFilename)) {
presets.push([tsPreset])
}
const plugins = [[moduleResolver, { resolvePath: (specifier: string) => resolveImportPath(specifier) ?? specifier }]]
const result = await transformAsync(code, {
filename: cleanFilename,
configFile: false,
babelrc: false,
presets,
plugins,
})
return result?.code ?? code
}

async function listSourceFiles(): Promise<string[]> {
const entries = await readdir(srcDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
.map((entry) => join(srcDir, entry.name))
.sort()
}

await rm(distDir, { recursive: true, force: true })
await mkdir(distDir, { recursive: true })

const files = await listSourceFiles()
for (const file of files) {
const rel = relative(srcDir, file)
const outPath = join(distDir, rel.replace(/\.tsx?$/, ".js"))
const code = await readFile(file, "utf8")
const transformed = await transformSolidSource(code, rel)
await writeFile(outPath, transformed)
}

console.error(`built ${files.length} files to dist/`)
67 changes: 62 additions & 5 deletions scripts/install-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { join, resolve } from "node:path"

const repoRoot = resolve(import.meta.dir, "..")
const snapshotDir = join(homedir(), ".kagan", "plugin", "kagan-pinned")
const prodPackageDir = join(homedir(), ".kagan", "plugin", "prod")
const opencodeCacheDir = process.env.XDG_CACHE_HOME
? join(process.env.XDG_CACHE_HOME, "opencode")
: join(homedir(), ".cache", "opencode")
const globalConfigDir = process.env.XDG_CONFIG_HOME
? join(process.env.XDG_CONFIG_HOME, "opencode")
: join(homedir(), ".config", "opencode")
Expand Down Expand Up @@ -44,13 +48,31 @@ async function addGlobalPluginSpec(spec: string): Promise<void> {
await prettify(globalConfigFiles)
}

async function removeGlobalPluginSpecs(prefix: string): Promise<void> {
function pluginSpec(entry: unknown): string | undefined {
if (typeof entry === "string") return entry
if (!Array.isArray(entry)) return
return typeof entry[0] === "string" ? entry[0] : undefined
}

function isKaganSpec(spec: string): boolean {
return (
spec.startsWith(snapshotDir) ||
spec.startsWith(`file:${prodPackageDir}/`) ||
spec === "@kagan-sh/kagan" ||
spec.startsWith("@kagan-sh/kagan@")
)
}

async function removeGlobalKaganPluginSpecs(): Promise<void> {
const touched: string[] = []
for (const file of globalConfigFiles) {
if (!(await Bun.file(file).exists())) continue
const config = await readConfig(file)
const plugins = Array.isArray(config.plugin) ? (config.plugin as unknown[]) : []
const kept = plugins.filter((entry) => typeof entry !== "string" || !entry.startsWith(prefix))
const kept = plugins.filter((entry) => {
const spec = pluginSpec(entry)
return spec === undefined || !isKaganSpec(spec)
})
if (kept.length === plugins.length) continue
if (kept.length > 0) config.plugin = kept
else delete config.plugin
Expand All @@ -66,6 +88,24 @@ async function removeGlobalPluginSpecs(prefix: string): Promise<void> {
if (touched.length > 0) await prettify(touched)
}

async function run(args: string[], options?: { cwd?: string; stdout?: "pipe" | "inherit" }): Promise<string> {
const proc = Bun.spawn(args, {
cwd: options?.cwd ?? repoRoot,
stdout: options?.stdout ?? "pipe",
stderr: "inherit",
})
const [stdout, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
if (code !== 0) throw new Error(`${args.join(" ")} failed`)
return stdout
}

function parsePackedFilename(stdout: string): string {
const packed = JSON.parse(stdout) as Array<{ filename?: string }>
const filename = packed[0]?.filename
if (!filename) throw new Error("npm pack returned no package")
return filename
}

async function gitDescription(): Promise<string> {
const proc = Bun.spawn(["git", "-C", repoRoot, "describe", "--always", "--dirty"], {
stdout: "pipe",
Expand Down Expand Up @@ -102,15 +142,32 @@ async function installDev(): Promise<void> {
console.log("kagan loads in every folder. Re-run `bun run plugin:install` after edits, then restart opencode.")
}

async function packProductionPlugin(): Promise<string> {
await rm(prodPackageDir, { recursive: true, force: true })
await mkdir(prodPackageDir, { recursive: true })
const filename = parsePackedFilename(await run(["npm", "pack", "--json", "--pack-destination", prodPackageDir]))
return join(prodPackageDir, filename)
}

async function installProd(): Promise<void> {
const tgz = await packProductionPlugin()
await removeGlobalKaganPluginSpecs()
await rm(join(opencodeCacheDir, "packages", `file:${tgz}`), { recursive: true, force: true })
await run(["opencode", "plugin", `file:${tgz}`, "--global", "--force"], { stdout: "inherit" })
console.log(`Packed production plugin: ${tgz}`)
console.log("kagan loads from the packed package. Restart opencode to apply.")
}

async function reset(): Promise<void> {
await removeGlobalPluginSpecs(snapshotDir)
console.log("Removed the local kagan pin from the global config. Restart opencode to apply.")
await removeGlobalKaganPluginSpecs()
console.log("Removed kagan plugin entries from the global config. Restart opencode to apply.")
}

const mode = Bun.argv[2]
if (mode === "dev") await installDev()
else if (mode === "prod") await installProd()
else if (mode === "reset") await reset()
else {
console.error("Usage: bun scripts/install-plugin.ts <dev|reset>")
console.error("Usage: bun scripts/install-plugin.ts <dev|prod|reset>")
process.exit(1)
}
47 changes: 43 additions & 4 deletions scripts/package-check.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises"
import { mkdtemp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"

Expand All @@ -7,6 +7,8 @@ const repoRoot = resolve(import.meta.dir, "..")
type PackedFile = { path: string }
type PackResult = { filename: string; files: PackedFile[] }

const rawJsxPattern = /<(?:box|text)\b/

async function run(args: string[], cwd = repoRoot) {
const proc = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
const [stdout, stderr, code] = await Promise.all([
Expand All @@ -20,7 +22,7 @@ async function run(args: string[], cwd = repoRoot) {

async function expectedPackageFiles() {
const files = new Set(["package.json", "README.md", "LICENSE", "tsconfig.json", "bun.lock"])
for await (const file of new Bun.Glob("src/**/*").scan({ cwd: repoRoot, onlyFiles: true })) {
for await (const file of new Bun.Glob("dist/**/*").scan({ cwd: repoRoot, onlyFiles: true })) {
if ((await stat(join(repoRoot, file))).isFile()) files.add(file)
}
return [...files].sort()
Expand All @@ -43,6 +45,29 @@ function assertSameFiles(actual: string[], expected: string[]) {
}
}

function assertCompiledSolid(file: string) {
const source = Bun.file(join(repoRoot, file))
if (!source.size) throw new Error(`${file} is empty`)
return source.text().then((code) => {
if (rawJsxPattern.test(code)) throw new Error(`${file} still contains raw JSX`)
if (!code.includes("createComponent")) throw new Error(`${file} missing compiled Solid output`)
})
}

async function assertNoBundledHostDeps(pluginRoot: string) {
const nested = join(pluginRoot, "node_modules")
if (!(await Bun.file(nested).exists())) return
const entries = await readdir(nested)
for (const name of ["@opentui", "solid-js"]) {
if (entries.includes(name)) {
throw new Error(`packed install bundles host dependency under ${nested}/${name}`)
}
}
}

await run(["bun", "scripts/build.ts"])
await Promise.all([assertCompiledSolid("dist/tui.js"), assertCompiledSolid("dist/board.js")])

const expected = await expectedPackageFiles()
assertSameFiles(
parsePackJson(await run(["npm", "pack", "--dry-run", "--json"]))
Expand All @@ -59,19 +84,33 @@ try {
await writeFile(
join(consumer, "package.json"),
JSON.stringify(
{ type: "module", dependencies: { "@kagan-sh/kagan": `file:${join(dir, packed.filename)}` } },
{
type: "module",
dependencies: {
"@kagan-sh/kagan": `file:${join(dir, packed.filename)}`,
"@opentui/core": "0.4.3",
"@opentui/keymap": "0.4.3",
"@opentui/solid": "0.4.3",
"solid-js": "1.9.12",
},
},
null,
2,
),
)
await run(["npm", "install", "--ignore-scripts", "--omit=dev", "--no-audit", "--no-fund"], consumer)
const pluginRoot = join(consumer, "node_modules", "@kagan-sh", "kagan")
await assertNoBundledHostDeps(pluginRoot)
await run(
[
"bun",
"--conditions",
"browser",
"-e",
`const server = await import("@kagan-sh/kagan/server")
`import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
import { runtimeModules } from "@opentui/keymap/runtime-modules"
ensureRuntimePluginSupport({ additional: runtimeModules })
const server = await import("@kagan-sh/kagan/server")
const tui = await import("@kagan-sh/kagan/tui")
if (typeof server.default?.server !== "function") throw new Error("server export missing")
if (typeof tui.default?.tui !== "function") throw new Error("tui export missing")`,
Expand Down
17 changes: 13 additions & 4 deletions src/create-task.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,14 @@ function CreateTaskForm(props: {
void submit()
return true
}
if (
focusIndex() === 1 &&
((key.ctrl && key.name === "j") || key.name === "linefeed" || (key.shift && key.name === "return"))
) {
descriptionRef?.newLine()
return true
}
if (key.name === "return") {
// In the description textarea, let Enter reach the field as a newline
// (ctrl+enter submits); consuming it here would swallow the newline.
if (focusIndex() === 1) return false
if (focusIndex() >= 2) openPicker()
else void submit()
return true
Expand Down Expand Up @@ -224,8 +228,13 @@ function CreateTaskForm(props: {
tab <span style={{ fg: theme().textMuted }}>move</span>
</text>
<text fg={theme().text}>
{focusIndex() === 0 ? "enter" : "ctrl+enter"} <span style={{ fg: theme().textMuted }}>create</span>
enter <span style={{ fg: theme().textMuted }}>create</span>
</text>
<Show when={focusIndex() === 1}>
<text fg={theme().text}>
ctrl+j <span style={{ fg: theme().textMuted }}>newline</span>
</text>
</Show>
<text fg={theme().text}>
esc <span style={{ fg: theme().textMuted }}>cancel</span>
</text>
Expand Down
Loading