Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 292a449632 |
@@ -34,25 +34,11 @@ jobs:
|
||||
|
||||
const now = Date.now();
|
||||
const twoHours = 2 * 60 * 60 * 1000;
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
if (teamAssociations.includes(item.author_association)) {
|
||||
core.info(`Skipping ${kind} #${item.number}: author association is ${item.author_association}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
if: github.event.action == 'opened' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
if: github.event.action == 'opened'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
|
||||
|
||||
recheck-compliance:
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)
|
||||
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -11,25 +11,22 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Check team membership
|
||||
id: team-check
|
||||
run: |
|
||||
LOGIN="${{ github.event.pull_request.user.login }}"
|
||||
ASSOCIATION="${{ github.event.pull_request.author_association }}"
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] || [ "$ASSOCIATION" = "OWNER" ] || [ "$ASSOCIATION" = "MEMBER" ] || [ "$ASSOCIATION" = "COLLABORATOR" ]; then
|
||||
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
|
||||
echo "is_team=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Skipping: $LOGIN is a team member or bot"
|
||||
else
|
||||
echo "is_team=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.team-check.outputs.is_team != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: steps.team-check.outputs.is_team != 'true'
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -28,9 +28,15 @@ jobs:
|
||||
|
||||
// Check if author is a team member or bot
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
if (teamAssociations.includes(pr.author_association)) {
|
||||
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/TEAM_MEMBERS',
|
||||
ref: 'dev'
|
||||
});
|
||||
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||
if (members.includes(login)) {
|
||||
console.log(`Skipping: ${login} is a team member`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,9 +175,15 @@ jobs:
|
||||
|
||||
// Check if author is a team member or bot
|
||||
if (login === 'opencode-agent[bot]') return;
|
||||
const teamAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
if (teamAssociations.includes(pr.author_association)) {
|
||||
console.log(`Skipping: ${login} has author association ${pr.author_association}`);
|
||||
const { data: file } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: '.github/TEAM_MEMBERS',
|
||||
ref: 'dev'
|
||||
});
|
||||
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
|
||||
if (members.includes(login)) {
|
||||
console.log(`Skipping: ${login} is a team member`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
//VITE_API_URL: gateway.url.apply((url) => url!),
|
||||
VITE_AUTH_URL: auth.url.apply((url) => url!),
|
||||
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
|
||||
PLACEHOLDER: "keepalive",
|
||||
},
|
||||
transform: {
|
||||
server: {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createHash } from "crypto"
|
||||
import { ZenData } from "@opencode-ai/console-core/model.js"
|
||||
|
||||
export async function GET() {
|
||||
const zenData = ZenData.list("full")
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hash: createHash("sha1").update(JSON.stringify(zenData)).digest("hex"),
|
||||
timestamp: Date.now(),
|
||||
check1: "alpha-di-k2.6" in zenData.models,
|
||||
check2: "qwen3.6-plus-free" in zenData.models,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,12 @@ export const Resource = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop: string) {
|
||||
console.log(`111 ${prop}`)
|
||||
if (`SST_RESOURCE_${prop}` in env) {
|
||||
console.log(`222 ${prop}`)
|
||||
// @ts-expect-error
|
||||
const value = env[`SST_RESOURCE_${prop}`]
|
||||
return typeof value === "string" ? JSON.parse(value) : value
|
||||
}
|
||||
if (prop in env) {
|
||||
// @ts-expect-error
|
||||
const value = env[prop]
|
||||
console.log(`333 ${value}`)
|
||||
return typeof value === "string" ? JSON.parse(value) : value
|
||||
} else if (prop === "App") {
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginBoot from "./boot"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { AccountV2 } from "../account"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
export const VercelPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("vercel"),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { it, model } from "./provider-helper"
|
||||
import { it, model, provider } from "./provider-helper"
|
||||
|
||||
const cerebrasOptions: Record<string, unknown>[] = []
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
type ContentPart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
} from "./schema"
|
||||
import { make as makeTool, type ToolSchema } from "./tool"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLMError, TransportReason } from "../../schema"
|
||||
import { Auth } from "../auth"
|
||||
import { LLMError, TransportReason, type LLMRequest } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Stream } from "effect"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import type { Tools } from "../../src/tool"
|
||||
import type { RunOptions } from "../../src/tool-runtime"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
// drizzle-kit check compares schema to migrations, exits non-zero if drift
|
||||
const result = await $`bun drizzle-kit check`.quiet().nothrow()
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
console.error("Schema has changes not captured in migrations!")
|
||||
console.error("Run: bun drizzle-kit generate")
|
||||
console.error("")
|
||||
console.error(result.stderr.toString())
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log("Migrations are up to date")
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import path from "path"
|
||||
const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
|
||||
await import(toDynamicallyImport)
|
||||
console.log(performance.now())
|
||||
@@ -1,153 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as path from "path"
|
||||
import * as ts from "typescript"
|
||||
|
||||
const BASE_DIR = "/home/thdxr/dev/projects/anomalyco/opencode/packages/opencode"
|
||||
|
||||
// Get entry file from command line arg or use default
|
||||
const ENTRY_FILE = process.argv[2] || "src/cli/cmd/tui/plugin/index.ts"
|
||||
|
||||
const visited = new Set<string>()
|
||||
|
||||
function resolveImport(importPath: string, fromFile: string): string | null {
|
||||
if (importPath.startsWith("@/")) {
|
||||
return path.join(BASE_DIR, "src", importPath.slice(2))
|
||||
}
|
||||
|
||||
if (importPath.startsWith("./") || importPath.startsWith("../")) {
|
||||
const dir = path.dirname(fromFile)
|
||||
return path.resolve(dir, importPath)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isInternalImport(importPath: string): boolean {
|
||||
return importPath.startsWith("@/") || importPath.startsWith("./") || importPath.startsWith("../")
|
||||
}
|
||||
|
||||
async function tryExtensions(filePath: string): Promise<string | null> {
|
||||
const extensions = [".ts", ".tsx", ".js", ".jsx"]
|
||||
|
||||
try {
|
||||
const file = Bun.file(filePath)
|
||||
const stat = await file.stat()
|
||||
|
||||
if (stat?.isDirectory()) {
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(filePath, "index" + ext)
|
||||
const indexFile = Bun.file(indexPath)
|
||||
if (await indexFile.exists()) return indexPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// It's a file
|
||||
return filePath
|
||||
} catch {
|
||||
// Path doesn't exist, try adding extensions
|
||||
for (const ext of extensions) {
|
||||
const withExt = filePath + ext
|
||||
const extFile = Bun.file(withExt)
|
||||
if (await extFile.exists()) return withExt
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractImports(sourceFile: ts.SourceFile): string[] {
|
||||
const imports: string[] = []
|
||||
|
||||
function visit(node: ts.Node) {
|
||||
// import x from "path" or import { x } from "path"
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
// Skip type-only imports
|
||||
if (node.importClause?.isTypeOnly) return
|
||||
|
||||
const moduleSpec = node.moduleSpecifier
|
||||
if (ts.isStringLiteral(moduleSpec)) {
|
||||
imports.push(moduleSpec.text)
|
||||
}
|
||||
}
|
||||
|
||||
// export { x } from "path"
|
||||
if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
|
||||
if (ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
imports.push(node.moduleSpecifier.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic import: import("path")
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
const arg = node.arguments[0]
|
||||
if (arg && ts.isStringLiteral(arg)) {
|
||||
imports.push(arg.text)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return imports
|
||||
}
|
||||
|
||||
async function traceFile(filePath: string, depth = 0): Promise<void> {
|
||||
const normalizedPath = path.relative(BASE_DIR, filePath)
|
||||
|
||||
if (visited.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only trace TypeScript/JavaScript files
|
||||
if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) {
|
||||
return
|
||||
}
|
||||
|
||||
visited.add(filePath)
|
||||
console.log("\t".repeat(depth) + normalizedPath)
|
||||
|
||||
let content: string
|
||||
try {
|
||||
content = await Bun.file(filePath).text()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true)
|
||||
|
||||
const imports = extractImports(sourceFile)
|
||||
const internalImports = imports.filter(isInternalImport)
|
||||
const externalImports = imports.filter((imp) => !isInternalImport(imp))
|
||||
|
||||
// Print external imports
|
||||
for (const imp of externalImports) {
|
||||
console.log("\t".repeat(depth + 1) + `[ext] ${imp}`)
|
||||
}
|
||||
|
||||
for (const imp of internalImports) {
|
||||
const resolved = resolveImport(imp, filePath)
|
||||
if (!resolved) continue
|
||||
|
||||
const actualPath = await tryExtensions(resolved)
|
||||
if (!actualPath) continue
|
||||
|
||||
await traceFile(actualPath, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entryPath = path.join(BASE_DIR, ENTRY_FILE)
|
||||
|
||||
// Check if file exists
|
||||
const file = Bun.file(entryPath)
|
||||
if (!(await file.exists())) {
|
||||
console.error(`File not found: ${ENTRY_FILE}`)
|
||||
console.error(`Resolved to: ${entryPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await traceFile(entryPath)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
@@ -1,67 +0,0 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { OpenCode } from "@opencode-ai/core"
|
||||
import { ReadTool } from "@opencode-ai/core/tools"
|
||||
|
||||
const opencode = OpenCode.make({})
|
||||
|
||||
opencode.tool.add(ReadTool)
|
||||
|
||||
opencode.tool.add({
|
||||
name: "bash",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "The command to run.",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
execute(input, ctx) {},
|
||||
})
|
||||
|
||||
opencode.auth.add({
|
||||
provider: "openai",
|
||||
type: "api",
|
||||
value: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
opencode.agent.add({
|
||||
name: "build",
|
||||
permissions: [],
|
||||
model: {
|
||||
id: "gpt-5-5",
|
||||
provider: "openai",
|
||||
variant: "xhigh",
|
||||
},
|
||||
})
|
||||
|
||||
const sessionID = await opencode.session.create({
|
||||
agent: "build",
|
||||
})
|
||||
|
||||
opencode.subscribe((event) => {
|
||||
console.log(event)
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "hey what is up",
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "what is up with this",
|
||||
files: [
|
||||
{
|
||||
mime: "image/png",
|
||||
uri: "data:image/png;base64,xxxx",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await opencode.session.wait()
|
||||
|
||||
console.log(await opencode.session.messages(sessionID))
|
||||
@@ -42,7 +42,9 @@ import type { ACPConfig } from "./types"
|
||||
import { ACPRuntime } from "./runtime"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "@/config/mcp"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Result, Schema } from "effect"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../../config/mcp"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { Installation } from "../../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
const project = useProject()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.client.find.files({
|
||||
query: store.filter,
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
if (result.error) return []
|
||||
const sliced = (result.data ?? []).slice(0, 5)
|
||||
return sliced
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1438,10 +1438,7 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
|
||||
const spinnerDef = createMemo(() => {
|
||||
const agent =
|
||||
status().type !== "idle"
|
||||
? (local.agent.list().find((a) => a.name === lastUserMessage()?.agent) ?? local.agent.current())
|
||||
: local.agent.current()
|
||||
const agent = local.agent.current()
|
||||
const color = agent ? local.agent.color(agent.name) : theme.border
|
||||
return {
|
||||
frames: createFrames({
|
||||
|
||||
@@ -49,7 +49,6 @@ type Theme = TuiThemeCurrent & {
|
||||
_hasSelectedListItemText: boolean
|
||||
}
|
||||
type ThemeColor = Exclude<keyof TuiThemeCurrent, "thinkingOpacity">
|
||||
type SyntaxStyleOverrides = Record<string, { italic?: boolean }>
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
// If theme explicitly defines selectedListItemText, use it
|
||||
@@ -719,18 +718,16 @@ export function generateSyntax(theme: Theme) {
|
||||
return SyntaxStyle.fromTheme(getSyntaxRules(theme))
|
||||
}
|
||||
|
||||
export function generateSubtleSyntax(theme: Theme, overrides?: SyntaxStyleOverrides) {
|
||||
export function generateSubtleSyntax(theme: Theme) {
|
||||
const rules = getSyntaxRules(theme)
|
||||
return SyntaxStyle.fromTheme(
|
||||
rules.map((rule) => {
|
||||
const override = rule.scope.reduce((acc, scope) => ({ ...acc, ...overrides?.[scope] }), {})
|
||||
if (rule.style.foreground) {
|
||||
const fg = rule.style.foreground
|
||||
return {
|
||||
...rule,
|
||||
style: {
|
||||
...rule.style,
|
||||
...override,
|
||||
foreground: RGBA.fromInts(
|
||||
Math.round(fg.r * 255),
|
||||
Math.round(fg.g * 255),
|
||||
|
||||
-16
@@ -5,7 +5,6 @@
|
||||
|
||||
export type FileTreeItem = {
|
||||
readonly file: string
|
||||
readonly status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type FileTreeNode = {
|
||||
@@ -126,21 +125,6 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu
|
||||
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (row?.kind !== "directory") return selected
|
||||
const child = rows[index + 1]
|
||||
return child && child.depth > row.depth ? child.id : selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) {
|
||||
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
const row = index === -1 ? undefined : rows[index]
|
||||
if (!row || row.depth === 0) return selected
|
||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFile(
|
||||
rows: readonly FileTreeRow[],
|
||||
selected: number | undefined,
|
||||
|
||||
+39
-74
@@ -1,36 +1,30 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
|
||||
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { tint } from "@tui/context/theme"
|
||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils"
|
||||
import { Panel } from "./diff-viewer-ui"
|
||||
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
|
||||
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const FILE_TREE_HORIZONTAL_PADDING = 2
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeTheme = {
|
||||
readonly background: RGBA
|
||||
readonly background: ColorInput
|
||||
readonly backgroundPanel: ColorInput
|
||||
readonly backgroundElement: ColorInput
|
||||
readonly primary: ColorInput
|
||||
readonly secondary: ColorInput
|
||||
readonly selectedListItemText: ColorInput
|
||||
readonly text: RGBA
|
||||
readonly textMuted: RGBA
|
||||
readonly text: ColorInput
|
||||
readonly textMuted: ColorInput
|
||||
readonly error: ColorInput
|
||||
}
|
||||
|
||||
export type DiffViewerFileTreeProps = {
|
||||
readonly width: number
|
||||
readonly files: readonly FileTreeItem[]
|
||||
readonly loading: boolean
|
||||
readonly error: unknown
|
||||
readonly theme: DiffViewerFileTreeTheme
|
||||
readonly focused?: boolean
|
||||
readonly highlightedNode?: number
|
||||
readonly selectedFileIndex?: number
|
||||
readonly reviewedFileNames?: ReadonlySet<string>
|
||||
readonly expandedNodes?: ReadonlySet<number>
|
||||
}
|
||||
|
||||
@@ -49,12 +43,21 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
requestAnimationFrame(scrollSelectedIntoView)
|
||||
})
|
||||
|
||||
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
|
||||
|
||||
return (
|
||||
<Panel border="both" width={props.width}>
|
||||
<box
|
||||
width={FILE_TREE_WIDTH}
|
||||
flexShrink={0}
|
||||
backgroundColor={props.theme.backgroundPanel}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
minHeight={0}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
@@ -67,27 +70,29 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</Match>
|
||||
<Match when={props.files.length > 0}>
|
||||
<For each={rows()}>
|
||||
{(row, index) => {
|
||||
{(row) => {
|
||||
const highlighted = () => props.focused && props.highlightedNode === row.id
|
||||
const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
|
||||
const reviewed = () => {
|
||||
const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file
|
||||
return file !== undefined && props.reviewedFileNames?.has(file)
|
||||
}
|
||||
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
|
||||
const status = () => fileTreeRowStatus(row, props.files)
|
||||
const prefix = () =>
|
||||
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`
|
||||
const name = () =>
|
||||
Locale.truncate(
|
||||
row.name,
|
||||
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
|
||||
Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length),
|
||||
)
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? props.theme.primary : undefined}
|
||||
>
|
||||
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
|
||||
<box flexDirection="row" width="100%">
|
||||
<text
|
||||
fg={
|
||||
highlighted()
|
||||
? props.theme.background
|
||||
: row.kind === "directory"
|
||||
? props.theme.textMuted
|
||||
: props.theme.text
|
||||
}
|
||||
bg={highlighted() ? props.theme.primary : undefined}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{prefix()}
|
||||
</text>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
@@ -95,26 +100,16 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
fg={
|
||||
highlighted()
|
||||
? props.theme.background
|
||||
: reviewed()
|
||||
: row.kind === "directory"
|
||||
? props.theme.textMuted
|
||||
: selected()
|
||||
? props.theme.primary
|
||||
: row.kind === "directory"
|
||||
? tint(props.theme.text, props.theme.background, 0.35)
|
||||
: props.theme.text
|
||||
: props.theme.text
|
||||
}
|
||||
bg={highlighted() ? props.theme.primary : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
{name()}
|
||||
</text>
|
||||
</box>
|
||||
<text
|
||||
fg={highlighted() ? props.theme.background : props.theme.textMuted}
|
||||
wrapMode="none"
|
||||
flexShrink={0}
|
||||
>
|
||||
{status()}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
@@ -122,7 +117,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</scrollbox>
|
||||
</Panel>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,33 +131,3 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
|
||||
scroll.scrollTo(index - scroll.viewport.height + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function fileTreeRowPrefix(
|
||||
rows: readonly FileTreeRow[],
|
||||
index: number,
|
||||
row: FileTreeRow,
|
||||
expandedNodes: ReadonlySet<number> | undefined,
|
||||
) {
|
||||
const indentation = Array.from({ length: row.depth }, (_, depth) => {
|
||||
if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " "
|
||||
return hasLaterSibling(rows, index, depth) ? "│ " : " "
|
||||
}).join("")
|
||||
const topRoot = index === 0 && row.depth === 0
|
||||
const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ "
|
||||
const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : ""
|
||||
|
||||
return `${indentation}${branch}${marker}`
|
||||
}
|
||||
|
||||
function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) {
|
||||
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
|
||||
}
|
||||
|
||||
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
|
||||
if (row.fileIndex === undefined) return ""
|
||||
const status = files[row.fileIndex]?.status
|
||||
if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
return "?".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createContext, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
export type PanelBorder = "start" | "end" | "both" | "none"
|
||||
|
||||
const PanelGroupContext = createContext<{ axis: Axis }>()
|
||||
|
||||
function crossAxis(axis: Axis) {
|
||||
return axis === "x" ? "y" : "x"
|
||||
}
|
||||
|
||||
function usePanelGroup() {
|
||||
return useContext(PanelGroupContext)
|
||||
}
|
||||
|
||||
export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) {
|
||||
const [local, boxProps] = splitProps(props, ["axis", "children"])
|
||||
return (
|
||||
<PanelGroupContext.Provider value={{ axis: local.axis }}>
|
||||
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
|
||||
{local.children}
|
||||
</box>
|
||||
</PanelGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { border?: PanelBorder }) {
|
||||
const group = usePanelGroup()
|
||||
const { theme } = useTheme()
|
||||
const [local, boxProps] = splitProps(props, ["border"])
|
||||
const border = local.border ?? "start"
|
||||
const borderProps =
|
||||
border === "none"
|
||||
? {}
|
||||
: {
|
||||
border: panelBorderSides(group?.axis ?? "y", border),
|
||||
borderColor: theme.border,
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
|
||||
{...borderProps}
|
||||
{...boxProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): BorderSides[] {
|
||||
if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"]
|
||||
return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"]
|
||||
}
|
||||
|
||||
export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) {
|
||||
const group = usePanelGroup()
|
||||
const { theme } = useTheme()
|
||||
const color = () => props.color ?? theme.border
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (axis() === "y") {
|
||||
if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
|
||||
return (
|
||||
<box width={1} flexShrink={0} flexDirection="column">
|
||||
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "├" : "┤"
|
||||
if (edge === "edge-in") return "┴"
|
||||
return "┬"
|
||||
}
|
||||
|
||||
function verticalEdge(edge: SeparatorEdge, side: "start" | "end") {
|
||||
if (edge === "edge") return side === "start" ? "┬" : "┴"
|
||||
if (edge === "edge-in") return "┤"
|
||||
return "├"
|
||||
}
|
||||
@@ -9,24 +9,19 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import path from "path"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
setFileTreeDirectoryExpanded,
|
||||
toggleFileTreeDirectory,
|
||||
} from "./diff-viewer-file-tree-utils"
|
||||
|
||||
const ROUTE = "diff"
|
||||
const MIN_SPLIT_WIDTH = 100
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||
type DiffMode = "git" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
|
||||
@@ -105,8 +100,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
||||
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
||||
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
||||
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
|
||||
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
|
||||
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
||||
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
|
||||
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
|
||||
@@ -116,7 +109,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
|
||||
const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
|
||||
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
|
||||
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
@@ -126,8 +118,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setHighlightedFileNode(undefined)
|
||||
setLastHighlightedFileNode(undefined)
|
||||
setActivePatchFileIndex(undefined)
|
||||
setSelectedFileIndex(undefined)
|
||||
setReviewedFileNames(new Set<string>())
|
||||
})
|
||||
|
||||
const ensureHighlightedFileNode = () => {
|
||||
@@ -154,12 +144,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setActivePatchFileIndex(undefined)
|
||||
}
|
||||
|
||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
|
||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
|
||||
if (!scroll) return
|
||||
const offset = fileIndex === 0 ? 0 : 1
|
||||
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||
scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
||||
requestAnimationFrame(() => {
|
||||
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
|
||||
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,9 +168,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const scrollToFileIndex = (fileIndex: number | undefined) => {
|
||||
if (fileIndex === undefined) return
|
||||
setActivePatchFileIndex(fileIndex)
|
||||
setSelectedFileIndex(fileIndex)
|
||||
const patchNode = patchNodeByFileIndex.get(fileIndex)
|
||||
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
|
||||
if (patchNode) scrollPatchNodeToTop(patchNode)
|
||||
}
|
||||
|
||||
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
||||
@@ -239,9 +227,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
patchNodeByFileIndex.set(fileIndex, element)
|
||||
if (pendingPatchScrollFileIndex() !== fileIndex) return
|
||||
requestAnimationFrame(() => {
|
||||
scrollPatchNodeToTop(element, fileIndex)
|
||||
scrollPatchNodeToTop(element)
|
||||
requestAnimationFrame(() => {
|
||||
scrollPatchNodeToTop(element, fileIndex)
|
||||
scrollPatchNodeToTop(element)
|
||||
setPendingPatchScrollFileIndex(undefined)
|
||||
})
|
||||
})
|
||||
@@ -256,21 +244,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode()))
|
||||
}
|
||||
|
||||
const toggleSelectedFileReviewed = () => {
|
||||
const fileIndex =
|
||||
focus() === "files"
|
||||
? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
||||
: (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex())
|
||||
const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file
|
||||
if (!file) return
|
||||
setReviewedFileNames((reviewed) => {
|
||||
const next = new Set(reviewed)
|
||||
if (next.has(file)) next.delete(file)
|
||||
else next.add(file)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{
|
||||
name: "diff.close",
|
||||
@@ -353,11 +326,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
const highlighted = highlightedFileNode()
|
||||
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
|
||||
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
|
||||
return
|
||||
}
|
||||
setExpandedFileNodes((expanded) =>
|
||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
|
||||
)
|
||||
@@ -371,12 +339,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
const highlighted = highlightedFileNode()
|
||||
const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted]
|
||||
if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) {
|
||||
setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted))
|
||||
return
|
||||
}
|
||||
setExpandedFileNodes((expanded) =>
|
||||
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
|
||||
)
|
||||
@@ -400,14 +362,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
jumpRelativePatchFile(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.mark_reviewed",
|
||||
title: "Toggle selected diff file reviewed",
|
||||
category: "VCS",
|
||||
run() {
|
||||
toggleSelectedFileReviewed()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.switch_focus",
|
||||
title: "Switch diff viewer focus",
|
||||
@@ -506,7 +460,6 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
{ key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
|
||||
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" },
|
||||
{ key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" },
|
||||
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
|
||||
...props.api.tuiConfig.keybinds.gather(
|
||||
"diff",
|
||||
commands.map((command) => command.name),
|
||||
@@ -515,193 +468,186 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
}))
|
||||
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<PanelGroup axis="y" width="100%" height="100%">
|
||||
<Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
|
||||
<text fg={theme().text}>Diff </text>
|
||||
<box
|
||||
position="absolute"
|
||||
zIndex={2500}
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme().background}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme().text}>Diff</text>
|
||||
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={theme().textMuted}>
|
||||
{files().length} {files().length === 1 ? "file" : "files"}
|
||||
</text>
|
||||
</Panel>
|
||||
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
<Switch>
|
||||
<Match when={diff.loading}>
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme().textMuted}>Loading diff...</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<PanelGroup axis="x">
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
theme={theme()}
|
||||
focused={focus() === "files"}
|
||||
width={FILE_TREE_WIDTH}
|
||||
highlightedNode={highlightedFileNode()}
|
||||
selectedFileIndex={selectedFileIndex()}
|
||||
reviewedFileNames={reviewedFileNames()}
|
||||
expandedNodes={expandedFileNodes()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Panel flexGrow={1} minHeight={0} border="none">
|
||||
<Separator axis="x" start="edge-out" />
|
||||
<Switch>
|
||||
<Match when={diff.error}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().error}>Failed to load diff</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().textMuted}>No diff to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry, index) => {
|
||||
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||
return (
|
||||
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().border}
|
||||
>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
</box>
|
||||
<Separator axis="x" start="edge" />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={["left"]} borderColor={theme().border}>
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? theme().textMuted : theme().text}
|
||||
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
||||
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
||||
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
||||
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
addedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Separator axis="x" start="edge-in" />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleFileTreeShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={singlePatchShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchDiffShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleViewShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={markReviewedShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
<Switch>
|
||||
<Match when={diff.loading}>
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<text fg={theme().textMuted}>Loading diff...</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!diff.loading}>
|
||||
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
|
||||
<Show when={showFileTree()}>
|
||||
<DiffViewerFileTree
|
||||
files={files()}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
theme={theme()}
|
||||
focused={focus() === "files"}
|
||||
highlightedNode={highlightedFileNode()}
|
||||
expandedNodes={expandedFileNodes()}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
backgroundColor={theme().background}
|
||||
paddingLeft={0}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={diff.error}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().error}>Failed to load diff</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().textMuted}>No diff to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry) => (
|
||||
<box
|
||||
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
|
||||
marginBottom={1}
|
||||
backgroundColor={theme().backgroundPanel}
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
backgroundColor={theme().backgroundPanel}
|
||||
>
|
||||
<text fg={theme().text}>{entry.file.file}</text>
|
||||
<text fg={theme().diffAdded}>+{entry.file.additions}</text>
|
||||
<text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
|
||||
</box>
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme().text}
|
||||
addedBg={theme().diffAddedBg}
|
||||
removedBg={theme().diffRemovedBg}
|
||||
contextBg={theme().diffContextBg}
|
||||
addedSignColor={theme().diffHighlightAdded}
|
||||
removedSignColor={theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
lineNumberBg={theme().diffContextBg}
|
||||
addedLineNumberBg={theme().diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme().diffRemovedLineNumberBg}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<box flexDirection="row" gap={2} flexShrink={0}>
|
||||
<Show when={switchFocusShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={nextFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={previousFileShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleFileTreeShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={singlePatchShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={switchDiffShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleViewShortcut()}>
|
||||
{(shortcut) => (
|
||||
<text fg={theme().text}>
|
||||
{shortcut()}{" "}
|
||||
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
SessionMessageCompaction,
|
||||
SessionMessageModelSwitched,
|
||||
SessionMessageShell,
|
||||
SessionMessageSynthetic,
|
||||
SessionMessageUser,
|
||||
ToolFileContent,
|
||||
ToolTextContent,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
|
||||
export function DialogSubagent(props: { sessionID: string }) {
|
||||
const route = useRoute()
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Subagent Actions"
|
||||
options={[
|
||||
{
|
||||
title: "Open",
|
||||
value: "subagent.view",
|
||||
description: "the subagent's session",
|
||||
onSelect: (dialog) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: props.sessionID,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useConnected } from "../../component/use-connected"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useRoute } from "../../context/route"
|
||||
|
||||
export function Footer() {
|
||||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const route = useRoute()
|
||||
const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length)
|
||||
const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed"))
|
||||
const lsp = createMemo(() => Object.keys(sync.data.lsp))
|
||||
const permissions = createMemo(() => {
|
||||
if (route.data.type !== "session") return []
|
||||
return sync.data.permission[route.data.sessionID] ?? []
|
||||
})
|
||||
const directory = useDirectory()
|
||||
const connected = useConnected()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
welcome: false,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
// Track all timeouts to ensure proper cleanup
|
||||
const timeouts: ReturnType<typeof setTimeout>[] = []
|
||||
|
||||
function tick() {
|
||||
if (connected()) return
|
||||
if (!store.welcome) {
|
||||
setStore("welcome", true)
|
||||
timeouts.push(setTimeout(() => tick(), 5000))
|
||||
return
|
||||
}
|
||||
|
||||
if (store.welcome) {
|
||||
setStore("welcome", false)
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
return
|
||||
}
|
||||
}
|
||||
timeouts.push(setTimeout(() => tick(), 10_000))
|
||||
|
||||
onCleanup(() => {
|
||||
timeouts.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={2} flexDirection="row" flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={store.welcome}>
|
||||
<text fg={theme.text}>
|
||||
Get started <span style={{ fg: theme.textMuted }}>/connect</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={connected()}>
|
||||
<Show when={permissions().length > 0}>
|
||||
<text fg={theme.warning}>
|
||||
<span style={{ fg: theme.warning }}>△</span> {permissions().length} Permission
|
||||
{permissions().length > 1 ? "s" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ fg: lsp().length > 0 ? theme.success : theme.textMuted }}>•</span> {lsp().length} LSP
|
||||
</text>
|
||||
<Show when={mcp()}>
|
||||
<text fg={theme.text}>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>⊙ </span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>⊙ </span>
|
||||
</Match>
|
||||
</Switch>
|
||||
{mcp()} MCP
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>/status</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import { generateSubtleSyntax, selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import type {
|
||||
@@ -53,6 +53,7 @@ import type { SkillTool } from "@/tool/skill"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { TodoItem } from "../../component/todo-item"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
@@ -1497,7 +1498,7 @@ const PART_MAPPING = {
|
||||
}
|
||||
|
||||
function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: AssistantMessage }) {
|
||||
const { theme } = useTheme()
|
||||
const { theme, subtleSyntax } = useTheme()
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
@@ -1519,8 +1520,6 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
// blocks. Surface the title both while streaming and after settling so the
|
||||
// collapsed line carries real signal, not just a duration.
|
||||
const title = createMemo(() => reasoningTitle(content()))
|
||||
// Keep markdown emphasis for the existing thinking color/concealment, but render it without italics.
|
||||
const syntax = createMemo(() => generateSubtleSyntax(theme, { "markup.italic": { italic: false } }))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
@@ -1537,9 +1536,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
// `_Thinking:_`/`_Thought:_` still drives markdown emphasis color and conceals the underscores;
|
||||
// the syntax override above removes only the italic attribute from that emphasis token.
|
||||
syntaxStyle={subtleSyntax()}
|
||||
content={(inMinimal() ? "- " : "") + (isDone() ? "_Thought:_ " : "_Thinking:_ ") + content()}
|
||||
conceal={ctx.conceal()}
|
||||
fg={theme.textMuted}
|
||||
@@ -1567,7 +1564,7 @@ function CollapsedReasoningText(props: { title: string | null; duration: number
|
||||
|
||||
return (
|
||||
<text fg={theme.warning} wrapMode="none">
|
||||
<span style={{ fg: theme.warning }}>
|
||||
<span style={{ fg: theme.warning, italic: true }}>
|
||||
{props.title ? "+ Thought: " + props.title + " · " + duration() : "+ Thought: " + duration()}
|
||||
</span>
|
||||
</text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { Portal, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useTheme, selectedForeground } from "../../context/theme"
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from "os"
|
||||
import path from "path"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Process } from "@/util/process"
|
||||
import { warn } from "console"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Config } from "@/config/config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Installation } from "../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { Authorization } from "../middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
||||
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
|
||||
@@ -10,11 +10,9 @@ import { NotFoundError } from "@/storage/storage"
|
||||
import { and } from "drizzle-orm"
|
||||
import { desc } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { lt } from "drizzle-orm"
|
||||
import { or } from "drizzle-orm"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import * as ProviderError from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -563,8 +561,8 @@ export type WithParts = {
|
||||
}
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: MessageID,
|
||||
time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
sequence: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
})
|
||||
type Cursor = typeof Cursor.Type
|
||||
|
||||
@@ -579,18 +577,9 @@ export const cursor = {
|
||||
},
|
||||
}
|
||||
|
||||
const chronologicalOrder = Symbol("chronologicalOrder")
|
||||
const messageRowID = sql<number>`rowid`
|
||||
type MessageRow = typeof MessageTable.$inferSelect & { sequence?: number }
|
||||
type Chronological = WithParts & { [chronologicalOrder]?: number }
|
||||
|
||||
const info = (row: MessageRow) =>
|
||||
const info = (row: typeof MessageTable.$inferSelect) =>
|
||||
({
|
||||
...row.data,
|
||||
time: {
|
||||
...row.data.time,
|
||||
created: row.time_created,
|
||||
},
|
||||
id: row.id,
|
||||
sessionID: row.session_id,
|
||||
}) as Info
|
||||
@@ -604,9 +593,9 @@ const part = (row: typeof PartTable.$inferSelect) =>
|
||||
}) as Part
|
||||
|
||||
const older = (row: Cursor) =>
|
||||
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(messageRowID, row.sequence)))
|
||||
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
|
||||
|
||||
function hydrate(rows: MessageRow[]) {
|
||||
function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
|
||||
const ids = rows.map((row) => row.id)
|
||||
const partByMessage = new Map<string, Part[]>()
|
||||
if (ids.length > 0) {
|
||||
@@ -942,10 +931,10 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
|
||||
: eq(MessageTable.session_id, input.sessionID)
|
||||
const rows = Database.use((db) =>
|
||||
db
|
||||
.select({ ...getTableColumns(MessageTable), sequence: messageRowID })
|
||||
.select()
|
||||
.from(MessageTable)
|
||||
.where(where)
|
||||
.orderBy(desc(MessageTable.time_created), desc(messageRowID))
|
||||
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
|
||||
.limit(input.limit + 1)
|
||||
.all(),
|
||||
)
|
||||
@@ -968,7 +957,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
|
||||
return {
|
||||
items,
|
||||
more,
|
||||
cursor: more && tail ? cursor.encode({ time: tail.time_created, sequence: tail.sequence ?? 0 }) : undefined,
|
||||
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1046,7 +1035,6 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||
completed.add(msg.info.parentID)
|
||||
}
|
||||
result.reverse()
|
||||
result.forEach((msg, index) => ((msg as Chronological)[chronologicalOrder] = index))
|
||||
const compactionIndex = result.findLastIndex(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
@@ -1080,57 +1068,29 @@ export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: Ses
|
||||
return filterCompacted(stream(sessionID))
|
||||
})
|
||||
|
||||
export function compare(a: WithParts, b: WithParts, indexA = -1, indexB = -1) {
|
||||
if (a.info.time.created !== b.info.time.created) return a.info.time.created - b.info.time.created
|
||||
const sequenceA = (a as Chronological)[chronologicalOrder]
|
||||
const sequenceB = (b as Chronological)[chronologicalOrder]
|
||||
if (sequenceA !== undefined && sequenceB !== undefined && sequenceA !== sequenceB) return sequenceA - sequenceB
|
||||
return indexA - indexB
|
||||
}
|
||||
|
||||
// filterCompacted reorders messages for model consumption
|
||||
// ([compaction-user, summary, ...retained tail..., continue-user]), so array
|
||||
// position is not chronological. Derive each binding by DB-created time; user
|
||||
// message IDs can be allocated by clients, so lexical ID order is not reliable.
|
||||
// Same-millisecond ties use the DB row order captured before compaction reorder.
|
||||
// tasks are compaction/subtask parts attached to user messages newer than the
|
||||
// latest finished assistant — i.e. unprocessed work.
|
||||
// position is not chronological. Derive each binding by max id (MessageID
|
||||
// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail
|
||||
// assistant doesn't get mistaken for the most recent turn. tasks are
|
||||
// compaction/subtask parts attached to user messages newer than the latest
|
||||
// finished assistant — i.e. unprocessed work.
|
||||
export function latest(msgs: WithParts[]) {
|
||||
let user: WithParts | undefined
|
||||
let assistant: WithParts | undefined
|
||||
let finished: WithParts | undefined
|
||||
let userIndex = -1
|
||||
let assistantIndex = -1
|
||||
let finishedIndex = -1
|
||||
for (const [index, msg] of msgs.entries()) {
|
||||
let user: User | undefined
|
||||
let assistant: Assistant | undefined
|
||||
let finished: Assistant | undefined
|
||||
for (const msg of msgs) {
|
||||
const info = msg.info
|
||||
if (info.role === "user" && (!user || compare(msg, user, index, userIndex) > 0)) {
|
||||
user = msg
|
||||
userIndex = index
|
||||
}
|
||||
if (info.role === "assistant" && (!assistant || compare(msg, assistant, index, assistantIndex) > 0)) {
|
||||
assistant = msg
|
||||
assistantIndex = index
|
||||
}
|
||||
if (info.role === "assistant" && info.finish && (!finished || compare(msg, finished, index, finishedIndex) > 0)) {
|
||||
finished = msg
|
||||
finishedIndex = index
|
||||
}
|
||||
if (info.role === "user" && (!user || info.id > user.id)) user = info
|
||||
if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info
|
||||
if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info
|
||||
}
|
||||
const tasks = msgs.flatMap((m, index) =>
|
||||
finished && compare(m, finished, index, finishedIndex) <= 0
|
||||
const tasks = msgs.flatMap((m) =>
|
||||
finished && m.info.id <= finished.id
|
||||
? []
|
||||
: m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"),
|
||||
)
|
||||
return {
|
||||
user: user?.info.role === "user" ? user.info : undefined,
|
||||
assistant: assistant?.info.role === "assistant" ? assistant.info : undefined,
|
||||
finished: finished?.info.role === "assistant" ? finished.info : undefined,
|
||||
userMessage: user,
|
||||
assistantMessage: assistant,
|
||||
finishedMessage: finished,
|
||||
tasks,
|
||||
}
|
||||
return { user, assistant, finished, tasks }
|
||||
}
|
||||
|
||||
export function fromError(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
@@ -47,6 +47,7 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session-event"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -1250,18 +1251,13 @@ export const layer = Layer.effect(
|
||||
|
||||
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
|
||||
|
||||
const latest = MessageV2.latest(msgs)
|
||||
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest
|
||||
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
|
||||
const lastAssistantMsg = msgs.findLast(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
||||
)
|
||||
const userBeforeAssistant =
|
||||
latest.userMessage &&
|
||||
latest.assistantMessage &&
|
||||
MessageV2.compare(latest.userMessage, latest.assistantMessage) < 0
|
||||
// Some providers return "stop" even when the assistant message contains tool calls.
|
||||
// Keep the loop running so tool results can be sent back to the model.
|
||||
// Skip provider-executed tool parts — those were fully handled within the
|
||||
@@ -1273,7 +1269,7 @@ export const layer = Layer.effect(
|
||||
lastAssistant?.finish &&
|
||||
!["tool-calls"].includes(lastAssistant.finish) &&
|
||||
!hasToolCalls &&
|
||||
userBeforeAssistant
|
||||
lastUser.id < lastAssistant.id
|
||||
) {
|
||||
yield* slog.info("exiting loop")
|
||||
break
|
||||
@@ -1403,8 +1399,7 @@ export const layer = Layer.effect(
|
||||
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const m of msgs) {
|
||||
const finishedBeforeMessage = latest.finishedMessage && MessageV2.compare(latest.finishedMessage, m) < 0
|
||||
if (m.info.role !== "user" || !finishedBeforeMessage) continue
|
||||
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
|
||||
for (const p of m.parts) {
|
||||
if (p.type !== "text" || p.ignored || p.synthetic) continue
|
||||
if (!p.text.trim()) continue
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SessionID } from "@/session/schema"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
|
||||
import * as Database from "@/storage/db"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import type { Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import {
|
||||
buildFileTree,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
setFileTreeDirectoryExpanded,
|
||||
toggleFileTreeDirectory,
|
||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
@@ -179,41 +177,6 @@ describe("diff viewer file tree utilities", () => {
|
||||
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves directory selection to first visible child", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
|
||||
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves collapsed chain selection to first visible child", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
|
||||
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
|
||||
})
|
||||
|
||||
test("moves file and collapsed directory selection to visible parent", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]),
|
||||
)
|
||||
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
|
||||
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
|
||||
const app = rows.find((row) => row.name === "app.ts")!
|
||||
|
||||
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
|
||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves file selection relative to the highlighted row", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
||||
|
||||
@@ -3,10 +3,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -18,7 +14,6 @@ const theme = {
|
||||
backgroundPanel: RGBA.fromHex("#111111"),
|
||||
backgroundElement: RGBA.fromHex("#333333"),
|
||||
primary: RGBA.fromHex("#00ffff"),
|
||||
secondary: RGBA.fromHex("#0088ff"),
|
||||
selectedListItemText: RGBA.fromHex("#ffffff"),
|
||||
text: RGBA.fromHex("#ffffff"),
|
||||
textMuted: RGBA.fromHex("#888888"),
|
||||
@@ -28,38 +23,29 @@ const theme = {
|
||||
describe("DiffViewerFileTree", () => {
|
||||
test("renders sorted hierarchical file rows", async () => {
|
||||
const app = await testRender(
|
||||
() =>
|
||||
withTheme(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
)),
|
||||
() => (
|
||||
<DiffViewerFileTree
|
||||
files={[
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
),
|
||||
{ width: 40, height: 20 },
|
||||
)
|
||||
|
||||
try {
|
||||
await renderOnceSettled(app)
|
||||
await app.renderOnce()
|
||||
const lines = visibleLines(app.captureCharFrame())
|
||||
|
||||
expect(lines).toEqual([
|
||||
"▾ a",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ zeta.ts ?",
|
||||
"├─ ▾ b",
|
||||
"│ ├─ alpha.ts ?",
|
||||
"│ └─ file.ts ?",
|
||||
])
|
||||
expect(lines).toEqual(["▾ a", " alpha.ts", " zeta.ts", "▾ b", " alpha.ts", " file.ts", " z-file.ts"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -67,13 +53,13 @@ describe("DiffViewerFileTree", () => {
|
||||
|
||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||
const loading = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />
|
||||
))
|
||||
const failed = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
))
|
||||
const empty = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />
|
||||
))
|
||||
|
||||
expect(loading).not.toContain("Loading diff...")
|
||||
@@ -90,7 +76,6 @@ describe("DiffViewerFileTree", () => {
|
||||
const focused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
@@ -101,9 +86,7 @@ describe("DiffViewerFileTree", () => {
|
||||
)),
|
||||
)
|
||||
const unfocused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
|
||||
)),
|
||||
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src/config")
|
||||
@@ -122,24 +105,16 @@ describe("DiffViewerFileTree", () => {
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={collapsed}
|
||||
/>
|
||||
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src/config"])
|
||||
).toEqual(["▸ src/config", " README.md"])
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
files={files}
|
||||
width={32}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
@@ -147,41 +122,25 @@ describe("DiffViewerFileTree", () => {
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
|
||||
).toEqual(["▾ src/config", " tui.ts", " README.md"])
|
||||
})
|
||||
})
|
||||
|
||||
async function renderFrame(component: () => JSX.Element) {
|
||||
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
|
||||
const app = await testRender(component, { width: 40, height: 10 })
|
||||
try {
|
||||
await renderOnceSettled(app)
|
||||
await app.renderOnce()
|
||||
return app.captureCharFrame()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
await app.renderOnce()
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
function withTheme(component: () => JSX.Element) {
|
||||
return (
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">{component()}</ThemeProvider>
|
||||
</KVProvider>
|
||||
</TuiConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function visibleLines(frame: string) {
|
||||
return frame
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
|
||||
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
|
||||
.filter((line) => line.length > 0 && !/^┌|^└/.test(line))
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
|
||||
type Msg = {
|
||||
key: string
|
||||
dir: string
|
||||
staleMs?: number
|
||||
timeoutMs?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
holdMs?: number
|
||||
ready?: string
|
||||
active?: string
|
||||
done?: string
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function input() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Missing flock worker input")
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as Msg
|
||||
}
|
||||
|
||||
async function job(input: Msg) {
|
||||
if (input.ready) {
|
||||
await fs.writeFile(input.ready, String(process.pid))
|
||||
}
|
||||
|
||||
if (input.active) {
|
||||
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
|
||||
}
|
||||
|
||||
try {
|
||||
if (input.holdMs && input.holdMs > 0) {
|
||||
await sleep(input.holdMs)
|
||||
}
|
||||
|
||||
if (input.done) {
|
||||
await fs.appendFile(input.done, "1\n")
|
||||
}
|
||||
} finally {
|
||||
if (input.active) {
|
||||
await fs.rm(input.active, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const msg = input()
|
||||
|
||||
await Flock.withLock(msg.key, () => job(msg), {
|
||||
dir: msg.dir,
|
||||
staleMs: msg.staleMs,
|
||||
timeoutMs: msg.timeoutMs,
|
||||
baseDelayMs: msg.baseDelayMs,
|
||||
maxDelayMs: msg.maxDelayMs,
|
||||
})
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
process.stderr.write(text)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -58,12 +58,12 @@ const model: Provider.Model = {
|
||||
release_date: "2026-01-01",
|
||||
}
|
||||
|
||||
function userInfo(id: string, created = 0): MessageV2.User {
|
||||
function userInfo(id: string): MessageV2.User {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created },
|
||||
time: { created: 0 },
|
||||
agent: "user",
|
||||
model: { providerID, modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
@@ -76,14 +76,13 @@ function assistantInfo(
|
||||
parentID: string,
|
||||
error?: MessageV2.Assistant["error"],
|
||||
meta?: { providerID: string; modelID: string },
|
||||
created = 0,
|
||||
): MessageV2.Assistant {
|
||||
const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id }
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created },
|
||||
time: { created: 0 },
|
||||
error,
|
||||
parentID,
|
||||
modelID: infoModel.modelID,
|
||||
@@ -1558,13 +1557,13 @@ describe("session.message-v2.latest", () => {
|
||||
const NEW_COMPACTION_USER = MessageID.make("msg_006")
|
||||
|
||||
const tailUser: MessageV2.WithParts = {
|
||||
info: userInfo(TAIL_USER, 1),
|
||||
info: userInfo(TAIL_USER),
|
||||
parts: [{ ...basePart(TAIL_USER, "p1"), type: "text", text: "original prompt" }] as MessageV2.Part[],
|
||||
}
|
||||
|
||||
const overflowAssistant: MessageV2.WithParts = {
|
||||
info: {
|
||||
...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER, undefined, undefined, 2),
|
||||
...assistantInfo(OVERFLOW_ASSISTANT, TAIL_USER),
|
||||
finish: "tool-calls",
|
||||
tokens: { input: 280_000, output: 200, reasoning: 0, cache: { read: 0, write: 0 }, total: 280_200 },
|
||||
} as MessageV2.Assistant,
|
||||
@@ -1572,7 +1571,7 @@ describe("session.message-v2.latest", () => {
|
||||
}
|
||||
|
||||
const compactionUser: MessageV2.WithParts = {
|
||||
info: userInfo(COMPACTION_USER, 3),
|
||||
info: userInfo(COMPACTION_USER),
|
||||
parts: [
|
||||
{
|
||||
...basePart(COMPACTION_USER, "p1"),
|
||||
@@ -1585,7 +1584,7 @@ describe("session.message-v2.latest", () => {
|
||||
|
||||
const summaryAssistant: MessageV2.WithParts = {
|
||||
info: {
|
||||
...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER, undefined, undefined, 4),
|
||||
...assistantInfo(SUMMARY_ASSISTANT, COMPACTION_USER),
|
||||
summary: true,
|
||||
finish: "stop",
|
||||
tokens: { input: 150_000, output: 1_500, reasoning: 0, cache: { read: 0, write: 0 }, total: 151_500 },
|
||||
@@ -1594,7 +1593,7 @@ describe("session.message-v2.latest", () => {
|
||||
}
|
||||
|
||||
const continueUser: MessageV2.WithParts = {
|
||||
info: userInfo(CONTINUE_USER, 5),
|
||||
info: userInfo(CONTINUE_USER),
|
||||
parts: [
|
||||
{
|
||||
...basePart(CONTINUE_USER, "p1"),
|
||||
@@ -1630,7 +1629,7 @@ describe("session.message-v2.latest", () => {
|
||||
|
||||
test("a fresh compaction-user newer than the latest summary surfaces in tasks", () => {
|
||||
const newCompactionUser: MessageV2.WithParts = {
|
||||
info: userInfo(NEW_COMPACTION_USER, 6),
|
||||
info: userInfo(NEW_COMPACTION_USER),
|
||||
parts: [
|
||||
{
|
||||
...basePart(NEW_COMPACTION_USER, "p1"),
|
||||
@@ -1654,27 +1653,4 @@ describe("session.message-v2.latest", () => {
|
||||
expect(state.tasks).toHaveLength(1)
|
||||
expect(state.tasks[0]).toMatchObject({ type: "compaction", auto: true })
|
||||
})
|
||||
|
||||
test("latest uses created time when message ids are not chronological", () => {
|
||||
const newerAssistant = MessageID.make("msg_001")
|
||||
const olderAssistant = MessageID.make("msg_999")
|
||||
const state = MessageV2.latest([
|
||||
{
|
||||
info: {
|
||||
...assistantInfo(olderAssistant, TAIL_USER, undefined, undefined, 1),
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
...assistantInfo(newerAssistant, TAIL_USER, undefined, undefined, 2),
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [],
|
||||
},
|
||||
])
|
||||
|
||||
expect(state.finished?.id).toBe(newerAssistant)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -173,35 +173,6 @@ describe("MessageV2.page", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("uses db order when same-timestamp ids are not chronological", () =>
|
||||
withSession(({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const older = MessageID.make("msg_999")
|
||||
const newer = MessageID.make("msg_001")
|
||||
for (const id of [older, newer]) {
|
||||
yield* session.updateMessage({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "test",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
tools: {},
|
||||
mode: "",
|
||||
} as unknown as MessageV2.Info)
|
||||
}
|
||||
|
||||
const first = yield* MessageV2.page({ sessionID, limit: 1 })
|
||||
expect(first.items.map((item) => item.info.id)).toEqual([newer])
|
||||
expect(first.cursor).toBeTruthy()
|
||||
|
||||
const second = yield* MessageV2.page({ sessionID, limit: 1, before: first.cursor! })
|
||||
expect(second.items.map((item) => item.info.id)).toEqual([older])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("returns empty items for session with no messages", () =>
|
||||
withSession(({ sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1001,22 +972,22 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
describe("MessageV2.cursor", () => {
|
||||
test("encode/decode roundtrip", () => {
|
||||
const input = { time: 1234567890, sequence: 1 }
|
||||
const input = { id: MessageID.ascending(), time: 1234567890 }
|
||||
const encoded = MessageV2.cursor.encode(input)
|
||||
const decoded = MessageV2.cursor.decode(encoded)
|
||||
expect(decoded.id).toBe(input.id)
|
||||
expect(decoded.time).toBe(input.time)
|
||||
expect(decoded.sequence).toBe(input.sequence)
|
||||
})
|
||||
|
||||
test("encode/decode with fractional time", () => {
|
||||
const input = { time: 1234567890.5, sequence: 1 }
|
||||
const input = { id: MessageID.ascending(), time: 1234567890.5 }
|
||||
const encoded = MessageV2.cursor.encode(input)
|
||||
const decoded = MessageV2.cursor.decode(encoded)
|
||||
expect(decoded.time).toBe(1234567890.5)
|
||||
})
|
||||
|
||||
test("encoded cursor is base64url", () => {
|
||||
const encoded = MessageV2.cursor.encode({ time: 0, sequence: 1 })
|
||||
const encoded = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 })
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Git } from "@/git"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
|
||||
@@ -15,11 +15,8 @@ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
type Issue = {
|
||||
number: number
|
||||
updated_at: string
|
||||
author_association: string
|
||||
}
|
||||
|
||||
const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
@@ -66,10 +63,6 @@ async function main() {
|
||||
for (const i of all) {
|
||||
const updated = new Date(i.updated_at)
|
||||
if (updated < cutoff) {
|
||||
if (teamAssociations.has(i.author_association)) {
|
||||
console.log(`Skipping #${i.number}: author association is ${i.author_association}`)
|
||||
continue
|
||||
}
|
||||
stale.push(i.number)
|
||||
} else {
|
||||
console.log(`\nFound fresh issue #${i.number}, stopping`)
|
||||
|
||||
@@ -69,7 +69,6 @@ const maxClose =
|
||||
const sleepMs = requireNonNegativeInteger("sleep-ms", values["sleep-ms"])
|
||||
const printLimit = requireNonNegativeInteger("print-limit", values["print-limit"])
|
||||
const cutoff = subtractMonths(new Date(), ageMonths)
|
||||
const teamAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"])
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -83,7 +82,6 @@ type PullRequest = {
|
||||
title: string
|
||||
url: string
|
||||
createdAt: string
|
||||
authorAssociation: string
|
||||
reactionGroups: Array<{
|
||||
content: string
|
||||
users: {
|
||||
@@ -147,25 +145,19 @@ async function main() {
|
||||
console.log(`Threshold: fewer than ${threshold} positive reactions`)
|
||||
|
||||
const prs = await fetchOpenPullRequests()
|
||||
const scored = prs.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) }))
|
||||
const recentCount = scored.filter((pr) => new Date(pr.createdAt) >= cutoff).length
|
||||
const teamCount = scored.filter((pr) => isTeamMember(pr)).length
|
||||
const matching = scored.filter(
|
||||
(pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold,
|
||||
)
|
||||
const recentCount = prs.filter((pr) => new Date(pr.createdAt) >= cutoff).length
|
||||
const matching = prs
|
||||
.map((pr) => ({ ...pr, positiveReactions: positiveReactionCount(pr) }))
|
||||
.filter((pr) => new Date(pr.createdAt) < cutoff && pr.positiveReactions < threshold)
|
||||
const candidates = matching.filter((pr) => !hasPriorCleanup(pr))
|
||||
const selected = maxClose === undefined ? candidates : candidates.slice(0, maxClose)
|
||||
|
||||
console.log(`Fetched ${prs.length} open PRs`)
|
||||
console.log(`Matching cleanup criteria: ${matching.length}`)
|
||||
console.log(`Skipped previously cleaned PRs: ${matching.length - candidates.length}`)
|
||||
console.log(`Team member PRs untouched: ${teamCount}`)
|
||||
console.log(`Recent PRs untouched: ${recentCount}`)
|
||||
console.log(
|
||||
`Older PRs with at least ${threshold} positive reactions untouched: ${
|
||||
scored.filter((pr) => !isTeamMember(pr) && new Date(pr.createdAt) < cutoff && pr.positiveReactions >= threshold)
|
||||
.length
|
||||
}`,
|
||||
`Older PRs with at least ${threshold} positive reactions untouched: ${prs.length - matching.length - recentCount}`,
|
||||
)
|
||||
|
||||
if (selected.length === 0) return
|
||||
@@ -213,7 +205,6 @@ async function fetchOpenPullRequests() {
|
||||
title
|
||||
url
|
||||
createdAt
|
||||
authorAssociation
|
||||
reactionGroups {
|
||||
content
|
||||
users {
|
||||
@@ -344,10 +335,6 @@ function hasPriorCleanup(pr: PullRequest) {
|
||||
return pr.labels.nodes.some((label) => label.name === cleanupLabel)
|
||||
}
|
||||
|
||||
function isTeamMember(pr: PullRequest) {
|
||||
return teamAssociations.has(pr.authorAssociation)
|
||||
}
|
||||
|
||||
function requireRepo(value: string | undefined) {
|
||||
if (!value) throw new Error("repo is required")
|
||||
const [owner, name] = value.split("/")
|
||||
|
||||
Reference in New Issue
Block a user