mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-13 23:09:50 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a74b79834 | |||
| 7b89f06402 | |||
| 3a41ee8817 | |||
| b882ccc57d | |||
| 4836356c17 | |||
| 90fd61225e | |||
| e5d79ae187 | |||
| b66f01b37c | |||
| d2b8cb8081 | |||
| ed708f9dc2 | |||
| f2408060b7 | |||
| 9fff6e2b4f | |||
| 2cf20e660e | |||
| fb8b4c4ce6 | |||
| 1b587823b6 | |||
| c7de57ee0e | |||
| 642772e2a5 | |||
| 595e4c8c96 | |||
| 20929b3081 | |||
| 8bcc245142 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
|
||||||
@@ -171,4 +171,4 @@ const table = sqliteTable("session", {
|
|||||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
|
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||||
|
|||||||
@@ -393,7 +393,6 @@
|
|||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"turndown": "7.2.0",
|
|
||||||
"venice-ai-sdk-provider": "2.1.1",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
"web-tree-sitter": "0.25.10",
|
||||||
"which": "6.0.1",
|
"which": "6.0.1",
|
||||||
@@ -414,7 +413,6 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/turndown": "5.0.5",
|
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -1966,8 +1964,6 @@
|
|||||||
|
|
||||||
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
|
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
|
||||||
|
|
||||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||||
@@ -3258,8 +3254,6 @@
|
|||||||
|
|
||||||
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
|
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
|
||||||
|
|
||||||
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
|
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
||||||
@@ -5774,8 +5768,6 @@
|
|||||||
|
|
||||||
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="],
|
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="],
|
||||||
|
|
||||||
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
|
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
|
||||||
|
|||||||
@@ -79,7 +79,6 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/turndown": "5.0.5",
|
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
@@ -137,7 +136,6 @@
|
|||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"turndown": "7.2.0",
|
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"venice-ai-sdk-provider": "2.1.1",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ const layer = Layer.effect(
|
|||||||
return { id: info?.id ?? defaultID, info }
|
return { id: info?.id ?? defaultID, info }
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("Agent.list")(function* () {
|
list: Effect.fn("Agent.list")(function* () {
|
||||||
return Array.fromIterable(state.get().agents.values())
|
const agents = Array.fromIterable(state.get().agents.values())
|
||||||
|
const selected = selectedDefault()
|
||||||
|
if (!selected) return agents
|
||||||
|
return [selected, ...agents.filter((agent) => agent.id !== selected.id)]
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
|
|||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
import { Clock, Context, Duration, Effect, Layer, Schema, Scope } from "effect"
|
||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { FileSystem } from "../filesystem.js"
|
import { FileSystem } from "../filesystem.js"
|
||||||
@@ -22,38 +22,64 @@ export type Options = typeof Options.Type
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
|
||||||
|
|
||||||
export const ripgrepLayer = Layer.effect(
|
export const ripgrepLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const files: string[] = []
|
const clock = yield* Clock.Clock
|
||||||
const directories = new Set<string>()
|
|
||||||
const home = Protected.isHome(location.directory)
|
const home = Protected.isHome(location.directory)
|
||||||
yield* ripgrep
|
let index = { files: [] as string[], directories: new Set<string>() }
|
||||||
.find({
|
let initialized = false
|
||||||
|
let settledAt = Number.NEGATIVE_INFINITY
|
||||||
|
let refreshing = false
|
||||||
|
const scan = Effect.gen(function* () {
|
||||||
|
const next = { files: [] as string[], directories: new Set<string>() }
|
||||||
|
if (!initialized) index = next
|
||||||
|
yield* ripgrep.find({
|
||||||
cwd: location.directory,
|
cwd: location.directory,
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||||
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
files.push(entry.path)
|
next.files.push(entry.path)
|
||||||
const parts = entry.path.split("/")
|
const parts = entry.path.split("/")
|
||||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
parts
|
||||||
|
.slice(0, -1)
|
||||||
|
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
index = next
|
||||||
|
initialized = true
|
||||||
|
}).pipe(
|
||||||
|
Effect.orDie,
|
||||||
|
Effect.ensuring(
|
||||||
|
Effect.sync(() => {
|
||||||
|
settledAt = clock.currentTimeMillisUnsafe()
|
||||||
|
refreshing = false
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const refresh = Effect.sync(() => {
|
||||||
|
if (refreshing || clock.currentTimeMillisUnsafe() < settledAt + REFRESH_INTERVAL) return
|
||||||
|
refreshing = true
|
||||||
|
return scan
|
||||||
|
}).pipe(Effect.flatMap((effect) => (effect ? effect.pipe(Effect.forkIn(scope)) : Effect.void)))
|
||||||
|
yield* refresh
|
||||||
return Service.of({
|
return Service.of({
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
yield* refresh
|
||||||
const items =
|
const items =
|
||||||
input.type === "file"
|
input.type === "file"
|
||||||
? files
|
? index.files
|
||||||
: input.type === "directory"
|
: input.type === "directory"
|
||||||
? Array.from(directories)
|
? Array.from(index.directories)
|
||||||
: [...files, ...directories]
|
: [...index.files, ...index.directories]
|
||||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||||
const relative = item.target
|
const relative = item.target
|
||||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * as InstructionDiscovery from "./instruction-discovery.js"
|
|||||||
|
|
||||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
|
import { createPatch } from "diff"
|
||||||
import { Bus } from "./bus.js"
|
import { Bus } from "./bus.js"
|
||||||
import { Instructions } from "./instructions/index.js"
|
import { Instructions } from "./instructions/index.js"
|
||||||
import { AbsolutePath } from "./schema.js"
|
import { AbsolutePath } from "./schema.js"
|
||||||
@@ -81,8 +82,7 @@ export const layer = (options?: Options) =>
|
|||||||
read: Effect.succeed(value),
|
read: Effect.succeed(value),
|
||||||
render: {
|
render: {
|
||||||
initial: render,
|
initial: render,
|
||||||
changed: (_previous, current) =>
|
changed: renderUpdate,
|
||||||
`These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`,
|
|
||||||
removed: () => "Previously loaded instructions no longer apply.",
|
removed: () => "Previously loaded instructions no longer apply.",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -120,3 +120,27 @@ export const node = configured()
|
|||||||
function render(files: ReadonlyArray<File>) {
|
function render(files: ReadonlyArray<File>) {
|
||||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderUpdate(previous: ReadonlyArray<File>, current: ReadonlyArray<File>) {
|
||||||
|
const changes = Instructions.diffByKey(
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
(file) => file.path,
|
||||||
|
(before, after) => before.content !== after.content,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
...changes.removed.map((file) => `The instructions from ${file.path} no longer apply.`),
|
||||||
|
...changes.added.map((file) => `New instructions apply from:\n${render([file])}`),
|
||||||
|
...changes.changed.map(({ previous: before, current: after }) => {
|
||||||
|
const patch = createPatch(after.path, before.content, after.content, "", "", { context: 3 })
|
||||||
|
const diff = [
|
||||||
|
`The instructions from ${after.path} changed. Here's the diff:`,
|
||||||
|
"```diff",
|
||||||
|
patch.slice(patch.indexOf("@@")).trimEnd(),
|
||||||
|
"```",
|
||||||
|
].join("\n")
|
||||||
|
const replacement = `The instructions changed:\n${render([after])}`
|
||||||
|
return diff.length < replacement.length ? diff : replacement
|
||||||
|
}),
|
||||||
|
].join("\n\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -436,7 +436,6 @@ const layer = Layer.effectDiscard(
|
|||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* InstructionState.reset(db, event.data.sessionID)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
// Sessions whose ownership came from the directory's previous resolution
|
// Sessions whose ownership came from the directory's previous resolution
|
||||||
|
|||||||
@@ -0,0 +1,662 @@
|
|||||||
|
import { Parser } from "htmlparser2"
|
||||||
|
|
||||||
|
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
|
||||||
|
const blocks = new Set([
|
||||||
|
"address",
|
||||||
|
"article",
|
||||||
|
"aside",
|
||||||
|
"details",
|
||||||
|
"dialog",
|
||||||
|
"div",
|
||||||
|
"dl",
|
||||||
|
"fieldset",
|
||||||
|
"figcaption",
|
||||||
|
"figure",
|
||||||
|
"footer",
|
||||||
|
"form",
|
||||||
|
"header",
|
||||||
|
"main",
|
||||||
|
"nav",
|
||||||
|
"p",
|
||||||
|
"section",
|
||||||
|
"summary",
|
||||||
|
])
|
||||||
|
|
||||||
|
type Frame = {
|
||||||
|
suppressed: boolean
|
||||||
|
link?: { href: string; title?: string }
|
||||||
|
suspendedLink?: Frame["link"]
|
||||||
|
marker?: { index: number; block: number; leadingSpace?: boolean; previous?: Frame["marker"] }
|
||||||
|
code?: { inline: boolean; text: string; language?: string }
|
||||||
|
linkCode?: NonNullable<Frame["code"]>
|
||||||
|
resumedCode?: NonNullable<Frame["code"]>
|
||||||
|
list?: { ordered: boolean; next: number; previous?: Frame["list"] }
|
||||||
|
item?: { indent: string; previous?: Frame["item"] }
|
||||||
|
table?: {
|
||||||
|
start: number
|
||||||
|
rows: string[][]
|
||||||
|
row?: string[]
|
||||||
|
caption?: string
|
||||||
|
fallback: boolean
|
||||||
|
previous?: Frame["table"]
|
||||||
|
}
|
||||||
|
cell?: { start: number }
|
||||||
|
caption?: { start: number }
|
||||||
|
details?: { open: boolean; summary: boolean; previous?: Frame["details"] }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Chunk = string | { raw: string }
|
||||||
|
|
||||||
|
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
|
||||||
|
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
|
||||||
|
|
||||||
|
export function convertHTMLToMarkdown(html: string) {
|
||||||
|
const output: Chunk[] = []
|
||||||
|
const stack: Frame[] = []
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
let pendingSpace = false
|
||||||
|
let pendingIndent = ""
|
||||||
|
let last = ""
|
||||||
|
let quoteDepth = 0
|
||||||
|
let needsQuotePrefix = false
|
||||||
|
let blockCount = 0
|
||||||
|
let depth = 0
|
||||||
|
let stopped = false
|
||||||
|
let outputBytes = 0
|
||||||
|
let activeCode: NonNullable<Frame["code"]> | undefined
|
||||||
|
let activeLink: Frame["link"] | undefined
|
||||||
|
let linkOpen = false
|
||||||
|
let activeMarker: Frame["marker"] | undefined
|
||||||
|
let activeList: Frame["list"] | undefined
|
||||||
|
let activeItem: Frame["item"] | undefined
|
||||||
|
let activeTable: NonNullable<Frame["table"]> | undefined
|
||||||
|
let activeCell: Frame["cell"] | undefined
|
||||||
|
let tableDepth = 0
|
||||||
|
let fallbackSuppressedDepth = 0
|
||||||
|
let fallbackOmittedDepth = 0
|
||||||
|
let activeDetails: Frame["details"] | undefined
|
||||||
|
|
||||||
|
const sliceBytes = (value: string, bytes: number) => {
|
||||||
|
if (encoder.encode(value).byteLength <= bytes) return value
|
||||||
|
const characters = Array.from(value)
|
||||||
|
let low = 0
|
||||||
|
let high = characters.length
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.ceil((low + high) / 2)
|
||||||
|
if (encoder.encode(characters.slice(0, middle).join("")).byteLength <= bytes) low = middle
|
||||||
|
else high = middle - 1
|
||||||
|
}
|
||||||
|
return characters.slice(0, low).join("")
|
||||||
|
}
|
||||||
|
const append = (value: string, content = false) => {
|
||||||
|
const limit = content ? CONTENT_BYTES : MAX_MARKDOWN_BYTES
|
||||||
|
if (!value || outputBytes >= limit) return
|
||||||
|
const bytes = encoder.encode(value)
|
||||||
|
const remaining = limit - outputBytes
|
||||||
|
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
|
||||||
|
output.push(next)
|
||||||
|
outputBytes += encoder.encode(next).byteLength
|
||||||
|
last = next.at(-1) ?? last
|
||||||
|
}
|
||||||
|
const appendRaw = (value: string) => {
|
||||||
|
const before = output.length
|
||||||
|
append(value)
|
||||||
|
if (output.length > before) output[output.length - 1] = { raw: output[output.length - 1] as string }
|
||||||
|
}
|
||||||
|
const take = (start: number) => {
|
||||||
|
const chunks = output.splice(start)
|
||||||
|
const value = chunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk.raw)).join("")
|
||||||
|
outputBytes -= encoder.encode(value).byteLength
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
const prefixQuote = () => {
|
||||||
|
if (!needsQuotePrefix || quoteDepth === 0 || activeCell) return
|
||||||
|
append(`${"> ".repeat(Math.min(8, quoteDepth))}`)
|
||||||
|
needsQuotePrefix = false
|
||||||
|
}
|
||||||
|
const flushSpace = () => {
|
||||||
|
if (!pendingSpace) return
|
||||||
|
const marker = activeMarker
|
||||||
|
if (marker && output.length === marker.index + 1 && last !== " " && last !== "\n") {
|
||||||
|
const value = output[marker.index]
|
||||||
|
if (typeof value === "string") output[marker.index] = ` ${value}`
|
||||||
|
outputBytes++
|
||||||
|
marker.leadingSpace = true
|
||||||
|
pendingSpace = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (last && last !== "\n" && last !== " ") append(" ")
|
||||||
|
pendingSpace = false
|
||||||
|
}
|
||||||
|
const inline = (value: string, open = false) => {
|
||||||
|
if (open) flushSpace()
|
||||||
|
prefixQuote()
|
||||||
|
if (pendingIndent) {
|
||||||
|
append(pendingIndent)
|
||||||
|
pendingIndent = ""
|
||||||
|
}
|
||||||
|
if (activeLink && !linkOpen) {
|
||||||
|
append("[")
|
||||||
|
linkOpen = true
|
||||||
|
}
|
||||||
|
append(value)
|
||||||
|
}
|
||||||
|
const block = () => {
|
||||||
|
if (activeLink && linkOpen) {
|
||||||
|
append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
|
||||||
|
linkOpen = false
|
||||||
|
}
|
||||||
|
pendingSpace = false
|
||||||
|
append("\n\n")
|
||||||
|
blockCount++
|
||||||
|
needsQuotePrefix = quoteDepth > 0
|
||||||
|
pendingIndent = activeItem?.indent ?? ""
|
||||||
|
}
|
||||||
|
const suspendLink = (frame: Frame) => {
|
||||||
|
if (!activeLink) return
|
||||||
|
frame.suspendedLink = activeLink
|
||||||
|
block()
|
||||||
|
activeLink = undefined
|
||||||
|
linkOpen = false
|
||||||
|
}
|
||||||
|
const text = (value: string) => {
|
||||||
|
if (activeCode) {
|
||||||
|
activeCode.text += value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const part of value.split(/([\t\n\f\r ]+)/)) {
|
||||||
|
if (!part) continue
|
||||||
|
if (/^[\t\n\f\r ]+$/.test(part)) {
|
||||||
|
pendingSpace = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flushSpace()
|
||||||
|
prefixQuote()
|
||||||
|
if (pendingIndent) {
|
||||||
|
append(pendingIndent)
|
||||||
|
pendingIndent = ""
|
||||||
|
}
|
||||||
|
if (activeLink && !linkOpen) {
|
||||||
|
append("[")
|
||||||
|
linkOpen = true
|
||||||
|
}
|
||||||
|
const escaped = part
|
||||||
|
.replace(/([\\`*_[\]<>|])/g, "\\$1")
|
||||||
|
.replace(/~/g, "\\~")
|
||||||
|
.replace(/^([#+-])/, "\\$1")
|
||||||
|
.replace(/^(\d+)\./, "$1\\.")
|
||||||
|
append(escaped, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const destination = (value: string) => value.replace(/([\\()])/g, "\\$1").replace(/[\t\n\r ]+/g, "%20")
|
||||||
|
const title = (value: string | undefined) =>
|
||||||
|
value
|
||||||
|
? ` "${value
|
||||||
|
.replace(/[\t\n\r ]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/([\\"])/g, "\\$1")}"`
|
||||||
|
: ""
|
||||||
|
const finishCode = (code: NonNullable<Frame["code"]>) => {
|
||||||
|
if (code.inline && !code.text) return
|
||||||
|
let backticks = 0
|
||||||
|
let tildes = 0
|
||||||
|
let currentBackticks = 0
|
||||||
|
let currentTildes = 0
|
||||||
|
for (const character of code.text) {
|
||||||
|
currentBackticks = character === "`" ? currentBackticks + 1 : 0
|
||||||
|
currentTildes = character === "~" ? currentTildes + 1 : 0
|
||||||
|
backticks = Math.max(backticks, currentBackticks)
|
||||||
|
tildes = Math.max(tildes, currentTildes)
|
||||||
|
}
|
||||||
|
if (code.inline) {
|
||||||
|
const fence = "`".repeat(Math.max(1, backticks + 1))
|
||||||
|
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
|
||||||
|
flushSpace()
|
||||||
|
prefixQuote()
|
||||||
|
const wrapper = encoder.encode(`${fence}${padding}${padding}${fence}`).byteLength
|
||||||
|
appendRaw(
|
||||||
|
`${fence}${padding}${sliceBytes(code.text, Math.max(0, CONTENT_BYTES - outputBytes - wrapper))}${padding}${fence}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeCell) {
|
||||||
|
text(code.text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const marker = backticks <= tildes ? "`" : "~"
|
||||||
|
const length = Math.max(3, (marker === "`" ? backticks : tildes) + 1)
|
||||||
|
const fence = marker.repeat(length)
|
||||||
|
block()
|
||||||
|
const prefix = `${fence}${code.language ?? ""}\n`
|
||||||
|
const quote = quoteDepth > 0 ? `${"> ".repeat(Math.min(8, quoteDepth))}` : ""
|
||||||
|
const closing = `${code.text.endsWith("\n") ? "" : "\n"}${fence}`
|
||||||
|
let payload = code.text
|
||||||
|
for (;;) {
|
||||||
|
const candidate = `${prefix}${payload}${payload.endsWith("\n") ? "" : "\n"}${fence}`
|
||||||
|
const value = quote ? candidate.replace(/^/gm, quote) : candidate
|
||||||
|
const valueBytes = encoder.encode(value).byteLength
|
||||||
|
if (outputBytes + valueBytes <= CONTENT_BYTES) {
|
||||||
|
appendRaw(value)
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const excess = valueBytes - Math.max(0, CONTENT_BYTES - outputBytes)
|
||||||
|
payload = sliceBytes(payload, Math.max(0, encoder.encode(payload).byteLength - Math.ceil(excess)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new Parser({
|
||||||
|
onopentag(name, attributes) {
|
||||||
|
depth++
|
||||||
|
if (depth > 10_000) {
|
||||||
|
if (stack.at(-1)?.suppressed) {
|
||||||
|
const visibleParent = stack.findLastIndex((frame) => !frame.suppressed)
|
||||||
|
fallbackSuppressedDepth = visibleParent + 2
|
||||||
|
}
|
||||||
|
activeCode = undefined
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
if (stopped) {
|
||||||
|
if (omitted.has(name)) fallbackOmittedDepth++
|
||||||
|
else pendingSpace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const suppressed = (stack.at(-1)?.suppressed ?? false) || omitted.has(name)
|
||||||
|
const frame: Frame = { suppressed }
|
||||||
|
const hidden = "hidden" in attributes || attributes["aria-hidden"]?.toLowerCase() === "true" || name === "head"
|
||||||
|
const details = activeDetails
|
||||||
|
if (hidden || (details && !details.open && !details.summary && name !== "summary")) frame.suppressed = true
|
||||||
|
stack.push(frame)
|
||||||
|
if (frame.suppressed) return
|
||||||
|
|
||||||
|
if (activeCode && !activeCode.inline) {
|
||||||
|
if (name === "br") activeCode.text += "\n"
|
||||||
|
if (name === "code" && attributes.class)
|
||||||
|
activeCode.language = attributes.class.match(/(?:language-|lang-)([^\s]+)/)?.[1]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "details") {
|
||||||
|
frame.details = { open: "open" in attributes, summary: false, previous: activeDetails }
|
||||||
|
activeDetails = frame.details
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "summary") {
|
||||||
|
if (details) details.summary = true
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "pre") {
|
||||||
|
suspendLink(frame)
|
||||||
|
frame.code = { inline: false, text: "" }
|
||||||
|
activeCode = frame.code
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "code") {
|
||||||
|
if (activeCode?.inline) return
|
||||||
|
frame.code = { inline: true, text: "" }
|
||||||
|
activeCode = frame.code
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (/^h[1-6]$/.test(name)) {
|
||||||
|
block()
|
||||||
|
inline(`${"#".repeat(Number(name[1]))} `)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (blocks.has(name)) {
|
||||||
|
suspendLink(frame)
|
||||||
|
if (name === "p" && last === " ") return
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "br") {
|
||||||
|
pendingSpace = false
|
||||||
|
inline(" \n")
|
||||||
|
needsQuotePrefix = quoteDepth > 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "hr") {
|
||||||
|
block()
|
||||||
|
inline("---")
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "strong" || name === "b") {
|
||||||
|
inline("**", true)
|
||||||
|
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
|
||||||
|
activeMarker = frame.marker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "em" || name === "i") {
|
||||||
|
inline("*", true)
|
||||||
|
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
|
||||||
|
activeMarker = frame.marker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "s" || name === "strike" || name === "del") {
|
||||||
|
inline("~~", true)
|
||||||
|
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
|
||||||
|
activeMarker = frame.marker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "a") {
|
||||||
|
if (activeLink && activeCode?.inline) {
|
||||||
|
const parent = stack.findLast(
|
||||||
|
(candidate) => candidate.link === activeLink && candidate.linkCode === activeCode,
|
||||||
|
)
|
||||||
|
if (parent) {
|
||||||
|
finishCode(activeCode)
|
||||||
|
if (linkOpen) append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
|
||||||
|
activeCode = parent.resumedCode
|
||||||
|
parent.link = undefined
|
||||||
|
parent.linkCode = undefined
|
||||||
|
activeLink = undefined
|
||||||
|
linkOpen = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeCode?.inline) {
|
||||||
|
frame.resumedCode = activeCode
|
||||||
|
if (activeCode.text) finishCode(activeCode)
|
||||||
|
activeCode.text = ""
|
||||||
|
activeCode = undefined
|
||||||
|
frame.link = { href: attributes.href ?? "", title: attributes.title }
|
||||||
|
activeLink = frame.link
|
||||||
|
linkOpen = true
|
||||||
|
inline("[", true)
|
||||||
|
frame.linkCode = { inline: true, text: "" }
|
||||||
|
activeCode = frame.linkCode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeLink) {
|
||||||
|
if (linkOpen) append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
|
||||||
|
const parent = stack.findLast((candidate) => candidate.link === activeLink)
|
||||||
|
if (parent) parent.link = undefined
|
||||||
|
activeLink = undefined
|
||||||
|
linkOpen = false
|
||||||
|
}
|
||||||
|
frame.link = { href: attributes.href ?? "", title: attributes.title }
|
||||||
|
activeLink = frame.link
|
||||||
|
linkOpen = true
|
||||||
|
return inline("[", true)
|
||||||
|
}
|
||||||
|
if (name === "img") {
|
||||||
|
const alt = (attributes.alt ?? "").replace(/([\\\]])/g, "\\$1")
|
||||||
|
const close = `](${destination(attributes.src ?? "")}${title(attributes.title)})`
|
||||||
|
const open = "!["
|
||||||
|
const available = CONTENT_BYTES - outputBytes - encoder.encode(open + close).byteLength
|
||||||
|
inline(`${open}${sliceBytes(alt, Math.max(0, available))}${close}`, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "blockquote") {
|
||||||
|
suspendLink(frame)
|
||||||
|
block()
|
||||||
|
quoteDepth++
|
||||||
|
needsQuotePrefix = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "ul" || name === "ol") {
|
||||||
|
suspendLink(frame)
|
||||||
|
const start = Number.parseInt(attributes.start ?? "1")
|
||||||
|
frame.list = { ordered: name === "ol", next: Number.isNaN(start) ? 1 : start, previous: activeList }
|
||||||
|
activeList = frame.list
|
||||||
|
block()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "li") {
|
||||||
|
block()
|
||||||
|
const value = Number.parseInt(attributes.value ?? "")
|
||||||
|
if (activeList?.ordered && !Number.isNaN(value)) activeList.next = value
|
||||||
|
const marker = activeList?.ordered ? `${activeList.next++}.` : "-"
|
||||||
|
const prefix = `${(activeItem?.indent ?? "").slice(0, 24)}${marker} `
|
||||||
|
frame.item = { indent: " ".repeat(prefix.length), previous: activeItem }
|
||||||
|
activeItem = frame.item
|
||||||
|
pendingIndent = ""
|
||||||
|
inline(prefix)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "table") {
|
||||||
|
suspendLink(frame)
|
||||||
|
tableDepth++
|
||||||
|
if (tableDepth === 1) {
|
||||||
|
block()
|
||||||
|
frame.table = { start: output.length, rows: [], fallback: false, previous: activeTable }
|
||||||
|
activeTable = frame.table
|
||||||
|
} else pendingSpace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "tr") {
|
||||||
|
if (tableDepth !== 1) {
|
||||||
|
pendingSpace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeTable) activeTable.row = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "th" || name === "td") {
|
||||||
|
if (tableDepth !== 1) {
|
||||||
|
pendingSpace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (attributes.colspan || attributes.rowspan) activeTable!.fallback = true
|
||||||
|
frame.cell = { start: output.length }
|
||||||
|
activeCell = frame.cell
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "caption") {
|
||||||
|
frame.caption = { start: output.length }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "dt") {
|
||||||
|
block()
|
||||||
|
inline("**")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "dd") {
|
||||||
|
inline("\n: ")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ontext(value) {
|
||||||
|
if (stopped) {
|
||||||
|
if (fallbackSuppressedDepth === 0 && fallbackOmittedDepth === 0) text(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (stack.at(-1)?.suppressed) return
|
||||||
|
text(value)
|
||||||
|
},
|
||||||
|
onclosetag(name) {
|
||||||
|
depth--
|
||||||
|
if (stopped) {
|
||||||
|
if (fallbackOmittedDepth > 0 && omitted.has(name)) fallbackOmittedDepth--
|
||||||
|
if (fallbackSuppressedDepth > 0 && depth < fallbackSuppressedDepth) fallbackSuppressedDepth = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const frame = stack.pop()
|
||||||
|
if (!frame || frame.suppressed) return
|
||||||
|
if (frame.linkCode) {
|
||||||
|
if (activeLink !== frame.link || !linkOpen) {
|
||||||
|
frame.resumedCode!.text += frame.linkCode.text
|
||||||
|
activeCode = frame.resumedCode
|
||||||
|
if (activeLink === frame.link) activeLink = undefined
|
||||||
|
linkOpen = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeCode = undefined
|
||||||
|
finishCode(frame.linkCode)
|
||||||
|
if (frame.link && linkOpen) append(`](${destination(frame.link.href)}${title(frame.link.title)})`)
|
||||||
|
activeLink = undefined
|
||||||
|
linkOpen = false
|
||||||
|
activeCode = frame.resumedCode
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activeCode && !activeCode.inline && !frame.code) return
|
||||||
|
if (frame.code) {
|
||||||
|
activeCode = undefined
|
||||||
|
finishCode(frame.code)
|
||||||
|
if (frame.suspendedLink) activeLink = frame.suspendedLink
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "summary") {
|
||||||
|
if (activeDetails) activeDetails.summary = false
|
||||||
|
return block()
|
||||||
|
}
|
||||||
|
if (name === "details") {
|
||||||
|
activeDetails = frame.details?.previous
|
||||||
|
return block()
|
||||||
|
}
|
||||||
|
if (name === "dt") {
|
||||||
|
inline("**")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "dd") return block()
|
||||||
|
if (
|
||||||
|
name === "strong" ||
|
||||||
|
name === "b" ||
|
||||||
|
name === "em" ||
|
||||||
|
name === "i" ||
|
||||||
|
name === "s" ||
|
||||||
|
name === "strike" ||
|
||||||
|
name === "del"
|
||||||
|
) {
|
||||||
|
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
|
||||||
|
const trailingSpace = pendingSpace
|
||||||
|
pendingSpace = false
|
||||||
|
if (frame.marker) activeMarker = frame.marker.previous
|
||||||
|
if (frame.marker && (frame.marker.block !== blockCount || output.length === frame.marker.index + 1)) {
|
||||||
|
output[frame.marker.index] = ""
|
||||||
|
pendingSpace = trailingSpace || frame.marker.leadingSpace === true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inline(value)
|
||||||
|
pendingSpace = trailingSpace || frame.marker?.leadingSpace === true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "a") {
|
||||||
|
if (frame.link && (activeLink === frame.link || !activeLink)) {
|
||||||
|
activeLink = frame.link
|
||||||
|
if (linkOpen) append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
|
||||||
|
else if (last && last !== "\n") append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
|
||||||
|
linkOpen = false
|
||||||
|
activeLink = undefined
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (/^h[1-6]$/.test(name) || blocks.has(name)) {
|
||||||
|
block()
|
||||||
|
if (frame.suspendedLink) activeLink = frame.suspendedLink
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "blockquote") {
|
||||||
|
quoteDepth--
|
||||||
|
block()
|
||||||
|
if (frame.suspendedLink) activeLink = frame.suspendedLink
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "li") {
|
||||||
|
activeItem = frame.item?.previous
|
||||||
|
return block()
|
||||||
|
}
|
||||||
|
if (name === "ul" || name === "ol") {
|
||||||
|
activeList = frame.list?.previous
|
||||||
|
block()
|
||||||
|
if (frame.suspendedLink) activeLink = frame.suspendedLink
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((name === "th" || name === "td") && tableDepth === 1) {
|
||||||
|
activeCell = undefined
|
||||||
|
if (frame.cell) {
|
||||||
|
const value = take(frame.cell.start)
|
||||||
|
.replace(/[\t\r\n ]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.replace(/(?<!\\)\|/g, "\\|")
|
||||||
|
activeTable?.row?.push(value)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "tr") {
|
||||||
|
if (tableDepth !== 1) return
|
||||||
|
if (activeTable?.row) activeTable.rows.push(activeTable.row)
|
||||||
|
if (activeTable) activeTable.row = undefined
|
||||||
|
pendingSpace = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "caption" && frame.caption && activeTable) {
|
||||||
|
activeTable.caption = take(frame.caption.start)
|
||||||
|
.replace(/[\t\r\n ]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (name === "table") {
|
||||||
|
tableDepth--
|
||||||
|
if (tableDepth === 0) {
|
||||||
|
const table = frame.table
|
||||||
|
activeTable = table?.previous
|
||||||
|
if (table) {
|
||||||
|
const loose = take(table.start)
|
||||||
|
.replace(/[\t\r\n ]+/g, " ")
|
||||||
|
.trim()
|
||||||
|
const width = table.rows[0]?.length ?? 0
|
||||||
|
const rectangular = width > 0 && table.rows.every((row) => row.length === width)
|
||||||
|
if (loose) {
|
||||||
|
append(loose)
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
if (table.caption) {
|
||||||
|
append(table.caption)
|
||||||
|
block()
|
||||||
|
}
|
||||||
|
if (!table.fallback && rectangular) {
|
||||||
|
const prefix = `${quoteDepth > 0 ? `${"> ".repeat(Math.min(8, quoteDepth))}` : ""}${pendingIndent}`
|
||||||
|
pendingIndent = ""
|
||||||
|
append(`${prefix}| ${table.rows[0].join(" | ")} |\n${prefix}|${" --- |".repeat(width)}`)
|
||||||
|
for (const row of table.rows.slice(1)) append(`\n${prefix}| ${row.join(" | ")} |`)
|
||||||
|
} else {
|
||||||
|
for (const [index, row] of table.rows.entries()) {
|
||||||
|
if (index > 0) block()
|
||||||
|
append(row.join(" | "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
block()
|
||||||
|
if (frame.suspendedLink) activeLink = frame.suspendedLink
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSpace = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for (let index = 0; index < html.length; index += 64 * 1024) parser.write(html.slice(index, index + 64 * 1024))
|
||||||
|
parser.end()
|
||||||
|
|
||||||
|
const normalized: string[] = []
|
||||||
|
let pendingText = ""
|
||||||
|
const flushText = () => {
|
||||||
|
if (!pendingText) return
|
||||||
|
const lines = pendingText
|
||||||
|
.replace(/[ \t]+\n/g, (space) => (space.startsWith(" ") ? " \n" : "\n"))
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.split("\n")
|
||||||
|
normalized.push(
|
||||||
|
lines
|
||||||
|
.map((line, index) => {
|
||||||
|
if (line) return line
|
||||||
|
const before = lines[index - 1]?.match(/^(?:> )+/)?.[0]
|
||||||
|
const after = lines[index + 1]?.match(/^(?:> )+/)?.[0]
|
||||||
|
if (!before || !after || before.length === after.length) return line
|
||||||
|
return "> ".repeat(Math.min(before.length, after.length) / 2).trimEnd()
|
||||||
|
})
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
pendingText = ""
|
||||||
|
}
|
||||||
|
for (const chunk of output) {
|
||||||
|
if (typeof chunk !== "string") {
|
||||||
|
flushText()
|
||||||
|
normalized.push(chunk.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pendingText += chunk
|
||||||
|
}
|
||||||
|
flushText()
|
||||||
|
return sliceBytes(normalized.join("").trim(), MAX_MARKDOWN_BYTES)
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { Duration, Effect, Schema } from "effect"
|
import { Duration, Effect, Schema } from "effect"
|
||||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { Parser } from "htmlparser2"
|
import { Parser } from "htmlparser2"
|
||||||
import TurndownService from "turndown"
|
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
|
import { convertHTMLToMarkdown, MAX_MARKDOWN_BYTES } from "../html-markdown.js"
|
||||||
import { collectBoundedResponseBody } from "../http-body.js"
|
import { collectBoundedResponseBody } from "../http-body.js"
|
||||||
|
|
||||||
export const name = "webfetch"
|
export const name = "webfetch"
|
||||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
export const MAX_RESPONSE_BYTES = MAX_MARKDOWN_BYTES
|
||||||
export const DEFAULT_TIMEOUT_SECONDS = 30
|
export const DEFAULT_TIMEOUT_SECONDS = 30
|
||||||
export const MAX_TIMEOUT_SECONDS = 120
|
export const MAX_TIMEOUT_SECONDS = 120
|
||||||
|
|
||||||
@@ -196,14 +196,4 @@ export function extractTextFromHTML(html: string) {
|
|||||||
return text.trim()
|
return text.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertHTMLToMarkdown(html: string) {
|
export { convertHTMLToMarkdown }
|
||||||
const turndown = new TurndownService({
|
|
||||||
headingStyle: "atx",
|
|
||||||
hr: "---",
|
|
||||||
bulletListMarker: "-",
|
|
||||||
codeBlockStyle: "fenced",
|
|
||||||
emDelimiter: "*",
|
|
||||||
})
|
|
||||||
turndown.remove(["script", "style", "meta", "link"])
|
|
||||||
return turndown.turndown(html)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -68,6 +68,26 @@ describe("Agent", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("lists the selected default agent first", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const agent = yield* Agent.Service
|
||||||
|
yield* agent.transform((editor) => {
|
||||||
|
editor.update(Agent.ID.make("build"), (info) => {
|
||||||
|
info.mode = "primary"
|
||||||
|
})
|
||||||
|
editor.update(Agent.ID.make("reviewer"), (info) => {
|
||||||
|
info.mode = "primary"
|
||||||
|
})
|
||||||
|
editor.update(Agent.ID.make("explore"), (info) => {
|
||||||
|
info.mode = "subagent"
|
||||||
|
})
|
||||||
|
editor.default(Agent.ID.make("reviewer"))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((yield* agent.list()).map((info) => String(info.id))).toEqual(["reviewer", "build", "explore"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("rebuilds state when a transform is replaced", () =>
|
it.effect("rebuilds state when a transform is replaced", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agent = yield* Agent.Service
|
const agent = yield* Agent.Service
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, Layer } from "effect"
|
import { Deferred, Effect, Layer } from "effect"
|
||||||
|
import { TestClock } from "effect/testing"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||||
@@ -56,4 +57,70 @@ describe("FileSystemSearch", () => {
|
|||||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||||
|
let scans = 0
|
||||||
|
const initial = Effect.runSync(Deferred.make<void>())
|
||||||
|
const started = Effect.runSync(Deferred.make<void>())
|
||||||
|
const release = Effect.runSync(Deferred.make<void>())
|
||||||
|
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Ripgrep.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Ripgrep.Service,
|
||||||
|
Ripgrep.Service.of({
|
||||||
|
find: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
scans++
|
||||||
|
if (scans > 1) {
|
||||||
|
yield* Deferred.succeed(started, undefined)
|
||||||
|
yield* Deferred.await(release)
|
||||||
|
}
|
||||||
|
const entry = FileSystem.Entry.make({
|
||||||
|
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||||
|
type: "file",
|
||||||
|
})
|
||||||
|
if (input.onEntry) yield* input.onEntry(entry)
|
||||||
|
if (scans === 1) yield* Deferred.succeed(initial, undefined)
|
||||||
|
return [entry]
|
||||||
|
}),
|
||||||
|
glob: () => Effect.succeed([]),
|
||||||
|
grep: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const search = yield* FileSystemSearch.Service
|
||||||
|
yield* Deferred.await(initial)
|
||||||
|
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||||
|
expect(scans).toBe(1)
|
||||||
|
|
||||||
|
yield* TestClock.adjust("10 seconds")
|
||||||
|
yield* search.find({ query: "old", type: "file" })
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||||
|
expect(scans).toBe(2)
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
const refreshed = yield* Effect.gen(function* () {
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
return yield* search.find({ query: "new", type: "file" })
|
||||||
|
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||||
|
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||||
|
expect(scans).toBe(2)
|
||||||
|
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -111,6 +111,50 @@ describe("InstructionDiscovery", () => {
|
|||||||
).toBe(false)
|
).toBe(false)
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("renders granular instruction updates", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const discovery = yield* InstructionDiscovery.Service
|
||||||
|
yield* discovery.transform((draft) => {
|
||||||
|
draft.add(file("/global/AGENTS.md", "global"))
|
||||||
|
draft.add(
|
||||||
|
file("/repo/AGENTS.md", ["old", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const initial = yield* readInitial(yield* discovery.load())
|
||||||
|
|
||||||
|
yield* discovery.transform((draft) => {
|
||||||
|
draft.update("/repo/AGENTS.md", (current) => {
|
||||||
|
current.content = ["new", ...Array.from({ length: 20 }, (_, index) => `keep ${index}`)].join("\n")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const modified = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||||
|
expect(modified).toContain("The instructions from /repo/AGENTS.md changed. Here's the diff:")
|
||||||
|
expect(modified).toContain("-old\n+new")
|
||||||
|
expect(modified).not.toContain("global")
|
||||||
|
|
||||||
|
const rewritten = state({
|
||||||
|
"core/instructions": [{ path: "/repo/AGENTS.md", content: "old one\nold two\nold three\nold four" }],
|
||||||
|
})
|
||||||
|
yield* discovery.transform((draft) => {
|
||||||
|
draft.remove("/global/AGENTS.md")
|
||||||
|
draft.update("/repo/AGENTS.md", (current) => {
|
||||||
|
current.content = "new"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect((yield* readUpdate(yield* discovery.load(), rewritten)).text).toBe(
|
||||||
|
"The instructions changed:\nInstructions from: /repo/AGENTS.md\nnew",
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* discovery.transform((draft) => {
|
||||||
|
draft.add(file("/repo/packages/AGENTS.md", "package"))
|
||||||
|
})
|
||||||
|
const structural = (yield* readUpdate(yield* discovery.load(), initial)).text
|
||||||
|
expect(structural).toContain("The instructions from /global/AGENTS.md no longer apply.")
|
||||||
|
expect(structural).toContain("New instructions apply from:\nInstructions from: /repo/packages/AGENTS.md\npackage")
|
||||||
|
expect(structural).not.toContain("Instructions from: /global/AGENTS.md\nglobal")
|
||||||
|
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([InstructionDiscovery.node, Bus.node])))),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ConfigInstructionPlugin.Plugin", () => {
|
describe("ConfigInstructionPlugin.Plugin", () => {
|
||||||
@@ -168,20 +212,15 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
|||||||
|
|
||||||
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
yield* Effect.promise(() => fs.writeFile(packageFile, "changed"))
|
||||||
yield* emitAndWait({ type: "update", path: packageFile })
|
yield* emitAndWait({ type: "update", path: packageFile })
|
||||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toContain(
|
const changed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||||
`Instructions from: ${packageFile}\nchanged`,
|
expect(changed).toContain(`The instructions changed:\nInstructions from: ${packageFile}\nchanged`)
|
||||||
)
|
expect(changed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.rm(packageFile))
|
yield* Effect.promise(() => fs.rm(packageFile))
|
||||||
yield* emitAndWait({ type: "delete", path: packageFile })
|
yield* emitAndWait({ type: "delete", path: packageFile })
|
||||||
expect((yield* readUpdate(yield* discovery.load(), initialized)).text).toBe(
|
const removed = (yield* readUpdate(yield* discovery.load(), initialized)).text
|
||||||
[
|
expect(removed).toContain(`The instructions from ${packageFile} no longer apply.`)
|
||||||
"These instructions replace all previously loaded ambient instructions.",
|
expect(removed).not.toContain(`Instructions from: ${globalFile}\nglobal`)
|
||||||
`Instructions from: ${globalFile}\nglobal`,
|
|
||||||
`Instructions from: ${projectFile}\nproject`,
|
|
||||||
`Instructions from: ${sharedFile}\nshared`,
|
|
||||||
].join("\n\n"),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.promise(() => fs.rm(globalFile))
|
yield* Effect.promise(() => fs.rm(globalFile))
|
||||||
yield* emitAndWait({ type: "delete", path: globalFile })
|
yield* emitAndWait({ type: "delete", path: globalFile })
|
||||||
|
|||||||
@@ -1204,12 +1204,17 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("interrupts a source Location runner after a Session moves", () =>
|
it.effect("preserves instruction state and interrupts the source Location runner after a Session moves", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
yield* runPrompt(session, "First")
|
yield* runPrompt(session, "First")
|
||||||
|
const instructionState = yield* db
|
||||||
|
.select()
|
||||||
|
.from(InstructionStateTable)
|
||||||
|
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||||
|
.get()
|
||||||
|
|
||||||
yield* bus.publish(SessionEvent.Moved, {
|
yield* bus.publish(SessionEvent.Moved, {
|
||||||
sessionID,
|
sessionID,
|
||||||
@@ -1218,7 +1223,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
})
|
})
|
||||||
expect(
|
expect(
|
||||||
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
|
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
|
||||||
).toBeUndefined()
|
).toEqual(instructionState)
|
||||||
|
|
||||||
yield* admit(session, "Second")
|
yield* admit(session, "Second")
|
||||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
||||||
|
|||||||
@@ -68,9 +68,316 @@ describe("WebFetchTool helpers", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("ports HTML text and markdown conversions without active content", () => {
|
test("ports HTML text and markdown conversions without active content", () => {
|
||||||
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
|
const html =
|
||||||
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
|
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
|
||||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
|
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
|
||||||
|
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves inline and preformatted code verbatim with safe fences", () => {
|
||||||
|
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n& stays decoded</code></pre>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps nested ordered and unordered lists structurally readable", () => {
|
||||||
|
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders blockquotes and tables as readable Markdown", () => {
|
||||||
|
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("decodes entities and normalizes prose whitespace without joining words", () => {
|
||||||
|
const html = `<p>alpha\n <span>& beta</span> <unknown>café</unknown> gamma 😀</p><p>delta</p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("omits active and fallback content while retaining surrounding prose", () => {
|
||||||
|
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("is deterministic and bounded for malformed maximum-size input", () => {
|
||||||
|
const html = `<main><p>${"visible & text ".repeat(250_000)}</main></p></unknown>`
|
||||||
|
const first = WebFetchTool.convertHTMLToMarkdown(html)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
|
||||||
|
expect(first.startsWith("visible & text visible & text")).toBe(true)
|
||||||
|
expect(first.length).toBeLessThanOrEqual(html.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("bounds deeply nested list output and fragmented code fences", () => {
|
||||||
|
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
|
||||||
|
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
|
||||||
|
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
|
||||||
|
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
|
||||||
|
expect(
|
||||||
|
WebFetchTool.convertHTMLToMarkdown(
|
||||||
|
"<div>".repeat(20_000) + "safe<script><b>bad</b>&</script><p>tail &</p>",
|
||||||
|
),
|
||||||
|
).toBe("safe tail &")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("escapes prose that would otherwise become Markdown structure", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
|
||||||
|
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves code whitespace and quotes every line of multiline blocks", () => {
|
||||||
|
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps nested blockquotes inside their outer quote", () => {
|
||||||
|
const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps visible whitespace around inline emphasis", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("captures formatting elements inside preformatted content as code only", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes multiline table cells without changing their columns", () => {
|
||||||
|
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flattens nested tables without corrupting the outer table", () => {
|
||||||
|
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves loose text around malformed table rows", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
|
||||||
|
`before after\n\n| cell |\n| --- |`,
|
||||||
|
)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("escapes tilde fences and removes empty emphasis markers", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
|
||||||
|
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses malformed tag prefixes in linear time without a regex prepass", () => {
|
||||||
|
const small = "<a".repeat(250_000)
|
||||||
|
const large = "<a".repeat(1_000_000)
|
||||||
|
const start = Bun.nanoseconds()
|
||||||
|
WebFetchTool.convertHTMLToMarkdown(small)
|
||||||
|
const smallDuration = Bun.nanoseconds() - start
|
||||||
|
const next = Bun.nanoseconds()
|
||||||
|
WebFetchTool.convertHTMLToMarkdown(large)
|
||||||
|
const largeDuration = Bun.nanoseconds() - next
|
||||||
|
expect(largeDuration).toBeLessThan(smallDuration * 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
|
||||||
|
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
|
||||||
|
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
|
||||||
|
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
|
||||||
|
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
|
||||||
|
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
expect(codeOutput.startsWith("~~~\n")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not confuse source NUL text with buffered code", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
|
||||||
|
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves multiline inline code verbatim", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
|
||||||
|
"` first\n\n\nsecond `",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefixes inline code at the start of a blockquote line", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps links nested in inline code associated with their text", () => {
|
||||||
|
const html = `<dl><dt><code>socket = new <a href="#constructor">WebSocket</a>(url)</code><dd>Creates one.</dl>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`,
|
||||||
|
)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
|
||||||
|
expect(
|
||||||
|
WebFetchTool.convertHTMLToMarkdown(
|
||||||
|
`<dl><dt><code><var>socket</var> = new <code><a href="#constructor">WebSocket</a></code>(<var>url</var>)</code><dd>Creates one.</dl>`,
|
||||||
|
),
|
||||||
|
).toBe(`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
|
||||||
|
`\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
|
||||||
|
)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(
|
||||||
|
`\`a\`[](\/x)\n\n\`bcd\``,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("indents nested list continuations and preserves ordered numbering", () => {
|
||||||
|
const html = `<ol start="0"><li value="4"><p>first</p><p>continued</p><ul><li><p>nested</p><p>continued nested</p></li></ul></li><li>next</li></ol>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders block content outside link syntax", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
|
||||||
|
`[before](/docs)\n\nblock\n\n[after](/docs)`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("recovers nested anchors without unmatched Markdown syntax", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps emphasis whitespace through neutral wrappers", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flattens preformatted content inside table cells", () => {
|
||||||
|
const html = `<table><tr><td><pre>a|b\nnext</pre></td><td><code>x|y</code></td></tr></table>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
|
||||||
|
const payload = "😀".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 4)
|
||||||
|
const cases = [
|
||||||
|
[`<strong>${payload}</strong>`, /^\*\*[\s\S]*\*\*$/],
|
||||||
|
[`<a href="/docs">${payload}</a>`, /^\[[\s\S]*\]\(\/docs\)$/],
|
||||||
|
[`<img src="image.png" alt="${payload}">`, /^!\[[\s\S]*\]\(image\.png\)$/],
|
||||||
|
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
|
||||||
|
] as const
|
||||||
|
for (const [html, pattern] of cases) {
|
||||||
|
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||||
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
expect(output).not.toContain("�")
|
||||||
|
expect(output).toMatch(pattern)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps near-boundary block constructs syntactically complete", () => {
|
||||||
|
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
const table = WebFetchTool.convertHTMLToMarkdown(
|
||||||
|
`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
|
||||||
|
)
|
||||||
|
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
|
||||||
|
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
|
||||||
|
for (const output of [table, list, code]) {
|
||||||
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
expect(output).not.toContain("�")
|
||||||
|
}
|
||||||
|
expect(table).toMatch(/^\| Name \|\n\| --- \|\n\| [\s\S]* \|$/)
|
||||||
|
expect(list).toMatch(/^- [\s\S]*$/)
|
||||||
|
expect(list.includes("nested")).toBe(false)
|
||||||
|
expect(code.match(/^(`{3,}|~{3,})$/gm)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps quoted code within budget with a safe closed fence", () => {
|
||||||
|
const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
|
||||||
|
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||||
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
const lines = output.split("\n")
|
||||||
|
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
|
||||||
|
expect(lines.at(-1)).toBe(lines[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("separates reconstructed tables from adjacent inline and quoted content", () => {
|
||||||
|
const html = `intro<table><tr><td>x</td></tr></table>outro<blockquote>quote<table><tr><td>cell</td></tr></table></blockquote><ul><li>item<table><tr><td>cell</td></tr></table></li></ul>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`intro\n\n| x |\n| --- |\n\noutro\n\n> quote\n\n> | cell |\n> | --- |\n\n- item\n\n| cell |\n| --- |`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps multiline quoted code closed at the content budget", () => {
|
||||||
|
const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
|
||||||
|
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||||
|
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||||
|
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
|
||||||
|
expect(output.includes("\uFFFD")).toBe(false)
|
||||||
|
expect(output.endsWith("tail")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps active content suppressed when depth fallback begins", () => {
|
||||||
|
const html = `<object>${"<div>".repeat(10_001)}LEAK${"</div>".repeat(10_001)}</object><p>visible</p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps visible text after depth fallback begins inside preformatted content", () => {
|
||||||
|
const html = `<pre>${"<i>".repeat(10_001)}visible${"</i>".repeat(10_001)}</pre><p>after</p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible after")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resumes links around every block structure", () => {
|
||||||
|
const html = `<a href="/x">before<blockquote><p>quote</p></blockquote><ul><li>item</li></ul><pre>code</pre><table><tr><td>cell</td></tr></table>after</a>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`[before](/x)\n\n> quote\n\n- item\n\n\`\`\`\ncode\n\`\`\`\n\n| cell |\n| --- |\n\n[after](/x)`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("indents child lists from the actual parent marker width", () => {
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
|
||||||
|
`100. outer\n\n - inner`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders captions and definition lists with readable boundaries", () => {
|
||||||
|
const html = `<table><caption>Cache modes</caption><tr><th>Name</th><th>Meaning</th></tr><tr><td>A</td><td>Local</td></tr></table><dl><dt>Cache</dt><dd>A local store</dd><dt>Origin</dt><dd>The remote source</dd></dl>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`Cache modes\n\n| Name | Meaning |\n| --- | --- |\n| A | Local |\n\n**Cache**\n: A local store\n\n**Origin**\n: The remote source`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to row-oriented text for table spans", () => {
|
||||||
|
const html = `<table><tr><th colspan="2">Group</th></tr><tr><td>A</td><td rowspan="2">Shared</td></tr><tr><td>B</td></tr></table>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("suppresses head and hidden subtrees while retaining visible body content", () => {
|
||||||
|
const html = `<head><title>noise</title></head><body><p>visible</p><div hidden>hidden</div><div aria-hidden="true">aria</div><div aria-hidden="false">shown</div></body>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves pre breaks and normalizes multiline link titles", () => {
|
||||||
|
const html = `<pre>first<br>second</pre><p><a href="/x" title="line one\n line two">link</a></p>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||||
|
`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("renders closed and open details according to visibility", () => {
|
||||||
|
const html = `<details><summary>Closed</summary><p>secret</p></details><details open><summary>Open</summary><p>visible</p></details>`
|
||||||
|
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,7 +485,7 @@ describe("WebFetchTool registration", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
|
it.effect("converts deeply nested HTML without overflowing", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
reset()
|
reset()
|
||||||
respond = () =>
|
respond = () =>
|
||||||
@@ -191,8 +498,8 @@ describe("WebFetchTool registration", () => {
|
|||||||
const url = "https://1.1.1.1/deep-html"
|
const url = "https://1.1.1.1/deep-html"
|
||||||
|
|
||||||
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
|
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
|
||||||
status: "error",
|
status: "completed",
|
||||||
error: { type: "unknown" },
|
content: [{ type: "text", text: "content" }],
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { expect, test } from "vitest"
|
|
||||||
import { mkdtemp, rm } from "node:fs/promises"
|
|
||||||
import { tmpdir } from "node:os"
|
|
||||||
import { join, resolve } from "node:path"
|
|
||||||
|
|
||||||
const state = {
|
|
||||||
focused: { renderable: 1, editor: true },
|
|
||||||
elements: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
test.sequential("CLI drives an externally owned OpenCode endpoint on the default port", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "opencode-drive-direct-test-"))
|
|
||||||
const requests: unknown[] = []
|
|
||||||
const server = Bun.serve({
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: 40900,
|
|
||||||
fetch(request, server) {
|
|
||||||
if (server.upgrade(request)) return
|
|
||||||
return new Response("external OpenCode simulation endpoint", {
|
|
||||||
status: 426,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
message(socket, message) {
|
|
||||||
const request = JSON.parse(String(message)) as {
|
|
||||||
readonly id: number
|
|
||||||
readonly method: string
|
|
||||||
}
|
|
||||||
if (request.method === "simulation.handshake") {
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: request.id,
|
|
||||||
error: { code: -32601, message: "method not found" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requests.push(request)
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: request.id,
|
|
||||||
result: request.method === "ui.screenshot" ? "/tmp/home.png" : state,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const first = await sendState(root)
|
|
||||||
expect(first.status).toBe(0)
|
|
||||||
expect(JSON.parse(first.stdout)).toEqual(state)
|
|
||||||
|
|
||||||
const second = await sendState(root)
|
|
||||||
expect(second.status).toBe(0)
|
|
||||||
expect(JSON.parse(second.stdout)).toEqual(state)
|
|
||||||
|
|
||||||
const screenshot = await send(root, ["--command.ui.screenshot", '{"name":"home"}'])
|
|
||||||
expect(screenshot.status).toBe(0)
|
|
||||||
expect(screenshot.stdout.trim()).toBe("/tmp/home.png")
|
|
||||||
|
|
||||||
const ctrlTab = await send(root, ["--command.ui.press", '{"key":"tab","modifiers":{"ctrl":true}}'])
|
|
||||||
expect(ctrlTab.status).toBe(0)
|
|
||||||
|
|
||||||
const right = await send(root, ["--command.ui.press", '{"key":"right"}'])
|
|
||||||
expect(right.status).toBe(0)
|
|
||||||
|
|
||||||
const altDown = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"meta":true}}'])
|
|
||||||
expect(altDown.status).toBe(0)
|
|
||||||
|
|
||||||
const invalidAlt = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"alt":true}}'])
|
|
||||||
expect(invalidAlt.status).toBe(1)
|
|
||||||
expect(invalidAlt.stderr).toContain("alt")
|
|
||||||
expect(invalidAlt.stderr).toContain("Unexpected key with value true")
|
|
||||||
|
|
||||||
expect(requests).toEqual([
|
|
||||||
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
|
|
||||||
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
|
|
||||||
{ jsonrpc: "2.0", id: 1, method: "ui.screenshot", params: { name: "home" } },
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "ui.press",
|
|
||||||
params: { key: "\u001b[9;5u" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "ui.press",
|
|
||||||
params: { key: "\u001b[C" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method: "ui.press",
|
|
||||||
params: { key: "\u001b[1;3B" },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
} finally {
|
|
||||||
await server.stop(true)
|
|
||||||
await rm(root, { recursive: true, force: true })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function sendState(root: string) {
|
|
||||||
return send(root, ["--command.ui.state"])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function send(root: string, args: string[]) {
|
|
||||||
const child = Bun.spawn([process.execPath, resolve("src/cli/index.ts"), "send", ...args], {
|
|
||||||
cwd: resolve("."),
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
DRIVE_REGISTRY_DIR: join(root, "registry"),
|
|
||||||
TMPDIR: root,
|
|
||||||
},
|
|
||||||
stdin: "ignore",
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "pipe",
|
|
||||||
})
|
|
||||||
const [status, stdout, stderr] = await Promise.all([
|
|
||||||
child.exited,
|
|
||||||
new Response(child.stdout).text(),
|
|
||||||
new Response(child.stderr).text(),
|
|
||||||
])
|
|
||||||
return { status, stdout, stderr }
|
|
||||||
}
|
|
||||||
@@ -115,9 +115,10 @@ export function elements(renderer: CliRenderer): Element[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function state(harness: Harness) {
|
export function state(harness: Harness) {
|
||||||
|
const renderable = harness.renderer.currentFocusedRenderable?.num
|
||||||
return {
|
return {
|
||||||
focused: {
|
focused: {
|
||||||
renderable: harness.renderer.currentFocusedRenderable?.num,
|
...(renderable === undefined ? {} : { renderable }),
|
||||||
editor: Boolean(harness.renderer.currentFocusedEditor),
|
editor: Boolean(harness.renderer.currentFocusedEditor),
|
||||||
},
|
},
|
||||||
elements: elements(harness.renderer),
|
elements: elements(harness.renderer),
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ test("matches literal screen text", () => {
|
|||||||
expect(matches(harness, "opencode")).toBe(false)
|
expect(matches(harness, "opencode")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("omits an absent focused renderable from state", () => {
|
||||||
|
const harness = {
|
||||||
|
renderer: {
|
||||||
|
root: { getChildren: () => [] },
|
||||||
|
currentFocusedRenderable: undefined,
|
||||||
|
currentFocusedEditor: undefined,
|
||||||
|
},
|
||||||
|
} as unknown as Harness
|
||||||
|
|
||||||
|
expect(state(harness)).toEqual({ focused: { editor: false }, elements: [] })
|
||||||
|
})
|
||||||
|
|
||||||
test("normalizes named keys for OpenTUI", async () => {
|
test("normalizes named keys for OpenTUI", async () => {
|
||||||
const pressed: Array<readonly [string, object | undefined]> = []
|
const pressed: Array<readonly [string, object | undefined]> = []
|
||||||
const harness = {
|
const harness = {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createMemo, createSignal } from "solid-js"
|
import { createMemo, createSignal } from "solid-js"
|
||||||
import { useConfig } from "../config"
|
import { useConfig } from "../config"
|
||||||
import { DialogSelect } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
|
import { useTheme } from "../context/theme"
|
||||||
import { useToast } from "../ui/toast"
|
import { useToast } from "../ui/toast"
|
||||||
|
|
||||||
type Experiment = {
|
type Experiment = {
|
||||||
id: "tab_drafts"
|
id: string
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
@@ -14,34 +15,33 @@ type Experiment = {
|
|||||||
// unconditional) or dies (delete the entry and the branch it gated).
|
// unconditional) or dies (delete the entry and the branch it gated).
|
||||||
export const experiments: Experiment[] = [
|
export const experiments: Experiment[] = [
|
||||||
{
|
{
|
||||||
id: "tab_drafts",
|
id: "tab_scroll",
|
||||||
title: "Per-tab prompt drafts",
|
title: "Remember tab scroll",
|
||||||
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
|
description: "Keep each open tab's reading position and show a shortcut back to the bottom.",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DialogExperiments() {
|
export function DialogExperiments() {
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
|
const theme = useTheme()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [selected, setSelected] = createSignal(0)
|
const [selected, setSelected] = createSignal<Experiment>()
|
||||||
const [saving, setSaving] = createSignal(false)
|
const [saving, setSaving] = createSignal(false)
|
||||||
|
|
||||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||||
|
|
||||||
const options = createMemo(() =>
|
const options = createMemo(() =>
|
||||||
experiments.map((experiment, index) => ({
|
experiments.map((experiment) => ({
|
||||||
title: experiment.title,
|
title: experiment.title,
|
||||||
category: "Experiments",
|
|
||||||
searchText: experiment.description,
|
searchText: experiment.description,
|
||||||
footer: enabled(experiment) ? "on" : "off",
|
footer: enabled(experiment) ? "on" : "off",
|
||||||
value: index,
|
value: experiment,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
// All experiments are booleans, so either direction toggles.
|
// All experiments are booleans, so either direction toggles.
|
||||||
async function change(index = selected()) {
|
async function change(experiment = selected()) {
|
||||||
if (saving()) return
|
if (saving()) return
|
||||||
const experiment = experiments[index]
|
|
||||||
if (!experiment) return
|
if (!experiment) return
|
||||||
const next = !enabled(experiment)
|
const next = !enabled(experiment)
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -58,23 +58,33 @@ export function DialogExperiments() {
|
|||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Experiments"
|
title="Experiments"
|
||||||
options={options()}
|
options={options()}
|
||||||
|
renderFilter={experiments.length > 0}
|
||||||
onMove={(option) => setSelected(option.value)}
|
onMove={(option) => setSelected(option.value)}
|
||||||
onSelect={(option) => void change(option.value)}
|
onSelect={(option) => void change(option.value)}
|
||||||
footerHints={[{ title: "←/→", label: "change" }]}
|
emptyView={
|
||||||
bindings={[
|
<box paddingLeft={4} paddingRight={4}>
|
||||||
{
|
<text fg={theme.text.subdued}>No experiments available</text>
|
||||||
bind: "left",
|
</box>
|
||||||
title: "Previous value",
|
}
|
||||||
group: "Experiments",
|
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
|
||||||
run: () => void change(),
|
bindings={
|
||||||
},
|
experiments.length > 0
|
||||||
{
|
? [
|
||||||
bind: "right",
|
{
|
||||||
title: "Next value",
|
bind: "left",
|
||||||
group: "Experiments",
|
title: "Previous value",
|
||||||
run: () => void change(),
|
group: "Experiments",
|
||||||
},
|
run: () => void change(),
|
||||||
]}
|
},
|
||||||
|
{
|
||||||
|
bind: "right",
|
||||||
|
title: "Next value",
|
||||||
|
group: "Experiments",
|
||||||
|
run: () => void change(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type DialogMoveSessionProps = {
|
|||||||
onSelect: (selection: MoveSessionSelection) => void
|
onSelect: (selection: MoveSessionSelection) => void
|
||||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||||
initialDirectories?: ReadonlyArray<ProjectDirectory>
|
initialDirectories?: ReadonlyArray<ProjectDirectory>
|
||||||
|
fixture?: boolean
|
||||||
initialRemoving?: string
|
initialRemoving?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [directories, { refetch }] = createResource(
|
const [directories, { refetch }] = createResource(
|
||||||
() => (props.initialRemoving ? undefined : props.projectID),
|
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
|
||||||
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
|
||||||
try {
|
try {
|
||||||
const requestLocation = { directory: location()?.directory || paths.cwd }
|
const requestLocation = { directory: location()?.directory || paths.cwd }
|
||||||
@@ -110,11 +111,9 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
if (showError()) return
|
if (showError()) return
|
||||||
const directory = currentDirectory()
|
const directory = currentDirectory()
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
return (
|
return directoryData()
|
||||||
directoryData()
|
?.filter((root) => contains(root.directory, directory))
|
||||||
?.filter((root) => contains(root.directory, directory))
|
.toSorted((a, b) => b.directory.length - a.directory.length)[0]
|
||||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||||
@@ -123,7 +122,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
const current = currentRoot()?.directory
|
const current = currentRoot()?.directory
|
||||||
if (directories.loading && !data && !current) return []
|
if (directories.loading && !data && !current) return []
|
||||||
const roots = [...(data ?? [])]
|
const roots = [...(data ?? [])]
|
||||||
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
|
|
||||||
roots.sort((a, b) => {
|
roots.sort((a, b) => {
|
||||||
if (a.directory === current) return -1
|
if (a.directory === current) return -1
|
||||||
if (b.directory === current) return 1
|
if (b.directory === current) return 1
|
||||||
@@ -139,15 +137,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
|
||||||
)
|
)
|
||||||
.map((session) => session.location.directory)
|
.map((session) => session.location.directory)
|
||||||
|
.filter((directory) => currentRoot() || directory !== currentDirectory())
|
||||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||||
.map((location) => ({
|
.map((location) => ({
|
||||||
location,
|
location,
|
||||||
root: roots
|
root: roots
|
||||||
.filter((root) => {
|
.filter((root) => contains(root.directory, location))
|
||||||
const relative = path.relative(root.directory, location)
|
|
||||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
|
||||||
})
|
|
||||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
|
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
|
||||||
}))
|
}))
|
||||||
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
|
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
|
||||||
@@ -325,6 +321,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
renderFilter={!showError()}
|
renderFilter={!showError()}
|
||||||
|
flat={true}
|
||||||
options={options()}
|
options={options()}
|
||||||
emptyView={
|
emptyView={
|
||||||
showError() ? (
|
showError() ? (
|
||||||
@@ -357,7 +354,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||||||
}}
|
}}
|
||||||
onMove={() => setToDelete(undefined)}
|
onMove={() => setToDelete(undefined)}
|
||||||
actions={
|
actions={
|
||||||
showError()
|
showError() || props.fixture
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
import type { PromptInfo } from "../../prompt/history"
|
import type { PromptInfo } from "../../prompt/history"
|
||||||
|
|
||||||
// Holds one in-progress draft per slot across Prompt remounts. The undefined
|
// Holds one in-progress draft per tab across Prompt remounts. A draft is
|
||||||
// key is the default single global slot that follows focus across tabs; the
|
// consumed on take: restoring it moves it out of the stash, so a stale copy
|
||||||
// tab_drafts experiment keys drafts by the tab (sessionID or "home") they
|
// never shadows newer input.
|
||||||
// were written in. A draft is consumed on take: restoring it moves it out of
|
|
||||||
// the stash, so a stale copy never shadows newer input.
|
|
||||||
export type DraftEntry = { prompt: PromptInfo; cursor: number }
|
export type DraftEntry = { prompt: PromptInfo; cursor: number }
|
||||||
|
|
||||||
let global: DraftEntry | undefined
|
const byTab = new Map<string | undefined, DraftEntry>()
|
||||||
const byTab = new Map<string, DraftEntry>()
|
|
||||||
|
|
||||||
export function takeDraft(key: string | undefined) {
|
export function takeDraft(sessionID: string | undefined) {
|
||||||
if (key === undefined) {
|
const entry = byTab.get(sessionID)
|
||||||
const entry = global
|
byTab.delete(sessionID)
|
||||||
global = undefined
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
const entry = byTab.get(key)
|
|
||||||
byTab.delete(key)
|
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveDraft(key: string | undefined, entry: DraftEntry) {
|
export function saveDraft(sessionID: string | undefined, entry: DraftEntry) {
|
||||||
if (key === undefined) {
|
byTab.set(sessionID, entry)
|
||||||
global = entry
|
|
||||||
return
|
|
||||||
}
|
|
||||||
byTab.set(key, entry)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -678,10 +678,9 @@ export function Prompt(props: PromptProps) {
|
|||||||
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
// instance belongs to exactly one tab. Reading props.sessionID lazily would
|
||||||
// observe the *next* route during onCleanup and stash under the wrong tab.
|
// observe the *next* route during onCleanup and stash under the wrong tab.
|
||||||
const stashSessionID = props.sessionID
|
const stashSessionID = props.sessionID
|
||||||
const stashKey = () => (config.experimental?.tab_drafts === true ? (stashSessionID ?? "home") : undefined)
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const saved = takeDraft(stashKey())
|
const saved = takeDraft(stashSessionID)
|
||||||
if (store.prompt.text) return
|
if (store.prompt.text) return
|
||||||
if (saved && saved.prompt.text) {
|
if (saved && saved.prompt.text) {
|
||||||
input.setText(saved.prompt.text)
|
input.setText(saved.prompt.text)
|
||||||
@@ -694,7 +693,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
disposed = true
|
disposed = true
|
||||||
if (store.prompt.text) {
|
if (store.prompt.text) {
|
||||||
saveDraft(stashKey(), { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
saveDraft(stashSessionID, { prompt: unwrap(store.prompt), cursor: input.cursorOffset })
|
||||||
}
|
}
|
||||||
setInputTarget(undefined)
|
setInputTarget(undefined)
|
||||||
props.ref?.(undefined)
|
props.ref?.(undefined)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
moveSessionTab,
|
moveSessionTab,
|
||||||
NEW_SESSION_TAB_TITLE,
|
NEW_SESSION_TAB_TITLE,
|
||||||
sessionTabComplete,
|
sessionTabComplete,
|
||||||
|
sessionTabDetail,
|
||||||
sessionTabShortcutLabel,
|
sessionTabShortcutLabel,
|
||||||
seedSessionTabMotion,
|
seedSessionTabMotion,
|
||||||
sessionTabOverflowWidth,
|
sessionTabOverflowWidth,
|
||||||
@@ -408,10 +409,20 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
|
const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH)
|
||||||
const detail = createMemo(() => {
|
const detail = createMemo(() => {
|
||||||
const fixture = tabs.detail?.(tab.sessionID)
|
const fixture = tabs.detail?.(tab.sessionID)
|
||||||
if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth())
|
if (fixture !== undefined) return fixture
|
||||||
const value = session()
|
const value = session()
|
||||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
const currentProject = project()
|
||||||
|
const projectLabel = projectName(currentProject, value?.location.directory) ?? ""
|
||||||
|
const vcs = value ? data.location.vcs.info(value.location) : undefined
|
||||||
|
const location = value ? data.location.info(value.location) : undefined
|
||||||
|
const worktree = !!location && location.project.directory !== location.project.canonical
|
||||||
|
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree)
|
||||||
})
|
})
|
||||||
|
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
|
||||||
|
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
|
||||||
|
const detailFades = createMemo(
|
||||||
|
() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH,
|
||||||
|
)
|
||||||
const background = createMemo(() => {
|
const background = createMemo(() => {
|
||||||
if (selected()) return theme.background.action.primary.selected
|
if (selected()) return theme.background.action.primary.selected
|
||||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||||
@@ -453,6 +464,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
|
||||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||||
|
const detailTextColor = (index: number) =>
|
||||||
|
detailFades()
|
||||||
|
? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0)
|
||||||
|
: detailColor()
|
||||||
const glows = () => status().glows
|
const glows = () => status().glows
|
||||||
const previous = createMemo(() => items()[index() - 1])
|
const previous = createMemo(() => items()[index() - 1])
|
||||||
const previousStatus = createMemo(() => {
|
const previousStatus = createMemo(() => {
|
||||||
@@ -547,8 +562,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
outerComplete={previousStatus().complete && !previousStatus().attention}
|
outerComplete={previousStatus().complete && !previousStatus().attention}
|
||||||
glow={glows()}
|
glow={glows()}
|
||||||
outerGlow={previousGlows()}
|
outerGlow={previousGlows()}
|
||||||
breathe={status().attention}
|
|
||||||
outerBreathe={previousStatus().attention}
|
|
||||||
color={separatorLowerPulseColor()}
|
color={separatorLowerPulseColor()}
|
||||||
width={indicatorWidth}
|
width={indicatorWidth}
|
||||||
outerColor={separatorUpperPulseColor()}
|
outerColor={separatorUpperPulseColor()}
|
||||||
@@ -576,8 +589,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
outerComplete={false}
|
outerComplete={false}
|
||||||
glow={glows()}
|
glow={glows()}
|
||||||
outerGlow={false}
|
outerGlow={false}
|
||||||
breathe={status().attention}
|
|
||||||
outerBreathe={false}
|
|
||||||
color={tint(theme.background.default, theme.text.default, 0.04)}
|
color={tint(theme.background.default, theme.text.default, 0.04)}
|
||||||
width={indicatorWidth}
|
width={indicatorWidth}
|
||||||
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
|
||||||
@@ -599,7 +610,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
promptPulse={status().promptPulse}
|
promptPulse={status().promptPulse}
|
||||||
complete={complete() && !status().attention}
|
complete={complete() && !status().attention}
|
||||||
glow={glows()}
|
glow={glows()}
|
||||||
breathe={status().attention}
|
|
||||||
color={pulseColor()}
|
color={pulseColor()}
|
||||||
width={indicatorWidth}
|
width={indicatorWidth}
|
||||||
glowColor={glowColor()}
|
glowColor={glowColor()}
|
||||||
@@ -658,7 +668,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
promptPulse={status().promptPulse}
|
promptPulse={status().promptPulse}
|
||||||
complete={complete() && !status().attention}
|
complete={complete() && !status().attention}
|
||||||
glow={glows()}
|
glow={glows()}
|
||||||
breathe={status().attention}
|
|
||||||
color={detailPulseColor()}
|
color={detailPulseColor()}
|
||||||
width={indicatorWidth}
|
width={indicatorWidth}
|
||||||
glowColor={detailGlowColor()}
|
glowColor={detailGlowColor()}
|
||||||
@@ -670,7 +679,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|||||||
/>
|
/>
|
||||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||||
{detail()}
|
<Show when={detailFades()} fallback={visibleDetail()}>
|
||||||
|
<For each={visibleDetailParts()}>
|
||||||
|
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
@@ -1054,7 +1067,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|||||||
promptPulse={status().promptPulse}
|
promptPulse={status().promptPulse}
|
||||||
complete={status().complete && !status().attention}
|
complete={status().complete && !status().attention}
|
||||||
glow={glows()}
|
glow={glows()}
|
||||||
breathe={status().attention}
|
|
||||||
color={pulseColor()}
|
color={pulseColor()}
|
||||||
glowColor={glowColor()}
|
glowColor={glowColor()}
|
||||||
flashColor={flashColor()}
|
flashColor={flashColor()}
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
|||||||
outerComplete?: boolean
|
outerComplete?: boolean
|
||||||
glow?: boolean
|
glow?: boolean
|
||||||
outerGlow?: boolean
|
outerGlow?: boolean
|
||||||
breathe?: boolean
|
|
||||||
outerBreathe?: boolean
|
|
||||||
color?: RGBA
|
color?: RGBA
|
||||||
outerColor?: RGBA
|
outerColor?: RGBA
|
||||||
glowColor?: RGBA
|
glowColor?: RGBA
|
||||||
@@ -49,8 +47,6 @@ const GLOW_IGNITION_DURATION = 600
|
|||||||
const GLOW_IGNITION_PEAK = 1.5
|
const GLOW_IGNITION_PEAK = 1.5
|
||||||
const GLOW_IGNITION_ATTACK = 0.3
|
const GLOW_IGNITION_ATTACK = 0.3
|
||||||
const GLOW_FADE_OUT = 200
|
const GLOW_FADE_OUT = 200
|
||||||
const GLOW_BREATHE_PERIOD = 3_600
|
|
||||||
const GLOW_BREATHE_RISE = 0.25
|
|
||||||
const GLOW_TAIL = 12
|
const GLOW_TAIL = 12
|
||||||
const GLOW_OPACITY = 0.16
|
const GLOW_OPACITY = 0.16
|
||||||
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
|
||||||
@@ -91,18 +87,30 @@ export function blendTabPulseColor(
|
|||||||
flash: number,
|
flash: number,
|
||||||
completion: number,
|
completion: number,
|
||||||
) {
|
) {
|
||||||
output.r = background.r + (glowColor.r - background.r) * glow
|
if (glow === 0) {
|
||||||
output.g = background.g + (glowColor.g - background.g) * glow
|
output.r = background.r
|
||||||
output.b = background.b + (glowColor.b - background.b) * glow
|
output.g = background.g
|
||||||
output.r += (runningColor.r - output.r) * running
|
output.b = background.b
|
||||||
output.g += (runningColor.g - output.g) * running
|
} else {
|
||||||
output.b += (runningColor.b - output.b) * running
|
output.r = background.r + (glowColor.r - background.r) * glow
|
||||||
output.r += (flashColor.r - output.r) * flash
|
output.g = background.g + (glowColor.g - background.g) * glow
|
||||||
output.g += (flashColor.g - output.g) * flash
|
output.b = background.b + (glowColor.b - background.b) * glow
|
||||||
output.b += (flashColor.b - output.b) * flash
|
}
|
||||||
output.r += (completionColor.r - output.r) * completion
|
if (running !== 0) {
|
||||||
output.g += (completionColor.g - output.g) * completion
|
output.r += (runningColor.r - output.r) * running
|
||||||
output.b += (completionColor.b - output.b) * completion
|
output.g += (runningColor.g - output.g) * running
|
||||||
|
output.b += (runningColor.b - output.b) * running
|
||||||
|
}
|
||||||
|
if (flash !== 0) {
|
||||||
|
output.r += (flashColor.r - output.r) * flash
|
||||||
|
output.g += (flashColor.g - output.g) * flash
|
||||||
|
output.b += (flashColor.b - output.b) * flash
|
||||||
|
}
|
||||||
|
if (completion !== 0) {
|
||||||
|
output.r += (completionColor.r - output.r) * completion
|
||||||
|
output.g += (completionColor.g - output.g) * completion
|
||||||
|
output.b += (completionColor.b - output.b) * completion
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
|
/** A one-shot animation clock: level() follows shape over duration, scaled by the value passed to start. */
|
||||||
@@ -154,7 +162,6 @@ type PulseStateOptions = {
|
|||||||
promptPulse: number
|
promptPulse: number
|
||||||
complete: boolean
|
complete: boolean
|
||||||
glow: boolean
|
glow: boolean
|
||||||
breathe: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PulseState {
|
class PulseState {
|
||||||
@@ -163,9 +170,7 @@ class PulseState {
|
|||||||
private promptPulse: number
|
private promptPulse: number
|
||||||
private complete: boolean
|
private complete: boolean
|
||||||
private glow: boolean
|
private glow: boolean
|
||||||
private breathe: boolean
|
|
||||||
private clock = 0
|
private clock = 0
|
||||||
private breatheClock = 0
|
|
||||||
private completionPending = false
|
private completionPending = false
|
||||||
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
||||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||||
@@ -181,16 +186,11 @@ class PulseState {
|
|||||||
this.promptPulse = options.promptPulse
|
this.promptPulse = options.promptPulse
|
||||||
this.complete = options.complete
|
this.complete = options.complete
|
||||||
this.glow = options.glow
|
this.glow = options.glow
|
||||||
this.breathe = options.breathe
|
|
||||||
if (this.enabled && this.active) this.runAttack.start()
|
if (this.enabled && this.active) this.runAttack.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private get breathing() {
|
|
||||||
return this.enabled && this.glow && this.breathe
|
|
||||||
}
|
|
||||||
|
|
||||||
get live() {
|
get live() {
|
||||||
return this.active || this.breathing || this.envelopes.some(envelopeActive)
|
return this.enabled && (this.active || this.envelopes.some(envelopeActive))
|
||||||
}
|
}
|
||||||
|
|
||||||
get running() {
|
get running() {
|
||||||
@@ -208,11 +208,7 @@ class PulseState {
|
|||||||
|
|
||||||
get glowLevel() {
|
get glowLevel() {
|
||||||
if (!this.glow) return this.glowOff.level()
|
if (!this.glow) return this.glowOff.level()
|
||||||
const base = this.ignition.active ? this.ignition.level() : 1
|
return this.ignition.active ? this.ignition.level() : 1
|
||||||
if (!this.breathing) return base
|
|
||||||
return (
|
|
||||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(value: boolean) {
|
setEnabled(value: boolean) {
|
||||||
@@ -221,7 +217,6 @@ class PulseState {
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
for (const envelope of this.envelopes) envelope.stop()
|
for (const envelope of this.envelopes) envelope.stop()
|
||||||
this.completionPending = false
|
this.completionPending = false
|
||||||
this.breatheClock = 0
|
|
||||||
} else if (this.active) {
|
} else if (this.active) {
|
||||||
this.runAttack.restart()
|
this.runAttack.restart()
|
||||||
}
|
}
|
||||||
@@ -274,7 +269,6 @@ class PulseState {
|
|||||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||||
this.glow = value
|
this.glow = value
|
||||||
this.ignition.stop()
|
this.ignition.stop()
|
||||||
this.breatheClock = 0
|
|
||||||
if (this.enabled && value) {
|
if (this.enabled && value) {
|
||||||
this.glowOff.stop()
|
this.glowOff.stop()
|
||||||
this.ignition.start()
|
this.ignition.start()
|
||||||
@@ -282,17 +276,9 @@ class PulseState {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
setBreathe(value: boolean) {
|
|
||||||
if (value === this.breathe) return false
|
|
||||||
this.breathe = value
|
|
||||||
this.breatheClock = 0
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
advance(deltaTime: number) {
|
advance(deltaTime: number) {
|
||||||
if (!this.enabled) return
|
if (!this.live) return
|
||||||
if (this.active || this.runFade.active) this.clock += deltaTime
|
if (this.active || this.runFade.active) this.clock += deltaTime
|
||||||
if (this.breathing) this.breatheClock += deltaTime
|
|
||||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||||
if (!this.completionPending) return
|
if (!this.completionPending) return
|
||||||
if (this.complete) {
|
if (this.complete) {
|
||||||
@@ -340,17 +326,13 @@ class TabPulseRenderable extends Renderable {
|
|||||||
const enabled = options.enabled ?? true
|
const enabled = options.enabled ?? true
|
||||||
const active = options.active ?? false
|
const active = options.active ?? false
|
||||||
const glow = options.glow ?? false
|
const glow = options.glow ?? false
|
||||||
const breathe = options.breathe ?? false
|
|
||||||
const edge = options.edge
|
const edge = options.edge
|
||||||
const outerActive = options.outerActive ?? active
|
const outerActive = options.outerActive ?? active
|
||||||
const outerGlow = options.outerGlow ?? glow
|
const outerGlow = options.outerGlow ?? glow
|
||||||
const outerBreathe = options.outerBreathe ?? breathe
|
|
||||||
super(ctx, {
|
super(ctx, {
|
||||||
...options,
|
...options,
|
||||||
height: 1,
|
height: 1,
|
||||||
live:
|
live: enabled && (active || (edge !== undefined && outerActive)),
|
||||||
enabled &&
|
|
||||||
(active || (glow && breathe) || (edge !== undefined && (outerActive || (outerGlow && outerBreathe)))),
|
|
||||||
})
|
})
|
||||||
this._enabled = enabled
|
this._enabled = enabled
|
||||||
this.inner = new PulseState({
|
this.inner = new PulseState({
|
||||||
@@ -359,7 +341,6 @@ class TabPulseRenderable extends Renderable {
|
|||||||
promptPulse: options.promptPulse ?? 0,
|
promptPulse: options.promptPulse ?? 0,
|
||||||
complete: options.complete ?? false,
|
complete: options.complete ?? false,
|
||||||
glow,
|
glow,
|
||||||
breathe,
|
|
||||||
})
|
})
|
||||||
this.outer = new PulseState({
|
this.outer = new PulseState({
|
||||||
enabled: enabled && edge !== undefined,
|
enabled: enabled && edge !== undefined,
|
||||||
@@ -367,7 +348,6 @@ class TabPulseRenderable extends Renderable {
|
|||||||
promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
|
promptPulse: options.outerPromptPulse ?? options.promptPulse ?? 0,
|
||||||
complete: options.outerComplete ?? options.complete ?? false,
|
complete: options.outerComplete ?? options.complete ?? false,
|
||||||
glow: outerGlow,
|
glow: outerGlow,
|
||||||
breathe: outerBreathe,
|
|
||||||
})
|
})
|
||||||
this._color = options.color ?? RGBA.defaultForeground()
|
this._color = options.color ?? RGBA.defaultForeground()
|
||||||
this._outerColor = options.outerColor ?? this._color
|
this._outerColor = options.outerColor ?? this._color
|
||||||
@@ -439,14 +419,6 @@ class TabPulseRenderable extends Renderable {
|
|||||||
if (this.outer.setGlow(value)) this.changed()
|
if (this.outer.setGlow(value)) this.changed()
|
||||||
}
|
}
|
||||||
|
|
||||||
set breathe(value: boolean) {
|
|
||||||
if (this.inner.setBreathe(value)) this.changed()
|
|
||||||
}
|
|
||||||
|
|
||||||
set outerBreathe(value: boolean) {
|
|
||||||
if (this.outer.setBreathe(value)) this.changed()
|
|
||||||
}
|
|
||||||
|
|
||||||
private changed() {
|
private changed() {
|
||||||
this.live = this.inner.live || this.outer.live
|
this.live = this.inner.live || this.outer.live
|
||||||
this.requestRender()
|
this.requestRender()
|
||||||
@@ -539,7 +511,7 @@ class TabPulseRenderable extends Renderable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override onUpdate(deltaTime: number): void {
|
protected override onUpdate(deltaTime: number): void {
|
||||||
if (!this._enabled) return
|
if (!this.live) return
|
||||||
this.inner.advance(deltaTime)
|
this.inner.advance(deltaTime)
|
||||||
this.outer.advance(deltaTime)
|
this.outer.advance(deltaTime)
|
||||||
this.live = this.inner.live || this.outer.live
|
this.live = this.inner.live || this.outer.live
|
||||||
@@ -568,13 +540,13 @@ class TabPulseRenderable extends Renderable {
|
|||||||
this.emitLevel(0)
|
this.emitLevel(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [front, secondFront] = this.inner.fronts(this.width)
|
const fronts = running === 0 ? undefined : this.inner.fronts(this.width)
|
||||||
const [outerFront, outerSecondFront] = this.outer.fronts(this.width)
|
const outerFronts = outerRunning === 0 ? undefined : this.outer.fronts(this.width)
|
||||||
if (this._onLevel)
|
if (this._onLevel)
|
||||||
this.emitLevel(
|
this.emitLevel(
|
||||||
running === 0
|
running === 0
|
||||||
? 0
|
? 0
|
||||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
: Math.max(intensityAt(1, fronts![0], RUN_HEAD, RUN_TAIL), intensityAt(1, fronts![1], RUN_HEAD, RUN_TAIL)) *
|
||||||
running,
|
running,
|
||||||
)
|
)
|
||||||
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
const glowTail = Math.min(this._glowTail, Math.max(1, this.width - 2))
|
||||||
@@ -588,8 +560,8 @@ class TabPulseRenderable extends Renderable {
|
|||||||
running === 0
|
running === 0
|
||||||
? 0
|
? 0
|
||||||
: Math.max(
|
: Math.max(
|
||||||
intensityAt(index, front, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, fronts![0], RUN_HEAD, RUN_TAIL),
|
||||||
intensityAt(index, secondFront, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, fronts![1], RUN_HEAD, RUN_TAIL),
|
||||||
) *
|
) *
|
||||||
0.14 *
|
0.14 *
|
||||||
running
|
running
|
||||||
@@ -597,8 +569,8 @@ class TabPulseRenderable extends Renderable {
|
|||||||
outerRunning === 0
|
outerRunning === 0
|
||||||
? 0
|
? 0
|
||||||
: Math.max(
|
: Math.max(
|
||||||
intensityAt(index, outerFront, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, outerFronts![0], RUN_HEAD, RUN_TAIL),
|
||||||
intensityAt(index, outerSecondFront, RUN_HEAD, RUN_TAIL),
|
intensityAt(index, outerFronts![1], RUN_HEAD, RUN_TAIL),
|
||||||
) *
|
) *
|
||||||
0.14 *
|
0.14 *
|
||||||
outerRunning
|
outerRunning
|
||||||
@@ -662,8 +634,6 @@ export function TabPulse(props: {
|
|||||||
outerComplete?: boolean
|
outerComplete?: boolean
|
||||||
glow?: boolean
|
glow?: boolean
|
||||||
outerGlow?: boolean
|
outerGlow?: boolean
|
||||||
breathe?: boolean
|
|
||||||
outerBreathe?: boolean
|
|
||||||
color: RGBA
|
color: RGBA
|
||||||
outerColor?: RGBA
|
outerColor?: RGBA
|
||||||
glowColor?: RGBA
|
glowColor?: RGBA
|
||||||
@@ -695,8 +665,6 @@ export function TabPulse(props: {
|
|||||||
outerComplete={props.outerComplete ?? props.complete ?? false}
|
outerComplete={props.outerComplete ?? props.complete ?? false}
|
||||||
glow={props.glow ?? false}
|
glow={props.glow ?? false}
|
||||||
outerGlow={props.outerGlow ?? props.glow ?? false}
|
outerGlow={props.outerGlow ?? props.glow ?? false}
|
||||||
breathe={props.breathe ?? false}
|
|
||||||
outerBreathe={props.outerBreathe ?? props.breathe ?? false}
|
|
||||||
color={props.color}
|
color={props.color}
|
||||||
outerColor={props.outerColor ?? props.color}
|
outerColor={props.outerColor ?? props.color}
|
||||||
glowColor={props.glowColor ?? props.color}
|
glowColor={props.glowColor ?? props.color}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * as Config from "."
|
export * as Config from "."
|
||||||
|
export * as ConfigPlugin from "./plugin"
|
||||||
|
|
||||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
@@ -192,13 +193,9 @@ export const Info = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Debugging settings" }),
|
).annotate({ description: "Debugging settings" }),
|
||||||
experimental: Schema.optional(
|
experimental: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||||
Schema.Struct({
|
description: "Experimental features that may change or be removed at any time",
|
||||||
tab_drafts: Schema.optional(Schema.Boolean).annotate({
|
}),
|
||||||
description: "Keep unsent prompt drafts on the tab where they were written",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
).annotate({ description: "Experimental features that may change or be removed at any time" }),
|
|
||||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }),
|
||||||
cursor: Schema.optional(Cursor),
|
cursor: Schema.optional(Cursor),
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function setEnabled(draft: { plugins?: unknown[] }, id: string, enabled: boolean) {
|
||||||
|
const plugins = Array.isArray(draft.plugins) ? draft.plugins : []
|
||||||
|
draft.plugins = plugins.filter((entry) => entry !== id && entry !== `-${id}`)
|
||||||
|
draft.plugins.push(enabled ? id : `-${id}`)
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ type Store = {
|
|||||||
location: Record<string, LocationData>
|
location: Record<string, LocationData>
|
||||||
}
|
}
|
||||||
|
|
||||||
function locationKey(location: LocationRef) {
|
export function locationKey(location: LocationRef) {
|
||||||
return JSON.stringify([location.directory, location.workspaceID])
|
return JSON.stringify([location.directory, location.workspaceID])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
default() {
|
default() {
|
||||||
return defaultLocation()
|
return defaultLocation()
|
||||||
},
|
},
|
||||||
async sync(ref?: LocationRef) {
|
syncInfo(ref?: LocationRef) {
|
||||||
const current = ref ?? defaultLocation()
|
const current = ref ?? defaultLocation()
|
||||||
await sync.run(`location:${locationKey(current)}`, async () => {
|
return sync.run(`location:${locationKey(current)}`, async () => {
|
||||||
const location = await client.api.location.get({ location: locationQuery(current) })
|
const location = await client.api.location.get({ location: locationQuery(current) })
|
||||||
const key = locationKey(location)
|
const key = locationKey(location)
|
||||||
if (!store.location[key]) setStore("location", key, {})
|
if (!store.location[key]) setStore("location", key, {})
|
||||||
@@ -1225,6 +1225,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
async sync(ref?: LocationRef) {
|
||||||
|
await result.location.syncInfo(ref)
|
||||||
const location = ref ?? defaultLocation()
|
const location = ref ?? defaultLocation()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
result.location.vcs.sync(location),
|
result.location.vcs.sync(location),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const context = createContext<{
|
|||||||
readonly current: LocationGetOutput | undefined
|
readonly current: LocationGetOutput | undefined
|
||||||
// The target location as set, available before the server-synced info in `current` arrives.
|
// The target location as set, available before the server-synced info in `current` arrives.
|
||||||
readonly ref: LocationRef | undefined
|
readonly ref: LocationRef | undefined
|
||||||
|
readonly error: { readonly location: LocationRef; readonly cause: unknown } | undefined
|
||||||
set: (location?: LocationRef) => void
|
set: (location?: LocationRef) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -14,16 +15,29 @@ export function LocationProvider(props: ParentProps) {
|
|||||||
const client = useClient()
|
const client = useClient()
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const [ref, setRef] = createSignal<LocationRef>()
|
const [ref, setRef] = createSignal<LocationRef>()
|
||||||
|
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
|
||||||
|
let generation = 0
|
||||||
const current = createMemo(() => data.location.info(ref()))
|
const current = createMemo(() => data.location.info(ref()))
|
||||||
|
|
||||||
function sync(location?: LocationRef) {
|
function sync(location?: LocationRef) {
|
||||||
if (!location) return
|
if (!location) return
|
||||||
|
const attempt = ++generation
|
||||||
const defaultLocation = data.location.default()
|
const defaultLocation = data.location.default()
|
||||||
const target =
|
const target =
|
||||||
location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID
|
location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID
|
||||||
? undefined
|
? undefined
|
||||||
: location
|
: location
|
||||||
void data.location.sync(target).catch(() => undefined)
|
setError(undefined)
|
||||||
|
void data.location.sync(target).catch((cause) => {
|
||||||
|
const current = ref()
|
||||||
|
if (
|
||||||
|
generation !== attempt ||
|
||||||
|
current?.directory !== location.directory ||
|
||||||
|
current.workspaceID !== location.workspaceID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
setError({ location, cause })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function set(location?: LocationRef) {
|
function set(location?: LocationRef) {
|
||||||
@@ -42,6 +56,9 @@ export function LocationProvider(props: ParentProps) {
|
|||||||
get ref() {
|
get ref() {
|
||||||
return ref()
|
return ref()
|
||||||
},
|
},
|
||||||
|
get error() {
|
||||||
|
return error()
|
||||||
|
},
|
||||||
set,
|
set,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export function sessionTabShortcutLabel(index: number) {
|
|||||||
return "·"
|
return "·"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sessionTabDetail(
|
||||||
|
project: string,
|
||||||
|
current: string | undefined,
|
||||||
|
defaultBranch: string | undefined,
|
||||||
|
worktree: boolean,
|
||||||
|
) {
|
||||||
|
const branch = worktree && current !== defaultBranch ? current : undefined
|
||||||
|
return branch && project ? `${project} ⎇ ${branch}` : (branch ?? project)
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionTabHistory = {
|
export type SessionTabHistory = {
|
||||||
entries: readonly string[]
|
entries: readonly string[]
|
||||||
index: number
|
index: number
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|||||||
import { isDeepEqual } from "remeda"
|
import { isDeepEqual } from "remeda"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useClient } from "./client"
|
import { useClient } from "./client"
|
||||||
import { useData } from "./data"
|
import { locationKey, useData } from "./data"
|
||||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||||
import { useEvent } from "./event"
|
import { useEvent } from "./event"
|
||||||
import { useRoute } from "./route"
|
import { useRoute } from "./route"
|
||||||
@@ -66,6 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||||
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
||||||
let closedTabs: ClosedSessionTab[] = []
|
let closedTabs: ClosedSessionTab[] = []
|
||||||
|
const scrollPositions = new Map<string, number>()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (config.experimental?.tab_scroll === true) return
|
||||||
|
scrollPositions.clear()
|
||||||
|
})
|
||||||
|
|
||||||
function state() {
|
function state() {
|
||||||
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
|
||||||
@@ -159,9 +165,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
|
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
|
||||||
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
|
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
|
||||||
// connection slots and switches still render from a warm cache.
|
// the first connection slots and switches still render from a warm cache.
|
||||||
const openTabSessions = createMemo(() =>
|
const openTabSessions = createMemo(() =>
|
||||||
state()
|
state()
|
||||||
.tabs.map((tab) => tab.sessionID)
|
.tabs.map((tab) => tab.sessionID)
|
||||||
@@ -171,10 +177,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
if (client.connection.status() !== "connected") return
|
if (client.connection.status() !== "connected") return
|
||||||
const sessionIDs = openTabSessions()
|
const signature = openTabSessions()
|
||||||
if (sessionIDs === "") return
|
if (signature === "") return
|
||||||
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
|
const sessionIDs = signature.split("\n")
|
||||||
let stale = false
|
let stale = false
|
||||||
|
void (async () => {
|
||||||
|
await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
|
||||||
|
if (stale) return
|
||||||
|
const locations = new Map(
|
||||||
|
sessionIDs
|
||||||
|
.map((sessionID) => data.session.get(sessionID)?.location)
|
||||||
|
.filter((location) => location !== undefined)
|
||||||
|
.map((location) => [locationKey(location), location]),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(locations.values(), (location) =>
|
||||||
|
Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})()
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
const sessions = state()
|
const sessions = state()
|
||||||
.tabs.map((tab) => tab.sessionID)
|
.tabs.map((tab) => tab.sessionID)
|
||||||
@@ -216,6 +237,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
|
|
||||||
function remove(sessionID: string, navigate: boolean) {
|
function remove(sessionID: string, navigate: boolean) {
|
||||||
const target = root(sessionID)
|
const target = root(sessionID)
|
||||||
|
scrollPositions.delete(target)
|
||||||
const closed = closeSessionTab(state().tabs, target)
|
const closed = closeSessionTab(state().tabs, target)
|
||||||
const selected = navigate && current() === target
|
const selected = navigate && current() === target
|
||||||
if (closed.tabs === state().tabs && !selected) return
|
if (closed.tabs === state().tabs && !selected) return
|
||||||
@@ -247,6 +269,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
},
|
},
|
||||||
current,
|
current,
|
||||||
status,
|
status,
|
||||||
|
scrollPosition(sessionID: string) {
|
||||||
|
const target = root(sessionID)
|
||||||
|
if (!state().tabs.some((tab) => tab.sessionID === target)) return
|
||||||
|
return scrollPositions.get(target)
|
||||||
|
},
|
||||||
|
setScrollPosition(sessionID: string, position: number | undefined) {
|
||||||
|
const target = root(sessionID)
|
||||||
|
if (position === undefined || !state().tabs.some((tab) => tab.sessionID === target)) {
|
||||||
|
scrollPositions.delete(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollPositions.set(target, position)
|
||||||
|
},
|
||||||
select(sessionID: string) {
|
select(sessionID: string) {
|
||||||
if (!enabled()) return
|
if (!enabled()) return
|
||||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePlugin } from "../../plugin/context"
|
|||||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog } from "../../ui/dialog"
|
||||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||||
|
import { ConfigPlugin, useConfig } from "../../config"
|
||||||
|
|
||||||
const id = "opencode.plugins"
|
const id = "opencode.plugins"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
|||||||
const [focused, setFocused] = createSignal<string>()
|
const [focused, setFocused] = createSignal<string>()
|
||||||
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
|
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const config = useConfig()
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
const builtins = props.plugins
|
const builtins = props.plugins
|
||||||
.registered()
|
.registered()
|
||||||
@@ -65,10 +67,9 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
|
|||||||
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
const current = props.plugins.registered().find((item) => item.id === plugin.value)
|
||||||
if (!current) return
|
if (!current) return
|
||||||
setLocked(true)
|
setLocked(true)
|
||||||
void (current.active ? props.plugins.deactivate(current.id) : props.plugins.activate(current.id))
|
void config
|
||||||
.then((ok) => {
|
.update((draft) => {
|
||||||
if (ok) return
|
ConfigPlugin.setEnabled(draft, current.id, !current.active)
|
||||||
props.context.ui.toast.show({ variant: "error", message: `Failed to update plugin ${current.id}` })
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
props.context.ui.toast.show({
|
props.context.ui.toast.show({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
|
|||||||
import { createSignal, For, type JSX } from "solid-js"
|
import { createSignal, For, type JSX } from "solid-js"
|
||||||
import { StoryFooter } from "./footer"
|
import { StoryFooter } from "./footer"
|
||||||
import { sessionTabsStory } from "./session-tabs"
|
import { sessionTabsStory } from "./session-tabs"
|
||||||
|
import { sessionLocationMissingStory } from "./session-location-missing"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
||||||
@@ -14,7 +15,7 @@ export type Story = {
|
|||||||
render: (context: Plugin.Context) => JSX.Element
|
render: (context: Plugin.Context) => JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
const stories: Story[] = [sessionTabsStory]
|
const stories: Story[] = [sessionTabsStory, sessionLocationMissingStory]
|
||||||
|
|
||||||
function Commands(props: { context: Plugin.Context }) {
|
function Commands(props: { context: Plugin.Context }) {
|
||||||
props.context.keymap.layer(() => ({
|
props.context.keymap.layer(() => ({
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||||
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import { DialogMoveSession } from "../../../component/dialog-move-session"
|
||||||
|
import { SessionLocationUnavailable } from "../../../routes/session/location-missing"
|
||||||
|
import type { Story } from "./index"
|
||||||
|
import { StoryFooter } from "./footer"
|
||||||
|
|
||||||
|
const directory = "/Users/kit/code/open-source/opencode-workerd-profile"
|
||||||
|
|
||||||
|
function SessionLocationMissingStory(props: { context: Plugin.Context }) {
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const theme = props.context.theme.contextual.elevated
|
||||||
|
const [message, setMessage] = createSignal("Choose another directory to continue")
|
||||||
|
const open = () =>
|
||||||
|
props.context.ui.dialog.show(() => (
|
||||||
|
<DialogMoveSession
|
||||||
|
projectID="fixture-project"
|
||||||
|
initialDirectories={[
|
||||||
|
{ directory: "/Users/kit/code/open-source/opencode" },
|
||||||
|
{
|
||||||
|
directory: "/Users/kit/code/open-source/opencode-instruction-rename",
|
||||||
|
strategy: "git_worktree",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fixture
|
||||||
|
onSelect={(selection) => {
|
||||||
|
if (selection.type !== "directory") return
|
||||||
|
setMessage(`Selected ${selection.directory}`)
|
||||||
|
props.context.ui.dialog.clear()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
props.context.keymap.layer(() => ({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
bind: "escape",
|
||||||
|
title: "Back to storybook",
|
||||||
|
group: "Storybook",
|
||||||
|
run: () => props.context.ui.router.navigate({ type: "plugin", name: "storybook" }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background.default}>
|
||||||
|
<box paddingLeft={2} paddingRight={2} paddingTop={1} flexGrow={1}>
|
||||||
|
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||||
|
Workerd Modal workspace driver
|
||||||
|
</text>
|
||||||
|
<text fg={theme.text.subdued}>build · GPT-5.6 Sol (high)</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.text.default}>You</text>
|
||||||
|
<text fg={theme.text.subdued}>Test the mounted workspace and verify the deployment.</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.text.default}>Build · GPT-5.6 Sol (high)</text>
|
||||||
|
<text fg={theme.text.subdued}>The deployment is verified and the worktree is clean.</text>
|
||||||
|
<box flexGrow={1} />
|
||||||
|
<SessionLocationUnavailable directory={directory} onMove={open} />
|
||||||
|
</box>
|
||||||
|
<StoryFooter
|
||||||
|
context={props.context}
|
||||||
|
title="storybook / missing session directory"
|
||||||
|
status={message()}
|
||||||
|
controls={[
|
||||||
|
{ shortcut: "enter", label: "confirm" },
|
||||||
|
{ shortcut: "esc", label: "back" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionLocationMissingStory: Story = {
|
||||||
|
id: "session-location-missing",
|
||||||
|
title: "Missing session directory",
|
||||||
|
render: (context) => <SessionLocationMissingStory context={context} />,
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ export function Composer(props: ComposerProps) {
|
|||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "composer",
|
mode: "composer",
|
||||||
enabled: () => props.open,
|
enabled: () => props.open,
|
||||||
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
{ bind: "left", title: "Previous tab", group: "Composer", run: () => switchTab(-1) },
|
||||||
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
{ bind: "right", title: "Next tab", group: "Composer", run: () => switchTab(1) },
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export function ShellTab(props: { sessionID: string }) {
|
|||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "composer",
|
mode: "composer",
|
||||||
enabled: () => composer.active("shell"),
|
enabled: () => composer.active("shell"),
|
||||||
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "composer.shell.up",
|
id: "composer.shell.up",
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
|||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "composer",
|
mode: "composer",
|
||||||
enabled: () => composer.active("subagents"),
|
enabled: () => composer.active("subagents"),
|
||||||
|
priority: 1,
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
id: "composer.subagent.up",
|
id: "composer.subagent.up",
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import { createSingleFlight } from "../../util/single-flight"
|
|||||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||||
|
import { SessionLocationMissing } from "./location-missing"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
@@ -115,12 +116,9 @@ addDefaultParsers(parsers.parsers)
|
|||||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||||
|
|
||||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
|
||||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
|
||||||
// in a few hundred milliseconds without a perceptible pause.
|
|
||||||
const TRANSCRIPT_TAIL_ROWS = 40
|
const TRANSCRIPT_TAIL_ROWS = 40
|
||||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||||
const TRANSCRIPT_BACKFILL_DELAY = 120
|
|
||||||
type PendingAction = "steer" | "queue" | "cancel"
|
type PendingAction = "steer" | "queue" | "cancel"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
@@ -274,6 +272,7 @@ export function Session() {
|
|||||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||||
const [synced, setSynced] = createSignal(false)
|
const [synced, setSynced] = createSignal(false)
|
||||||
const sessionTabs = useSessionTabs()
|
const sessionTabs = useSessionTabs()
|
||||||
|
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||||
|
|
||||||
const clearMessageNavigation = () => {
|
const clearMessageNavigation = () => {
|
||||||
setNavigationSlack(0)
|
setNavigationSlack(0)
|
||||||
@@ -319,7 +318,7 @@ export function Session() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
if (route.sessionID === sessionID && scroll) restoreScrollPosition(sessionID)
|
||||||
setSynced(true)
|
setSynced(true)
|
||||||
})().catch((error) => {
|
})().catch((error) => {
|
||||||
if (route.sessionID !== sessionID) return
|
if (route.sessionID !== sessionID) return
|
||||||
@@ -335,6 +334,13 @@ export function Session() {
|
|||||||
let seeded = false
|
let seeded = false
|
||||||
let sent = false
|
let sent = false
|
||||||
let scroll: ScrollBoxRenderable
|
let scroll: ScrollBoxRenderable
|
||||||
|
onCleanup(() => {
|
||||||
|
if (!scroll || scroll.isDestroyed) return
|
||||||
|
sessionTabs.setScrollPosition(
|
||||||
|
route.sessionID,
|
||||||
|
config.experimental?.tab_scroll === true && isAwayFromBottom() ? scroll.scrollTop : undefined,
|
||||||
|
)
|
||||||
|
})
|
||||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||||
const bind = (r: PromptRef | undefined) => {
|
const bind = (r: PromptRef | undefined) => {
|
||||||
setPrompt(r)
|
setPrompt(r)
|
||||||
@@ -354,32 +360,33 @@ export function Session() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tail-first transcript mounting: only the newest rows mount when the session opens, and the
|
// Tail-first transcript mounting: only the newest rows mount when the session opens. Older rows
|
||||||
// rest backfill in chunks shortly after, so switching to a long session costs the visible tail
|
// mount on demand near the top, keeping inactive tabs cheap to tear down. Until the first chunk
|
||||||
// instead of the whole transcript. Until backfill pins the count, the hidden span derives from
|
// pins the count, the hidden span derives from the row count, so streaming appends remain visible.
|
||||||
// the row count, so it needs no effect ordering; the clamp keeps at least a tail visible when a
|
|
||||||
// re-reduce shrinks the transcript. Streaming appends land at the end of the visible slice.
|
|
||||||
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
const [hiddenRows, setHiddenRows] = createSignal<number>()
|
||||||
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
|
||||||
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
const visibleRows = createMemo(() => (hidden() === 0 ? rows : rows.slice(hidden())))
|
||||||
createEffect(() => {
|
let revealingOlderRows = false
|
||||||
|
const revealOlderRows = (scrollBy = 0) => {
|
||||||
const current = hidden()
|
const current = hidden()
|
||||||
if (current === 0) return
|
if (
|
||||||
// Until the first chunk pins hiddenRows, appends change hidden() and reset this timer, so
|
revealingOlderRows ||
|
||||||
// backfill waits for a pause in streaming before starting. Once pinned, it drains on a fixed
|
current === 0 ||
|
||||||
// cadence undisturbed by appends.
|
!scroll ||
|
||||||
const timer = setTimeout(() => {
|
scroll.isDestroyed ||
|
||||||
const before = scroll && !scroll.isDestroyed ? scroll.scrollHeight : undefined
|
scroll.scrollTop > scroll.viewport.height
|
||||||
const viewportBottom = before === undefined ? 0 : scroll.scrollTop + scroll.viewport.height
|
)
|
||||||
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
|
return false
|
||||||
if (before === undefined) return
|
revealingOlderRows = true
|
||||||
// Sticky scroll holds bottom-anchored readers through the mount; compensation is only for
|
const before = scroll.scrollHeight
|
||||||
// readers who have scrolled up.
|
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
|
||||||
if (viewportBottom >= before - 1) return
|
afterLayout(() => {
|
||||||
afterLayout(() => scroll.scrollBy(scroll.scrollHeight - before))
|
revealingOlderRows = false
|
||||||
}, TRANSCRIPT_BACKFILL_DELAY)
|
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
|
||||||
onCleanup(() => clearTimeout(timer))
|
updateAwayFromBottom()
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
/** Message navigation needs the full transcript mounted before walking or jumping. */
|
||||||
const ensureAllRows = (continuation: () => void) => {
|
const ensureAllRows = (continuation: () => void) => {
|
||||||
if (hidden() === 0) return continuation()
|
if (hidden() === 0) return continuation()
|
||||||
@@ -387,6 +394,31 @@ export function Session() {
|
|||||||
afterLayout(continuation)
|
afterLayout(continuation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAwayFromBottom() {
|
||||||
|
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height) - 1
|
||||||
|
}
|
||||||
|
function updateAwayFromBottom() {
|
||||||
|
if (config.experimental?.tab_scroll !== true) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!scroll || scroll.isDestroyed) return
|
||||||
|
const away = isAwayFromBottom()
|
||||||
|
setAwayFromBottom(away)
|
||||||
|
if (!away) sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function restoreScrollPosition(sessionID: string) {
|
||||||
|
const position = config.experimental?.tab_scroll === true ? sessionTabs.scrollPosition(sessionID) : undefined
|
||||||
|
if (position === undefined) {
|
||||||
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
|
setAwayFromBottom(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureAllRows(() => {
|
||||||
|
scroll.scrollTo(position)
|
||||||
|
updateAwayFromBottom()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = prompt()
|
const current = prompt()
|
||||||
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
|
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
|
||||||
@@ -495,78 +527,65 @@ export function Session() {
|
|||||||
|
|
||||||
function toBottom() {
|
function toBottom() {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
|
setAwayFromBottom(false)
|
||||||
|
sessionTabs.setScrollPosition(route.sessionID, undefined)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!scroll || scroll.isDestroyed) return
|
if (!scroll || scroll.isDestroyed) return
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
scroll.scrollTo(scroll.scrollHeight)
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moveTranscript(delta: number) {
|
||||||
|
clearMessageNavigation()
|
||||||
|
if (delta >= 0 || !revealOlderRows(delta)) {
|
||||||
|
scroll.scrollBy(delta)
|
||||||
|
updateAwayFromBottom()
|
||||||
|
}
|
||||||
|
dialog.clear()
|
||||||
|
}
|
||||||
|
|
||||||
const globalCommands = [
|
const globalCommands = [
|
||||||
{
|
{
|
||||||
id: "session.page.up",
|
id: "session.page.up",
|
||||||
title: "Page up",
|
title: "Page up",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(-scroll.height / 2),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(-scroll.height / 2)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "session.page.down",
|
id: "session.page.down",
|
||||||
title: "Page down",
|
title: "Page down",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(scroll.height / 2),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(scroll.height / 2)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "session.line.up",
|
id: "session.line.up",
|
||||||
title: "Line up",
|
title: "Line up",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(-1),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(-1)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "session.line.down",
|
id: "session.line.down",
|
||||||
title: "Line down",
|
title: "Line down",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(1),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(1)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "session.half.page.up",
|
id: "session.half.page.up",
|
||||||
title: "Half page up",
|
title: "Half page up",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(-scroll.height / 4),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(-scroll.height / 4)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "session.half.page.down",
|
id: "session.half.page.down",
|
||||||
title: "Half page down",
|
title: "Half page down",
|
||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => moveTranscript(scroll.height / 4),
|
||||||
clearMessageNavigation()
|
|
||||||
scroll.scrollBy(scroll.height / 4)
|
|
||||||
dialog.clear()
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -578,7 +597,10 @@ export function Session() {
|
|||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
clearMessageNavigation()
|
||||||
scroll.scrollTo(0)
|
ensureAllRows(() => {
|
||||||
|
scroll.scrollTo(0)
|
||||||
|
updateAwayFromBottom()
|
||||||
|
})
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -588,8 +610,7 @@ export function Session() {
|
|||||||
group: "Session",
|
group: "Session",
|
||||||
palette: undefined,
|
palette: undefined,
|
||||||
run: () => {
|
run: () => {
|
||||||
clearMessageNavigation()
|
toBottom()
|
||||||
scroll.scrollTo(scroll.scrollHeight)
|
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1006,8 +1027,6 @@ export function Session() {
|
|||||||
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// snap to bottom when session changes
|
|
||||||
createEffect(on(() => route.sessionID, toBottom))
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => route.sessionID,
|
() => route.sessionID,
|
||||||
@@ -1043,47 +1062,59 @@ export function Session() {
|
|||||||
paddingBottom={1}
|
paddingBottom={1}
|
||||||
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
paddingLeft={dimensions().width < 44 ? 1 : 2}
|
||||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||||
gap={1}
|
|
||||||
>
|
>
|
||||||
<Show when={session()}>
|
<Show when={session()}>
|
||||||
<scrollbox
|
<box flexGrow={1} minHeight={0} position="relative">
|
||||||
ref={(r) => (scroll = r)}
|
<scrollbox
|
||||||
viewportOptions={{
|
ref={(r) => (scroll = r)}
|
||||||
paddingRight: showScrollbar() ? 1 : 0,
|
viewportOptions={{
|
||||||
}}
|
paddingRight: showScrollbar() ? 1 : 0,
|
||||||
verticalScrollbarOptions={{
|
}}
|
||||||
paddingLeft: 1,
|
verticalScrollbarOptions={{
|
||||||
visible: showScrollbar(),
|
paddingLeft: 1,
|
||||||
trackOptions: {
|
visible: showScrollbar(),
|
||||||
backgroundColor: theme.raise(theme.background.surface.offset),
|
trackOptions: {
|
||||||
foregroundColor: theme.border.default,
|
backgroundColor: theme.raise(theme.background.surface.offset),
|
||||||
},
|
foregroundColor: theme.border.default,
|
||||||
}}
|
},
|
||||||
stickyScroll={!navigationMessage()}
|
}}
|
||||||
stickyStart="bottom"
|
stickyScroll={!navigationMessage()}
|
||||||
flexGrow={1}
|
stickyStart="bottom"
|
||||||
scrollAcceleration={scrollAcceleration()}
|
flexGrow={1}
|
||||||
>
|
scrollAcceleration={scrollAcceleration()}
|
||||||
<For each={visibleRows()}>
|
onMouseScroll={(event) => {
|
||||||
{(row, index) => (
|
if (event.scroll?.direction === "up" && revealOlderRows()) return
|
||||||
<SessionRowView
|
updateAwayFromBottom()
|
||||||
row={row}
|
}}
|
||||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
>
|
||||||
boundaryID={boundaries()[index() + hidden()]}
|
<For each={visibleRows()}>
|
||||||
|
{(row, index) => (
|
||||||
|
<SessionRowView
|
||||||
|
row={row}
|
||||||
|
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||||
|
boundaryID={boundaries()[index() + hidden()]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<BackgroundToolHint messages={messages()} />
|
||||||
|
<Show when={session()?.revert?.messageID}>
|
||||||
|
<RevertMessage
|
||||||
|
count={messagesFromRevert().filter((message) => message.type === "user").length}
|
||||||
|
files={session()!.revert!.files ?? []}
|
||||||
/>
|
/>
|
||||||
)}
|
</Show>
|
||||||
</For>
|
<Show when={navigationSlack()}>
|
||||||
<BackgroundToolHint messages={messages()} />
|
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||||
<Show when={session()?.revert?.messageID}>
|
</Show>
|
||||||
<RevertMessage
|
</scrollbox>
|
||||||
count={messagesFromRevert().filter((message) => message.type === "user").length}
|
</box>
|
||||||
files={session()!.revert!.files ?? []}
|
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||||
/>
|
<Show when={config.experimental?.tab_scroll === true && awayFromBottom()}>
|
||||||
|
<text fg={theme.text.subdued} onMouseUp={toBottom}>
|
||||||
|
Latest ↓
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={navigationSlack()}>
|
</box>
|
||||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
|
||||||
</Show>
|
|
||||||
</scrollbox>
|
|
||||||
<box flexShrink={0}>
|
<box flexShrink={0}>
|
||||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||||
@@ -1115,6 +1146,19 @@ export function Session() {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match
|
||||||
|
when={
|
||||||
|
session() &&
|
||||||
|
currentLocation.error?.location.directory === session()!.location.directory &&
|
||||||
|
currentLocation.error?.location.workspaceID === session()!.location.workspaceID
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SessionLocationMissing
|
||||||
|
directory={session()!.location.directory}
|
||||||
|
projectID={session()!.projectID}
|
||||||
|
sessionID={route.sessionID}
|
||||||
|
/>
|
||||||
|
</Match>
|
||||||
<Match when={!disabled()}>
|
<Match when={!disabled()}>
|
||||||
<Prompt
|
<Prompt
|
||||||
visible={true}
|
visible={true}
|
||||||
@@ -2872,16 +2916,8 @@ function Shell(props: ToolProps) {
|
|||||||
})
|
})
|
||||||
const maxLines = 10
|
const maxLines = 10
|
||||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||||
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
|
const prefix = createMemo(() => (workdir() && workdir() !== "." ? `cd ${workdir()} && ` : ""))
|
||||||
const input = createMemo(() => {
|
const input = createMemo(() => (command() ? `${isRunning() ? "" : "$ "}${prefix()}${command()}` : ""))
|
||||||
const cmd = command()
|
|
||||||
if (!cmd) return ""
|
|
||||||
// While running, the workdir prompt shares the spinner's text column; when
|
|
||||||
// settled, the prompt renders as its own column so wrapped command lines
|
|
||||||
// keep a stable hanging indent instead of jumping to the card inset.
|
|
||||||
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
|
|
||||||
return cmd
|
|
||||||
})
|
|
||||||
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
||||||
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
||||||
const limited = createMemo(() => {
|
const limited = createMemo(() => {
|
||||||
@@ -2910,15 +2946,7 @@ function Shell(props: ToolProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show when={isRunning()} fallback={<text fg={theme.text.default}>{limitedInput()}</text>}>
|
||||||
when={isRunning()}
|
|
||||||
fallback={
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.text.default}>{prompt()}</text>
|
|
||||||
<text fg={theme.text.default}>{limitedInput()}</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Spinner color={color()}>{limitedInput()}</Spinner>
|
<Spinner color={color()}>{limitedInput()}</Spinner>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={limitedOutput()}>
|
<Show when={limitedOutput()}>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { useTuiPaths } from "../../context/runtime"
|
||||||
|
import { useTheme } from "../../context/theme"
|
||||||
|
import { Locale } from "../../util/locale"
|
||||||
|
import { abbreviateHome } from "../../util/path-format"
|
||||||
|
import { SessionQuestion } from "./permission"
|
||||||
|
import { usePromptMove } from "../../component/prompt/move"
|
||||||
|
|
||||||
|
export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
|
||||||
|
const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
|
||||||
|
return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
|
||||||
|
const paths = useTuiPaths()
|
||||||
|
const theme = useTheme("elevated")
|
||||||
|
const directory = createMemo(() => Locale.truncateMiddle(abbreviateHome(props.directory, paths.home), 72))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionQuestion
|
||||||
|
id="session.location-missing"
|
||||||
|
group="Session recovery"
|
||||||
|
choicesLabel="Recovery actions"
|
||||||
|
instance={props.directory}
|
||||||
|
title="Session location unavailable"
|
||||||
|
body={
|
||||||
|
<box paddingLeft={1} gap={1}>
|
||||||
|
<text fg={theme.text.subdued}>{directory()}</text>
|
||||||
|
<text fg={theme.text.default}>Choose another directory to continue this session.</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
options={{ move: "Choose directory" }}
|
||||||
|
onSelect={props.onMove}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -141,7 +141,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.stage === "always"}>
|
<Match when={store.stage === "always"}>
|
||||||
<Prompt
|
<SessionQuestion
|
||||||
title="Always allow"
|
title="Always allow"
|
||||||
semanticLabel={`Always allow ${props.request.action}`}
|
semanticLabel={`Always allow ${props.request.action}`}
|
||||||
instance={props.request.id}
|
instance={props.request.id}
|
||||||
@@ -235,7 +235,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
|||||||
)
|
)
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<Prompt
|
<SessionQuestion
|
||||||
title="Permission required"
|
title="Permission required"
|
||||||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||||
instance={props.request.id}
|
instance={props.request.id}
|
||||||
@@ -411,10 +411,13 @@ function RejectPrompt(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Prompt<const T extends Record<string, string>>(props: {
|
export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||||
title: string
|
title: string
|
||||||
semanticLabel?: string
|
semanticLabel?: string
|
||||||
instance: string
|
instance: string
|
||||||
|
id?: string
|
||||||
|
group?: string
|
||||||
|
choicesLabel?: string
|
||||||
header?: JSX.Element
|
header?: JSX.Element
|
||||||
body: JSX.Element
|
body: JSX.Element
|
||||||
options: T
|
options: T
|
||||||
@@ -431,86 +434,65 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
})
|
})
|
||||||
const narrow = createMemo(() => dimensions().width < 80)
|
const narrow = createMemo(() => dimensions().width < 80)
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
const id = () => props.id ?? "session.permission"
|
||||||
|
const group = () => props.group ?? "Permission"
|
||||||
|
|
||||||
Keymap.createLayer(() => ({
|
Keymap.createLayer(() => ({
|
||||||
mode: "base",
|
mode: "base",
|
||||||
commands: [
|
commands: [
|
||||||
{
|
|
||||||
id: "app.exit",
|
|
||||||
title: "Reject permission",
|
|
||||||
group: "Permission",
|
|
||||||
bind: false,
|
|
||||||
run() {
|
|
||||||
if (!props.escapeKey) return
|
|
||||||
props.onSelect(props.escapeKey)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "permission.prompt.fullscreen",
|
|
||||||
title: "Toggle permission fullscreen",
|
|
||||||
group: "Permission",
|
|
||||||
bind: false,
|
|
||||||
run() {
|
|
||||||
if (!props.fullscreen) return
|
|
||||||
setStore("expanded", (v) => !v)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bind: "left",
|
|
||||||
title: "Previous permission option",
|
|
||||||
group: "Permission",
|
|
||||||
run: () => {
|
|
||||||
const idx = keys.indexOf(store.selected)
|
|
||||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
|
||||||
setStore("selected", next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bind: "h",
|
|
||||||
title: "Previous permission option",
|
|
||||||
group: "Permission",
|
|
||||||
run: () => {
|
|
||||||
const idx = keys.indexOf(store.selected)
|
|
||||||
const next = keys[(idx - 1 + keys.length) % keys.length]
|
|
||||||
setStore("selected", next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bind: "right",
|
|
||||||
title: "Next permission option",
|
|
||||||
group: "Permission",
|
|
||||||
run: () => {
|
|
||||||
const idx = keys.indexOf(store.selected)
|
|
||||||
const next = keys[(idx + 1) % keys.length]
|
|
||||||
setStore("selected", next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bind: "l",
|
|
||||||
title: "Next permission option",
|
|
||||||
group: "Permission",
|
|
||||||
run: () => {
|
|
||||||
const idx = keys.indexOf(store.selected)
|
|
||||||
const next = keys[(idx + 1) % keys.length]
|
|
||||||
setStore("selected", next)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bind: "return",
|
|
||||||
title: "Select permission option",
|
|
||||||
group: "Permission",
|
|
||||||
run: () => props.onSelect(store.selected),
|
|
||||||
},
|
|
||||||
...(props.escapeKey
|
...(props.escapeKey
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
bind: "escape",
|
id: "app.exit",
|
||||||
title: "Reject permission",
|
title: "Reject permission",
|
||||||
group: "Permission",
|
group: group(),
|
||||||
|
bind: false as const,
|
||||||
run: () => props.onSelect(props.escapeKey!),
|
run: () => props.onSelect(props.escapeKey!),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(props.fullscreen
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "permission.prompt.fullscreen",
|
||||||
|
title: "Toggle permission fullscreen",
|
||||||
|
group: group(),
|
||||||
|
bind: false as const,
|
||||||
|
run: () => setStore("expanded", (value) => !value),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(keys.length > 1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
bind: "left,h",
|
||||||
|
title: "Previous option",
|
||||||
|
group: group(),
|
||||||
|
run: () => {
|
||||||
|
const index = keys.indexOf(store.selected)
|
||||||
|
setStore("selected", keys[(index - 1 + keys.length) % keys.length])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bind: "right,l",
|
||||||
|
title: "Next option",
|
||||||
|
group: group(),
|
||||||
|
run: () => {
|
||||||
|
const index = keys.indexOf(store.selected)
|
||||||
|
setStore("selected", keys[(index + 1) % keys.length])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
bind: "return",
|
||||||
|
title: "Select option",
|
||||||
|
group: group(),
|
||||||
|
run: () => props.onSelect(store.selected),
|
||||||
|
},
|
||||||
|
...(props.escapeKey
|
||||||
|
? [{ bind: "escape", title: "Reject permission", group: group(), run: () => props.onSelect(props.escapeKey!) }]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
|
bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
|
||||||
}))
|
}))
|
||||||
@@ -520,7 +502,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
|
|
||||||
const content = () => (
|
const content = () => (
|
||||||
<box
|
<box
|
||||||
id="session.permission"
|
id={id()}
|
||||||
ref={SimulationSemantics.bind(() => ({
|
ref={SimulationSemantics.bind(() => ({
|
||||||
instance: props.instance,
|
instance: props.instance,
|
||||||
role: "dialog",
|
role: "dialog",
|
||||||
@@ -571,11 +553,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
alignItems={narrow() ? "flex-start" : "center"}
|
alignItems={narrow() ? "flex-start" : "center"}
|
||||||
>
|
>
|
||||||
<box
|
<box
|
||||||
id="session.permission.actions"
|
id={`${id()}.actions`}
|
||||||
ref={SimulationSemantics.bind(() => ({
|
ref={SimulationSemantics.bind(() => ({
|
||||||
instance: props.instance,
|
instance: props.instance,
|
||||||
role: "listbox",
|
role: "listbox",
|
||||||
label: "Permission choices",
|
label: props.choicesLabel ?? "Permission choices",
|
||||||
}))}
|
}))}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
@@ -584,7 +566,7 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
<For each={keys}>
|
<For each={keys}>
|
||||||
{(option) => (
|
{(option) => (
|
||||||
<box
|
<box
|
||||||
id={`session.permission.action.${String(option)}`}
|
id={`${id()}.action.${String(option)}`}
|
||||||
ref={SimulationSemantics.bind(() => ({
|
ref={SimulationSemantics.bind(() => ({
|
||||||
instance: props.instance,
|
instance: props.instance,
|
||||||
role: "option",
|
role: "option",
|
||||||
@@ -621,9 +603,11 @@ function Prompt<const T extends Record<string, string>>(props: {
|
|||||||
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: theme.text.subdued }}>{hint()}</span>
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={theme.text.default}>
|
<Show when={keys.length > 1}>
|
||||||
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
<text fg={theme.text.default}>
|
||||||
</text>
|
{"⇆"} <span style={{ fg: theme.text.subdued }}>select</span>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<text fg={theme.text.default}>
|
<text fg={theme.text.default}>
|
||||||
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
enter <span style={{ fg: theme.text.subdued }}>confirm</span>
|
||||||
</text>
|
</text>
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ const sessions = {
|
|||||||
|
|
||||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||||
|
|
||||||
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
|
async function renderComposer(
|
||||||
|
defaultTab: "subagents" | "shell",
|
||||||
|
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||||
|
focusedTextarea = false,
|
||||||
|
) {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const interrupted: string[] = []
|
const interrupted: string[] = []
|
||||||
const removed: string[] = []
|
const removed: string[] = []
|
||||||
@@ -69,7 +73,21 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
|
|||||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||||
.then(() => ready.resolve(), ready.reject)
|
.then(() => ready.resolve(), ready.reject)
|
||||||
})
|
})
|
||||||
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
return (
|
||||||
|
<>
|
||||||
|
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||||
|
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppExit() {
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
mode: "global",
|
||||||
|
commands: [{ id: "app.exit", title: "Exit", group: "System", run: () => {} }],
|
||||||
|
}))
|
||||||
|
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(
|
const app = await testRender(
|
||||||
@@ -88,6 +106,7 @@ async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Parti
|
|||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
|
<AppExit />
|
||||||
</Keymap.Provider>
|
</Keymap.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
@@ -154,6 +173,20 @@ test("disabled shell bindings have no component fallbacks", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("configured composer bindings work with a focused textarea", async () => {
|
||||||
|
const composer = await renderComposer("subagents", { "composer.shell.kill": "ctrl+u" }, true)
|
||||||
|
try {
|
||||||
|
composer.app.mockInput.pressArrow("right")
|
||||||
|
await composer.app.renderOnce()
|
||||||
|
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||||
|
composer.app.mockInput.pressKey("u", { ctrl: true })
|
||||||
|
await wait(() => composer.removed.length === 1)
|
||||||
|
expect(composer.removed).toEqual(["sh-a"])
|
||||||
|
} finally {
|
||||||
|
composer.app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string, parentID?: string) {
|
function session(id: string, title: string, parentID?: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
import {
|
import {
|
||||||
|
TabPulse,
|
||||||
blendTabPulseColor,
|
blendTabPulseColor,
|
||||||
completionPulseOpacity,
|
completionPulseOpacity,
|
||||||
glowIgnitionLevel,
|
glowIgnitionLevel,
|
||||||
@@ -9,6 +13,44 @@ import {
|
|||||||
} from "../../src/component/tab-pulse"
|
} from "../../src/component/tab-pulse"
|
||||||
import { tint } from "../../src/theme/color"
|
import { tint } from "../../src/theme/color"
|
||||||
|
|
||||||
|
test("a disabled pulse stays idle when it becomes active", async () => {
|
||||||
|
const background = RGBA.fromHex("#101010")
|
||||||
|
const [active, setActive] = createSignal(false)
|
||||||
|
const app = await testRender(
|
||||||
|
() => <TabPulse enabled={false} active={active()} color={background} backgroundColor={background} />,
|
||||||
|
{ width: 8, height: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.renderer.root.liveCount).toBe(0)
|
||||||
|
|
||||||
|
setActive(true)
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.renderer.root.liveCount).toBe(0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("an attention glow becomes idle after ignition", async () => {
|
||||||
|
const background = RGBA.fromHex("#101010")
|
||||||
|
const app = await testRender(
|
||||||
|
() => <TabPulse active={false} glow color={RGBA.fromHex("#ffcc00")} backgroundColor={background} />,
|
||||||
|
{ width: 8, height: 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.renderer.root.liveCount).toBe(1)
|
||||||
|
await Bun.sleep(650)
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.renderer.root.liveCount).toBe(0)
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||||
expect(completionPulseOpacity(0)).toBe(0)
|
expect(completionPulseOpacity(0)).toBe(0)
|
||||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||||
|
|||||||
@@ -11,11 +11,20 @@ import {
|
|||||||
reopenSessionTab,
|
reopenSessionTab,
|
||||||
seedSessionTabMotion,
|
seedSessionTabMotion,
|
||||||
sessionTabComplete,
|
sessionTabComplete,
|
||||||
|
sessionTabDetail,
|
||||||
sessionTabOverflowWidth,
|
sessionTabOverflowWidth,
|
||||||
sessionTabShortcutLabel,
|
sessionTabShortcutLabel,
|
||||||
} from "../../src/context/session-tabs-model"
|
} from "../../src/context/session-tabs-model"
|
||||||
|
|
||||||
describe("session tabs", () => {
|
describe("session tabs", () => {
|
||||||
|
test("appends the branch to the project detail", () => {
|
||||||
|
expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
|
||||||
|
expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
|
||||||
|
expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
|
||||||
|
expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
|
||||||
|
expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
|
||||||
|
})
|
||||||
|
|
||||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||||
"1",
|
"1",
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
|||||||
|
|
||||||
async function renderSessionTabs(
|
async function renderSessionTabs(
|
||||||
initialSessionID: string,
|
initialSessionID: string,
|
||||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
options?: {
|
||||||
|
state?: string
|
||||||
|
title?: string
|
||||||
|
home?: boolean
|
||||||
|
persisted?: string[]
|
||||||
|
sessionGate?: Promise<void>
|
||||||
|
sessionDirectories?: Record<string, string>
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const temporary = options?.state ? undefined : await tmpdir()
|
const temporary = options?.state ? undefined : await tmpdir()
|
||||||
const state = options?.state ?? temporary!.path
|
const state = options?.state ?? temporary!.path
|
||||||
@@ -45,7 +52,25 @@ async function renderSessionTabs(
|
|||||||
}
|
}
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const sessions: string[] = []
|
const sessions: string[] = []
|
||||||
|
const locations: string[] = []
|
||||||
|
const vcsLocations: string[] = []
|
||||||
const calls = createFetch(async (url) => {
|
const calls = createFetch(async (url) => {
|
||||||
|
if (url.pathname === "/api/location") {
|
||||||
|
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||||
|
locations.push(requested)
|
||||||
|
return json({
|
||||||
|
directory: requested,
|
||||||
|
project: { id: "project", directory: requested, canonical: directory },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/vcs") {
|
||||||
|
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||||
|
vcsLocations.push(requested)
|
||||||
|
return json({
|
||||||
|
location: { directory: requested },
|
||||||
|
data: { branch: { current: "main", default: "main" } },
|
||||||
|
})
|
||||||
|
}
|
||||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
sessions.push(sessionID)
|
sessions.push(sessionID)
|
||||||
@@ -55,7 +80,7 @@ async function renderSessionTabs(
|
|||||||
id: sessionID,
|
id: sessionID,
|
||||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||||
projectID: "project",
|
projectID: "project",
|
||||||
location: { directory },
|
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
time: { created: 0, updated: 0 },
|
time: { created: 0, updated: 0 },
|
||||||
@@ -107,6 +132,8 @@ async function renderSessionTabs(
|
|||||||
route,
|
route,
|
||||||
data,
|
data,
|
||||||
sessions,
|
sessions,
|
||||||
|
locations,
|
||||||
|
vcsLocations,
|
||||||
state,
|
state,
|
||||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||||
async destroy() {
|
async destroy() {
|
||||||
@@ -137,6 +164,22 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads VCS metadata for each persisted tab location", async () => {
|
||||||
|
const other = `${directory}/other-worktree`
|
||||||
|
const setup = await renderSessionTabs("first", {
|
||||||
|
home: true,
|
||||||
|
persisted: ["first", "second"],
|
||||||
|
sessionDirectories: { second: other },
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wait(() => setup.locations.includes(other))
|
||||||
|
await wait(() => setup.vcsLocations.includes(other))
|
||||||
|
} finally {
|
||||||
|
await setup.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("stores session tabs for the current working directory by default", async () => {
|
test("stores session tabs for the current working directory by default", async () => {
|
||||||
const setup = await renderSessionTabs("first")
|
const setup = await renderSessionTabs("first")
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function until(read: () => Promise<string>, expected: (value: string | und
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootApp(directory: string) {
|
async function bootApp(directory: string, config: Record<string, unknown> = {}) {
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
const core = await import("@opentui/core")
|
const core = await import("@opentui/core")
|
||||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||||
@@ -53,7 +53,13 @@ async function bootApp(directory: string) {
|
|||||||
run({
|
run({
|
||||||
app: { name: "test", version: "test", channel: "test" },
|
app: { name: "test", version: "test", channel: "test" },
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: {
|
||||||
|
get: async () => config,
|
||||||
|
update: async (update) => {
|
||||||
|
update(config)
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
},
|
||||||
packages: { resolve: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: {},
|
args: {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
@@ -217,6 +223,29 @@ test("editing one plugin leaves others untouched and a broken save keeps the las
|
|||||||
await app.task
|
await app.task
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("editing one plugin does not reactivate a plugin disabled by configuration", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||||
|
await mkdir(directory, { recursive: true })
|
||||||
|
const markerA = path.join(tmp.path, "a.txt")
|
||||||
|
const markerB = path.join(tmp.path, "b.txt")
|
||||||
|
await writeFile(path.join(directory, "a.ts"), lifecycleSource(markerA, "test.a", "a1"))
|
||||||
|
const sourceB = path.join(directory, "b.ts")
|
||||||
|
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1"))
|
||||||
|
|
||||||
|
await using app = await bootApp(tmp.path, { plugins: ["-test.a"] })
|
||||||
|
const readB = () => readFile(markerB, "utf8")
|
||||||
|
expect(await until(readB, (value) => value === "b1:setup\n")).toBe("b1:setup\n")
|
||||||
|
expect(await readFile(markerA, "utf8").catch(() => undefined)).toBeUndefined()
|
||||||
|
|
||||||
|
await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2"))
|
||||||
|
expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n")
|
||||||
|
expect(await readFile(markerA, "utf8").catch(() => undefined)).toBeUndefined()
|
||||||
|
|
||||||
|
process.emit("SIGHUP")
|
||||||
|
await app.task
|
||||||
|
})
|
||||||
|
|
||||||
test("a save whose setup throws restores the previous version", async () => {
|
test("a save whose setup throws restores the previous version", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { ConfigPlugin } from "../src/config"
|
||||||
|
|
||||||
|
test("plugin toggles replace exact directives without disturbing source declarations", () => {
|
||||||
|
const source = { package: "/tmp/recap.ts", options: { compact: true } }
|
||||||
|
const config = { plugins: [source, "kit.session-recap", "-kit.session-recap", "other"] }
|
||||||
|
|
||||||
|
ConfigPlugin.setEnabled(config, "kit.session-recap", false)
|
||||||
|
expect(config.plugins).toEqual([source, "other", "-kit.session-recap"])
|
||||||
|
|
||||||
|
ConfigPlugin.setEnabled(config, "kit.session-recap", true)
|
||||||
|
expect(config.plugins).toEqual([source, "other", "kit.session-recap"])
|
||||||
|
})
|
||||||
@@ -3,23 +3,13 @@ import { saveDraft, takeDraft } from "../../src/component/prompt/draft-stash"
|
|||||||
import { emptyPrompt } from "../../src/prompt/history"
|
import { emptyPrompt } from "../../src/prompt/history"
|
||||||
|
|
||||||
// The Prompt component stashes an unsent draft in onCleanup and takes it back
|
// The Prompt component stashes an unsent draft in onCleanup and takes it back
|
||||||
// in onMount across route remounts. The key it uses is undefined by default
|
// in onMount across route remounts, keyed by sessionID or undefined for home.
|
||||||
// (one global slot that follows focus across tabs) and the tab identity
|
|
||||||
// (sessionID, or "home") when the tab_drafts experiment is on.
|
|
||||||
|
|
||||||
function draft(text: string, cursor = text.length) {
|
function draft(text: string, cursor = text.length) {
|
||||||
return { prompt: { ...emptyPrompt(), text }, cursor }
|
return { prompt: { ...emptyPrompt(), text }, cursor }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("prompt draft stash", () => {
|
describe("prompt draft stash", () => {
|
||||||
test("global slot follows focus: any tab takes the last stashed draft", () => {
|
|
||||||
const entry = draft("follow me")
|
|
||||||
saveDraft(undefined, entry)
|
|
||||||
expect(takeDraft(undefined)).toBe(entry)
|
|
||||||
// Consumed on take, so a remount never restores a stale copy.
|
|
||||||
expect(takeDraft(undefined)).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tab-keyed drafts stay on the tab they were written in", () => {
|
test("tab-keyed drafts stay on the tab they were written in", () => {
|
||||||
const two = draft("notes for session two")
|
const two = draft("notes for session two")
|
||||||
saveDraft("ses_two", two)
|
saveDraft("ses_two", two)
|
||||||
@@ -37,25 +27,12 @@ describe("prompt draft stash", () => {
|
|||||||
const one = draft("DRAFT-ONE")
|
const one = draft("DRAFT-ONE")
|
||||||
const home = draft("draft on home")
|
const home = draft("draft on home")
|
||||||
saveDraft("ses_one", one)
|
saveDraft("ses_one", one)
|
||||||
saveDraft("home", home)
|
saveDraft(undefined, home)
|
||||||
|
|
||||||
expect(takeDraft("home")).toBe(home)
|
expect(takeDraft(undefined)).toBe(home)
|
||||||
expect(takeDraft("ses_one")).toBe(one)
|
expect(takeDraft("ses_one")).toBe(one)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("global and tab slots never leak into each other when the experiment toggles mid-draft", () => {
|
|
||||||
const global = draft("stashed before enabling tab_drafts")
|
|
||||||
const keyed = draft("stashed after enabling tab_drafts")
|
|
||||||
saveDraft(undefined, global)
|
|
||||||
saveDraft("ses_a", keyed)
|
|
||||||
|
|
||||||
// A keyed lookup must not surface the global draft on the wrong tab...
|
|
||||||
expect(takeDraft("ses_b")).toBeUndefined()
|
|
||||||
// ...and the global slot must not surface a tab's draft.
|
|
||||||
expect(takeDraft(undefined)).toBe(global)
|
|
||||||
expect(takeDraft("ses_a")).toBe(keyed)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("a newer draft for the same slot replaces the older one", () => {
|
test("a newer draft for the same slot replaces the older one", () => {
|
||||||
saveDraft("ses_a", draft("first"))
|
saveDraft("ses_a", draft("first"))
|
||||||
const second = draft("second")
|
const second = draft("second")
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
dir: "packages/www",
|
dir: "packages/www",
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
background: "#131010",
|
background: { dark: "#131010" },
|
||||||
fonts: {
|
fonts: {
|
||||||
body: "ibm-plex-mono",
|
body: "ibm-plex-mono",
|
||||||
display: "ibm-plex-mono",
|
display: "ibm-plex-mono",
|
||||||
|
|||||||
Reference in New Issue
Block a user