mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cac6d60ed | |||
| 903acdf0d4 |
@@ -88,6 +88,7 @@
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -383,6 +384,7 @@
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -403,6 +405,7 @@
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"turndown": "7.2.0",
|
||||
@@ -424,6 +427,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
|
||||
@@ -133,6 +133,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
||||
@@ -116,6 +116,13 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||
},
|
||||
}),
|
||||
...(kv.dismissed_getting_started === undefined
|
||||
? {}
|
||||
: {
|
||||
hints: {
|
||||
onboarding: !kv.dismissed_getting_started,
|
||||
},
|
||||
}),
|
||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
pair: () => import("./commands/handlers/pair"),
|
||||
|
||||
@@ -235,6 +235,10 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||
|
||||
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
terminal: { title: false },
|
||||
prompt: { editor: false, paste: "full" },
|
||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||
hints: { onboarding: false },
|
||||
animations: false,
|
||||
mouse: false,
|
||||
})
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
@@ -99,6 +100,7 @@
|
||||
"@ff-labs/fff-node": "0.10.1",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -120,6 +122,7 @@
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -299,6 +299,8 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Project } from "../project"
|
||||
import { Session } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
export const Destination = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "MoveSession.Destination" })
|
||||
export type Destination = typeof Destination.Type
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
sessionID: SessionSchema.ID,
|
||||
destination: Destination,
|
||||
moveChanges: Schema.optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "MoveSession.Input" })
|
||||
export type Input = typeof Input.Type
|
||||
|
||||
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
|
||||
"MoveSession.DestinationProjectMismatchError",
|
||||
{
|
||||
expected: Project.ID,
|
||||
actual: Project.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
||||
"MoveSession.DestinationNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
||||
"MoveSession.DestinationNotDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
||||
"MoveSession.CaptureChangesError",
|
||||
{
|
||||
message: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
||||
"MoveSession.ResetSourceChangesError",
|
||||
{
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
},
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Session.NotFoundError
|
||||
| DestinationProjectMismatchError
|
||||
| DestinationNotFoundError
|
||||
| DestinationNotDirectoryError
|
||||
| Session.DestinationNotFoundError
|
||||
| Session.DestinationNotDirectoryError
|
||||
| CaptureChangesError
|
||||
| ApplyChangesError
|
||||
| ResetSourceChangesError
|
||||
|
||||
export interface Interface {
|
||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const project = yield* Project.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* sessions.get(input.sessionID)
|
||||
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
|
||||
const value = input.destination.directory.trim()
|
||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
||||
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
|
||||
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (current.location.directory === directory) return
|
||||
|
||||
const source = yield* project.resolve(current.location.directory)
|
||||
const destination = yield* project.resolve(directory)
|
||||
if (input.moveChanges && current.projectID !== destination.id) {
|
||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
||||
}
|
||||
// A move must not race active execution: a mid-drain relocation would let
|
||||
// the source Location dispatch a request assembled under stale instructions
|
||||
// and history. Serialize like removal does — stop the drain, then move.
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
|
||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
||||
if (moveChanges && !sourceRepository)
|
||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
||||
const patch = sourceRepository
|
||||
? yield* git.change
|
||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: Git.ChangeSet.make("")
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
||||
yield* git.change
|
||||
.apply({ repository, path: directory, changes: patch })
|
||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
yield* session.move({
|
||||
sessionID: input.sessionID,
|
||||
directory,
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
const repository = yield* git.repo.discover(current.location.directory)
|
||||
if (!repository)
|
||||
return yield* new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: "Source is not a Git repository",
|
||||
})
|
||||
yield* git.change
|
||||
.discard({
|
||||
repository,
|
||||
path: current.location.directory,
|
||||
index: "preserve",
|
||||
untracked: "remove",
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: error.message,
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ moveSession })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Git.node,
|
||||
Global.node,
|
||||
Project.node,
|
||||
Session.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
],
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const imageGenerationArgsSchema = z
|
||||
@@ -23,3 +24,91 @@ export const imageGenerationArgsSchema = z
|
||||
export const imageGenerationOutputSchema = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
type ImageGenerationArgs = {
|
||||
/**
|
||||
* Background type for the generated image. Default is 'auto'.
|
||||
*/
|
||||
background?: "auto" | "opaque" | "transparent"
|
||||
|
||||
/**
|
||||
* Input fidelity for the generated image. Default is 'low'.
|
||||
*/
|
||||
inputFidelity?: "low" | "high"
|
||||
|
||||
/**
|
||||
* Optional mask for inpainting.
|
||||
* Contains image_url (string, optional) and file_id (string, optional).
|
||||
*/
|
||||
inputImageMask?: {
|
||||
/**
|
||||
* File ID for the mask image.
|
||||
*/
|
||||
fileId?: string
|
||||
|
||||
/**
|
||||
* Base64-encoded mask image.
|
||||
*/
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The image generation model to use. Default: gpt-image-1.
|
||||
*/
|
||||
model?: string
|
||||
|
||||
/**
|
||||
* Moderation level for the generated image. Default: auto.
|
||||
*/
|
||||
moderation?: "auto"
|
||||
|
||||
/**
|
||||
* Compression level for the output image. Default: 100.
|
||||
*/
|
||||
outputCompression?: number
|
||||
|
||||
/**
|
||||
* The output format of the generated image. One of png, webp, or jpeg.
|
||||
* Default: png
|
||||
*/
|
||||
outputFormat?: "png" | "jpeg" | "webp"
|
||||
|
||||
/**
|
||||
* Number of partial images to generate in streaming mode, from 0 (default value) to 3.
|
||||
*/
|
||||
partialImages?: number
|
||||
|
||||
/**
|
||||
* The quality of the generated image.
|
||||
* One of low, medium, high, or auto. Default: auto.
|
||||
*/
|
||||
quality?: "auto" | "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* The size of the generated image.
|
||||
* One of 1024x1024, 1024x1536, 1536x1024, or auto.
|
||||
* Default: auto.
|
||||
*/
|
||||
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
|
||||
}
|
||||
|
||||
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
|
||||
{},
|
||||
{
|
||||
/**
|
||||
* The generated image encoded in base64.
|
||||
*/
|
||||
result: string
|
||||
},
|
||||
ImageGenerationArgs
|
||||
>({
|
||||
id: "openai.image_generation",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: imageGenerationOutputSchema,
|
||||
})
|
||||
|
||||
export const imageGeneration = (
|
||||
args: ImageGenerationArgs = {}, // default
|
||||
) => {
|
||||
return imageGenerationToolFactory(args)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const localShellInputSchema = z.object({
|
||||
@@ -14,3 +15,50 @@ export const localShellInputSchema = z.object({
|
||||
export const localShellOutputSchema = z.object({
|
||||
output: z.string(),
|
||||
})
|
||||
|
||||
export const localShell = createProviderToolFactoryWithOutputSchema<
|
||||
{
|
||||
/**
|
||||
* Execute a shell command on the server.
|
||||
*/
|
||||
action: {
|
||||
type: "exec"
|
||||
|
||||
/**
|
||||
* The command to run.
|
||||
*/
|
||||
command: string[]
|
||||
|
||||
/**
|
||||
* Optional timeout in milliseconds for the command.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
|
||||
/**
|
||||
* Optional user to run the command as.
|
||||
*/
|
||||
user?: string
|
||||
|
||||
/**
|
||||
* Optional working directory to run the command in.
|
||||
*/
|
||||
workingDirectory?: string
|
||||
|
||||
/**
|
||||
* Environment variables to set for the command.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
}
|
||||
},
|
||||
{
|
||||
/**
|
||||
* The output of local shell tool call.
|
||||
*/
|
||||
output: string
|
||||
},
|
||||
{}
|
||||
>({
|
||||
id: "openai.local_shell",
|
||||
inputSchema: localShellInputSchema,
|
||||
outputSchema: localShellOutputSchema,
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
// Args validation schema
|
||||
@@ -38,3 +39,65 @@ export const webSearchPreviewArgsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const webSearchPreview = createProviderToolFactory<
|
||||
{
|
||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Search context size to use for the web search.
|
||||
* - high: Most comprehensive context, highest cost, slower response
|
||||
* - medium: Balanced context, cost, and latency (default)
|
||||
* - low: Least context, lowest cost, fastest response
|
||||
*/
|
||||
searchContextSize?: "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* User location information to provide geographically relevant search results.
|
||||
*/
|
||||
userLocation?: {
|
||||
/**
|
||||
* Type of location (always 'approximate')
|
||||
*/
|
||||
type: "approximate"
|
||||
/**
|
||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
||||
*/
|
||||
country?: string
|
||||
/**
|
||||
* City name (free text, e.g., 'Minneapolis')
|
||||
*/
|
||||
city?: string
|
||||
/**
|
||||
* Region name (free text, e.g., 'Minnesota')
|
||||
*/
|
||||
region?: string
|
||||
/**
|
||||
* IANA timezone (e.g., 'America/Chicago')
|
||||
*/
|
||||
timezone?: string
|
||||
}
|
||||
}
|
||||
>({
|
||||
id: "openai.web_search_preview",
|
||||
inputSchema: z.object({
|
||||
action: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string().nullish(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("open_page"),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("find"),
|
||||
url: z.string(),
|
||||
pattern: z.string(),
|
||||
}),
|
||||
])
|
||||
.nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export const webSearchArgsSchema = z.object({
|
||||
@@ -19,3 +20,83 @@ export const webSearchArgsSchema = z.object({
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const webSearchToolFactory = createProviderToolFactory<
|
||||
{
|
||||
// Web search doesn't take input parameters - it's controlled by the prompt
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Filters for the search.
|
||||
*/
|
||||
filters?: {
|
||||
/**
|
||||
* Allowed domains for the search.
|
||||
* If not provided, all domains are allowed.
|
||||
* Subdomains of the provided domains are allowed as well.
|
||||
*/
|
||||
allowedDomains?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Search context size to use for the web search.
|
||||
* - high: Most comprehensive context, highest cost, slower response
|
||||
* - medium: Balanced context, cost, and latency (default)
|
||||
* - low: Least context, lowest cost, fastest response
|
||||
*/
|
||||
searchContextSize?: "low" | "medium" | "high"
|
||||
|
||||
/**
|
||||
* User location information to provide geographically relevant search results.
|
||||
*/
|
||||
userLocation?: {
|
||||
/**
|
||||
* Type of location (always 'approximate')
|
||||
*/
|
||||
type: "approximate"
|
||||
/**
|
||||
* Two-letter ISO country code (e.g., 'US', 'GB')
|
||||
*/
|
||||
country?: string
|
||||
/**
|
||||
* City name (free text, e.g., 'Minneapolis')
|
||||
*/
|
||||
city?: string
|
||||
/**
|
||||
* Region name (free text, e.g., 'Minnesota')
|
||||
*/
|
||||
region?: string
|
||||
/**
|
||||
* IANA timezone (e.g., 'America/Chicago')
|
||||
*/
|
||||
timezone?: string
|
||||
}
|
||||
}
|
||||
>({
|
||||
id: "openai.web_search",
|
||||
inputSchema: z.object({
|
||||
action: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string().nullish(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("open_page"),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("find"),
|
||||
url: z.string(),
|
||||
pattern: z.string(),
|
||||
}),
|
||||
])
|
||||
.nullish(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const webSearch = (
|
||||
args: Parameters<typeof webSearchToolFactory>[0] = {}, // default
|
||||
) => {
|
||||
return webSearchToolFactory(args)
|
||||
}
|
||||
|
||||
@@ -148,3 +148,6 @@ export function buildLocationServiceMap(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import { App } from "../app"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
@@ -348,6 +350,40 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
get: (input) => runtime.session.get(input.sessionID),
|
||||
list: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const query =
|
||||
input?.cursor !== undefined
|
||||
? yield* SessionsQuery.Cursor.parse(input.cursor).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
: (input ?? {})
|
||||
const page = yield* runtime.session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: input?.limit ?? SessionsQuery.DefaultLimit,
|
||||
})
|
||||
return { data: page.data, cursor: SessionsQuery.Cursor.page(query, page.data) }
|
||||
}),
|
||||
messages: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.cursor !== undefined && input.order !== undefined)
|
||||
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
|
||||
const decoded =
|
||||
input.cursor !== undefined
|
||||
? yield* SessionMessagesCursor.parse(input.cursor).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
: undefined
|
||||
const order = decoded?.order ?? input.order ?? "desc"
|
||||
const data = yield* runtime.session.messages({
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
return { data, cursor: SessionMessagesCursor.page(data, order) }
|
||||
}),
|
||||
prompt: runtime.session.prompt,
|
||||
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
|
||||
command: runtime.session.command,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export * as LayerMapExample from "./layer-map.example"
|
||||
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
|
||||
/**
|
||||
* Tutorial: split global services from context-specific services.
|
||||
*
|
||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
||||
* while another part should be cached per request/project/workspace key.
|
||||
*
|
||||
* In this example:
|
||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
||||
* be provided once by the application runtime.
|
||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
||||
* cached by LayerMap for that key.
|
||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
||||
*
|
||||
* Lifetime model:
|
||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
||||
* ConfigService for exactly that context key.
|
||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
||||
* running on the old instance; the next lookup can create a fresh instance.
|
||||
*
|
||||
* Key model:
|
||||
* - Keys can be strings, structs, classes, arrays, etc.
|
||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
||||
*/
|
||||
|
||||
export type RequestContext = {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
}
|
||||
|
||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
||||
"@opencode/example/RequestContextRef",
|
||||
) {}
|
||||
|
||||
export interface ConfigServiceShape {
|
||||
readonly directory: string
|
||||
readonly workspace: string
|
||||
readonly nextUse: () => Effect.Effect<number>
|
||||
readonly which: Npm.Interface["which"]
|
||||
}
|
||||
|
||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
||||
"@opencode/example/ConfigService",
|
||||
) {}
|
||||
|
||||
const configServiceLayer = Layer.effect(
|
||||
ConfigService,
|
||||
Effect.gen(function* () {
|
||||
const context = yield* RequestContextRef
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
let useCount = 0
|
||||
|
||||
return ConfigService.of({
|
||||
directory: context.directory,
|
||||
workspace: context.workspace,
|
||||
nextUse: () => Effect.succeed(++useCount),
|
||||
which: npm.which,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
||||
lookup: (context: RequestContext) =>
|
||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
||||
idleTimeToLive: "5 minutes",
|
||||
}) {}
|
||||
|
||||
export const appLayer = ConfigServiceMap.layer
|
||||
|
||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
||||
const config = yield* ConfigService
|
||||
|
||||
return {
|
||||
directory: config.directory,
|
||||
workspace: config.workspace,
|
||||
useCount: yield* config.nextUse(),
|
||||
}
|
||||
})
|
||||
|
||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
||||
})
|
||||
|
||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
||||
@@ -13,6 +13,7 @@ export interface Interface {
|
||||
Session.Interface,
|
||||
| "get"
|
||||
| "create"
|
||||
| "list"
|
||||
| "messages"
|
||||
| "prompt"
|
||||
| "generate"
|
||||
@@ -58,6 +59,7 @@ export const layerWithCell = (cell: Cell) =>
|
||||
session: {
|
||||
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
|
||||
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
|
||||
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
|
||||
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
|
||||
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
|
||||
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
|
||||
@@ -120,6 +122,7 @@ export const providerLayerWithCell = (cell: Cell) =>
|
||||
)
|
||||
|
||||
export const layer = layerWithCell(defaultCell)
|
||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
|
||||
export namespace Module {
|
||||
export function resolve(id: string, dir: string) {
|
||||
try {
|
||||
return createRequire(path.join(dir, "package.json")).resolve(id)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,12 @@ export function getDirectory(path: string | undefined) {
|
||||
return parts.slice(0, parts.length - 1).join("/") + "/"
|
||||
}
|
||||
|
||||
export function getFileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const parts = path.split(".")
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
|
||||
const filename = getFilename(path)
|
||||
if (filename.length <= maxLength) return filename
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export * as ConfigConsoleStateV1 from "./console-state"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../../schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
||||
consoleManagedProviders: [],
|
||||
activeOrgName: undefined,
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
// Records the execution serialization a move must perform before relocating.
|
||||
const executionCalls: string[] = []
|
||||
const recordingExecution = Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
|
||||
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
MoveSession.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
ProjectDirectories.node,
|
||||
Project.node,
|
||||
Session.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
[[SessionExecution.node, recordingExecution]],
|
||||
),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.autocrlf false`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
|
||||
await $`git add tracked.txt`.cwd(directory).quiet()
|
||||
await $`git commit -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("MoveSession", () => {
|
||||
it.live("moves session changes to another project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(`${root.path}-move-destination`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
|
||||
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move",
|
||||
directory: source,
|
||||
title: "move",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
executionCalls.length = 0
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
||||
)
|
||||
|
||||
// The move stops active execution before any relocation side effect.
|
||||
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ directory: moved, path: "" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves within a checkout without transferring existing changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(path.join(source, "packages"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-nested",
|
||||
directory: source,
|
||||
title: "move nested",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const missing = yield* Session.Service.use((service) =>
|
||||
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
|
||||
)
|
||||
expect(missing._tag).toBe("Session.DestinationNotFoundError")
|
||||
yield* Effect.promise(() => fs.mkdir(destination))
|
||||
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(
|
||||
yield* db
|
||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ directory: destination, path: "packages" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves a session to another project", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const destination = abs(`${root.path}-other-project`)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
|
||||
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
|
||||
)
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
|
||||
const sessionID = Session.ID.make("ses_move_project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-project",
|
||||
directory: source,
|
||||
title: "move project",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* Session.Service.use((service) =>
|
||||
service.move({ sessionID, directory: destination }),
|
||||
)
|
||||
|
||||
expect(
|
||||
yield* db
|
||||
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
).toEqual({ projectID: destinationProjectID, directory: destination })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves nested session changes without cleaning unrelated files", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const sourceDirectory = abs(path.join(source, "packages"))
|
||||
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
|
||||
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
|
||||
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
|
||||
const destination = abs(`${root.path}-move-nested-destination`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
|
||||
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
|
||||
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
|
||||
|
||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
||||
const sessionID = Session.ID.make("ses_move_nested_checkout")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "move-nested-checkout",
|
||||
directory: sourceDirectory,
|
||||
title: "move nested checkout",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* MoveSession.Service.use((service) =>
|
||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
|
||||
"initial\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
|
||||
"staged\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
|
||||
"M packages/staged.txt\n",
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -103,6 +103,8 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
|
||||
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
|
||||
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
|
||||
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
|
||||
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
|
||||
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
|
||||
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
|
||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
@@ -29,7 +29,9 @@ export interface SessionHooks {
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
SessionApi<unknown>,
|
||||
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
"create" | "get" | "list" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
|
||||
> & {
|
||||
readonly hook: Hooks<SessionHooks>
|
||||
/** Read a session's projected message history, paginated like the HTTP message list endpoint. */
|
||||
readonly messages: MessageApi<unknown>["list"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
@@ -29,76 +30,6 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
const SessionsQueryFields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const SessionsDirectoryQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const SessionsProjectQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
|
||||
|
||||
const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: Session.ListAnchor,
|
||||
}))
|
||||
|
||||
const SessionsCursorInput = Schema.Union([
|
||||
withCursor(SessionsDirectoryQuery),
|
||||
withCursor(SessionsProjectQuery),
|
||||
withCursor(SessionsAllQuery),
|
||||
])
|
||||
const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
|
||||
const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
|
||||
const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const SessionsCursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
return {
|
||||
make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))),
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type SessionsCursor = typeof SessionsCursor.Type
|
||||
|
||||
const SessionActive = Schema.Struct({
|
||||
type: Schema.Literal("running"),
|
||||
@@ -111,28 +42,17 @@ const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
|
||||
}),
|
||||
)
|
||||
|
||||
const SessionsQueryCursor = SessionsCursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
})
|
||||
|
||||
export const SessionsQuery = Schema.Struct({
|
||||
...SessionsQueryFields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: SessionsQueryCursor.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
|
||||
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
|
||||
HttpApiGroup.make("server.session")
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.list", "/api/session", {
|
||||
query: SessionsQuery,
|
||||
query: SessionsQuery.Query,
|
||||
success: Schema.Struct({
|
||||
data: Schema.Array(Session.Info),
|
||||
cursor: Schema.Struct({
|
||||
previous: SessionsCursor.pipe(Schema.optional),
|
||||
next: SessionsCursor.pipe(Schema.optional),
|
||||
previous: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
next: SessionsQuery.Cursor.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "SessionsResponse" }),
|
||||
error: [InvalidCursorError, InvalidRequestError],
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsCursor } from "../src/groups/session.js"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
describe("SessionsCursor", () => {
|
||||
test("round trips without Node globals", async () => {
|
||||
const input = {
|
||||
workspace: undefined,
|
||||
search: "protocol",
|
||||
order: "desc" as const,
|
||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||
}
|
||||
const cursor = SessionsCursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export * as SessionMessagesCursor from "./session-messages-cursor.js"
|
||||
|
||||
import { Effect, Encoding, Result, Schema } from "effect"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
|
||||
/**
|
||||
* Shared session message pagination cursor. Lives in schema so the protocol
|
||||
* message handler and the core plugin host paginate identically. The encoded
|
||||
* cursor carries the page order, so a cursor cannot be combined with an
|
||||
* explicit order.
|
||||
*/
|
||||
|
||||
export const DefaultLimit = 50
|
||||
|
||||
export const Order = Schema.Literals(["asc", "desc"])
|
||||
export type Order = typeof Order.Type
|
||||
|
||||
const Payload = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Order,
|
||||
direction: Schema.Literals(["previous", "next"]),
|
||||
})
|
||||
export type Payload = typeof Payload.Type
|
||||
|
||||
const PayloadJson = Schema.fromJsonString(Payload)
|
||||
const encodePayload = Schema.encodeSync(PayloadJson)
|
||||
const decodePayload = Schema.decodeUnknownEffect(PayloadJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
export const make = (input: Payload) => Encoding.encodeBase64Url(encodePayload(input))
|
||||
|
||||
export const parse = (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodePayload(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
})
|
||||
|
||||
/** previous/next cursors for one returned page of messages. */
|
||||
export const page = (data: ReadonlyArray<SessionMessage.Info>, order: Order) => {
|
||||
const first = data[0]
|
||||
const last = data.at(-1)
|
||||
return {
|
||||
previous: first ? make({ id: first.id, order, direction: "previous" }) : undefined,
|
||||
next: last ? make({ id: last.id, order, direction: "next" }) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
export * as SessionsQuery from "./sessions-query.js"
|
||||
|
||||
import { DateTime, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||
import { Session } from "./session.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
|
||||
/**
|
||||
* Shared session list query and cursor codec. Lives in schema so both the
|
||||
* protocol session group and the core plugin host paginate identically.
|
||||
*/
|
||||
|
||||
export const DefaultLimit = 50
|
||||
|
||||
const ParentIDFilter = Schema.Union([
|
||||
Session.ID,
|
||||
Schema.Null.pipe(
|
||||
Schema.encodeTo(Schema.Literal("null"), {
|
||||
decode: SchemaGetter.transform(() => null),
|
||||
encode: SchemaGetter.transform(() => "null" as const),
|
||||
}),
|
||||
),
|
||||
]).annotate({
|
||||
description: "Filter by parent session. Use null to return only root sessions.",
|
||||
})
|
||||
|
||||
export const Fields = {
|
||||
workspace: Workspace.ID.pipe(Schema.optional),
|
||||
limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
|
||||
description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
|
||||
}),
|
||||
order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
|
||||
description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
|
||||
}),
|
||||
search: Schema.optional(Schema.String),
|
||||
parentID: ParentIDFilter.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
const DirectoryQuery = Schema.Struct({
|
||||
...Fields,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
|
||||
const ProjectQuery = Schema.Struct({
|
||||
...Fields,
|
||||
project: Project.ID,
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const AllQuery = Schema.Struct(Fields)
|
||||
|
||||
const withAnchor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
|
||||
schema.mapFields((fields) => ({
|
||||
...Struct.omit(fields, ["limit"]),
|
||||
anchor: Session.ListAnchor,
|
||||
}))
|
||||
|
||||
const CursorInput = Schema.Union([withAnchor(DirectoryQuery), withAnchor(ProjectQuery), withAnchor(AllQuery)])
|
||||
const CursorJson = Schema.fromJsonString(CursorInput)
|
||||
const encodeCursor = Schema.encodeSync(CursorJson)
|
||||
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
|
||||
const invalidCursor = "Invalid cursor" as const
|
||||
|
||||
type PageQuery = Omit<typeof Query.Type, "limit" | "cursor">
|
||||
|
||||
export const Cursor = Schema.String.pipe(
|
||||
Schema.brand("SessionsCursor"),
|
||||
statics((schema) => {
|
||||
const make = schema.make.bind(schema)
|
||||
const makeCursor = (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input)))
|
||||
return {
|
||||
make: makeCursor,
|
||||
parse: (input: string) =>
|
||||
Effect.suspend(() => {
|
||||
const result = Encoding.decodeBase64UrlString(input)
|
||||
return Result.isFailure(result)
|
||||
? Effect.fail(invalidCursor)
|
||||
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
|
||||
}),
|
||||
/**
|
||||
* previous/next cursors for one returned page, re-anchoring the
|
||||
* originating query at the page's first and last items.
|
||||
*/
|
||||
page: (query: PageQuery, data: ReadonlyArray<Session.Info>) => {
|
||||
const first = data[0]
|
||||
const last = data.at(-1)
|
||||
return {
|
||||
previous: first
|
||||
? makeCursor({
|
||||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.updated),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? makeCursor({
|
||||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.updated),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export const Query = Schema.Struct({
|
||||
...Fields,
|
||||
directory: AbsolutePath.pipe(Schema.optional),
|
||||
project: Project.ID.pipe(Schema.optional),
|
||||
subpath: RelativePath.pipe(Schema.optional),
|
||||
cursor: Cursor.annotate({
|
||||
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
|
||||
}).pipe(Schema.optional),
|
||||
}).annotate({ identifier: "SessionsQuery" })
|
||||
export type Query = typeof Query.Type
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { SessionsQuery } from "../src/sessions-query.js"
|
||||
import { SessionMessagesCursor } from "../src/session-messages-cursor.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
import { Session } from "../src/session.js"
|
||||
|
||||
describe("SessionsQuery.Cursor", () => {
|
||||
test("round trips without Node globals", async () => {
|
||||
const input = {
|
||||
workspace: undefined,
|
||||
search: "protocol",
|
||||
order: "desc" as const,
|
||||
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
|
||||
}
|
||||
const cursor = SessionsQuery.Cursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionsQuery.Cursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionMessagesCursor", () => {
|
||||
test("round trips", async () => {
|
||||
const input = {
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
order: "desc" as const,
|
||||
direction: "next" as const,
|
||||
}
|
||||
const cursor = SessionMessagesCursor.make(input)
|
||||
|
||||
expect(await Effect.runPromise(SessionMessagesCursor.parse(cursor))).toEqual(input)
|
||||
})
|
||||
})
|
||||
@@ -1,29 +1,10 @@
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SessionMessagesCursor } from "@opencode-ai/schema/session-messages-cursor"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "@opencode-ai/protocol/errors"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
const decodeCursor = Schema.decodeUnknownSync(Cursor)
|
||||
|
||||
const cursor = {
|
||||
encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") {
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
},
|
||||
decode(input: string) {
|
||||
return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
|
||||
},
|
||||
}
|
||||
|
||||
export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -33,15 +14,16 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
if (ctx.query.cursor && ctx.query.order !== undefined)
|
||||
return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
|
||||
catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
|
||||
})
|
||||
const decoded = ctx.query.cursor
|
||||
? yield* SessionMessagesCursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: undefined
|
||||
const order = decoded?.order ?? ctx.query.order ?? "desc"
|
||||
const messages = yield* session
|
||||
.messages({
|
||||
sessionID: ctx.params.sessionID,
|
||||
limit: ctx.query.limit ?? DefaultMessagesLimit,
|
||||
limit: ctx.query.limit ?? SessionMessagesCursor.DefaultLimit,
|
||||
order,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
@@ -66,14 +48,9 @@ export const MessageHandler = HttpApiBuilder.group(Api, "server.message", (handl
|
||||
)
|
||||
}),
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
data: messages,
|
||||
cursor: {
|
||||
previous: first ? cursor.encode(first, order, "previous") : undefined,
|
||||
next: last ? cursor.encode(last, order, "next") : undefined,
|
||||
},
|
||||
cursor: SessionMessagesCursor.page(messages, order),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
||||
import { SessionsQuery } from "@opencode-ai/schema/sessions-query"
|
||||
import {
|
||||
ConflictError,
|
||||
CommandEvaluationError,
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -31,42 +29,18 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
Effect.fn(function* (ctx) {
|
||||
const query =
|
||||
ctx.query.cursor !== undefined
|
||||
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
||||
? yield* SessionsQuery.Cursor.parse(ctx.query.cursor).pipe(
|
||||
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
||||
)
|
||||
: ctx.query
|
||||
const page = yield* session.list({
|
||||
...query,
|
||||
workspaceID: query.workspace,
|
||||
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
||||
limit: ctx.query.limit ?? SessionsQuery.DefaultLimit,
|
||||
})
|
||||
const sessions = page.data
|
||||
const first = sessions[0]
|
||||
const last = sessions.at(-1)
|
||||
return {
|
||||
data: sessions,
|
||||
cursor: {
|
||||
previous: first
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: first.id,
|
||||
time: DateTime.toEpochMillis(first.time.updated),
|
||||
direction: "previous",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
next: last
|
||||
? SessionsCursor.make({
|
||||
...query,
|
||||
anchor: {
|
||||
id: last.id,
|
||||
time: DateTime.toEpochMillis(last.time.updated),
|
||||
direction: "next",
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
},
|
||||
data: page.data,
|
||||
cursor: SessionsQuery.Cursor.page(query, page.data),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -45,7 +45,6 @@ export type {
|
||||
StatefulColor,
|
||||
} from "./types.js"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||
export { expandTheme } from "./expand.js"
|
||||
export { migrateV1 } from "./v1-migrate.js"
|
||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
|
||||
@@ -72,9 +72,9 @@ import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
import { PromptHistoryProvider } from "./prompt/history"
|
||||
import { FrecencyProvider } from "./prompt/frecency"
|
||||
import { PromptStashProvider } from "./prompt/stash"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import * as Model from "./util/model"
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiKV,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
@@ -114,6 +115,8 @@ export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<Config.Resolved, "attention">
|
||||
update?: Config.Interface["update"]
|
||||
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
||||
import { readFile } from "node:fs/promises"
|
||||
|
||||
let audio: Audio | null | undefined
|
||||
@@ -42,6 +42,10 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
||||
return current.play(sound, options)
|
||||
}
|
||||
|
||||
export function stopVoice(voice: AudioVoice) {
|
||||
return audio?.stopVoice(voice) ?? false
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
audio?.dispose()
|
||||
audio = undefined
|
||||
|
||||
@@ -43,6 +43,15 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["motion", "effects"],
|
||||
},
|
||||
{
|
||||
title: "Onboarding",
|
||||
category: "Appearance",
|
||||
path: ["hints", "onboarding"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["hints", "getting started", "guidance"],
|
||||
},
|
||||
{
|
||||
title: "Sidebar",
|
||||
category: "Session",
|
||||
|
||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => {
|
||||
const provider = providers().get(model.providerID)
|
||||
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
return {
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
providerID: model.providerID,
|
||||
providerName: provider?.name ?? model.providerID,
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
favorite,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -96,9 +96,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
if (needle) {
|
||||
return prioritizeFavorites(
|
||||
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||
)
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
@@ -162,10 +160,6 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||
}
|
||||
|
||||
export function sortModelOptions<
|
||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||
>(options: T[]) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "../prompt/stash"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/frecency"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/history"
|
||||
@@ -130,7 +130,7 @@ function formatEditorContext(selection: EditorSelection) {
|
||||
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
}
|
||||
|
||||
const drafts = new Map<string, { prompt: PromptInfo; cursor: number }>()
|
||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
|
||||
function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
|
||||
const head = parseSlashHead(input, /\s/)
|
||||
@@ -172,20 +172,6 @@ export function Prompt(props: PromptProps) {
|
||||
const exit = useExit()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const draftKey = (sessionID?: string) => sessionID ?? "new"
|
||||
const saveDraft = (sessionID?: string) => {
|
||||
const key = draftKey(sessionID)
|
||||
if (
|
||||
!store.prompt.text &&
|
||||
store.prompt.pasted.length === 0 &&
|
||||
(store.prompt.files?.length ?? 0) === 0 &&
|
||||
(store.prompt.agents?.length ?? 0) === 0
|
||||
) {
|
||||
drafts.delete(key)
|
||||
return
|
||||
}
|
||||
drafts.set(key, { prompt: structuredClone(unwrap(store.prompt)), cursor: input.cursorOffset })
|
||||
}
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const animationsEnabled = createMemo(() => config.animations ?? true)
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
@@ -575,7 +561,6 @@ export function Prompt(props: PromptProps) {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(props.sessionID))
|
||||
},
|
||||
submit() {
|
||||
void submit()
|
||||
@@ -583,10 +568,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void history.load(props.sessionID)
|
||||
const saved = drafts.get(draftKey(props.sessionID))
|
||||
const saved = stashed
|
||||
stashed = undefined
|
||||
if (store.prompt.text) return
|
||||
if (saved) {
|
||||
if (saved && saved.prompt.text) {
|
||||
input.setText(saved.prompt.text)
|
||||
setStore("prompt", saved.prompt)
|
||||
restoreExtmarksFromPrompt(saved.prompt)
|
||||
@@ -594,25 +579,10 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.sessionID,
|
||||
(sessionID, previous) => {
|
||||
saveDraft(previous)
|
||||
const saved = drafts.get(draftKey(sessionID))
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", saved?.prompt ?? emptyPrompt())
|
||||
restoreExtmarksFromPrompt(saved?.prompt ?? emptyPrompt())
|
||||
input.cursorOffset = saved?.cursor ?? 0
|
||||
void history.load(sessionID)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
saveDraft(props.sessionID)
|
||||
if (store.prompt.text) {
|
||||
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
|
||||
}
|
||||
setInputTarget(undefined)
|
||||
props.ref?.(undefined)
|
||||
})
|
||||
@@ -884,7 +854,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(props.sessionID, -1, input.plainText)
|
||||
const item = history.move(-1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -923,7 +893,7 @@ export function Prompt(props: PromptProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = history.move(props.sessionID, 1, input.plainText)
|
||||
const item = history.move(1, input.plainText)
|
||||
if (!item) return false
|
||||
input.setText(item.text)
|
||||
setStore("prompt", item)
|
||||
@@ -1147,14 +1117,13 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
if (pendingEditorSelection) editor.markSelectionSent()
|
||||
}
|
||||
history.append(sessionID, {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: currentMode,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(sessionID))
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
@@ -1304,7 +1273,7 @@ export function Prompt(props: PromptProps) {
|
||||
(store.prompt.files?.length ?? 0) > 0 ||
|
||||
(store.prompt.agents?.length ?? 0) > 0
|
||||
) {
|
||||
history.append(props.sessionID, {
|
||||
history.append({
|
||||
...store.prompt,
|
||||
mode: store.mode,
|
||||
})
|
||||
@@ -1313,7 +1282,6 @@ export function Prompt(props: PromptProps) {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", emptyPrompt())
|
||||
setStore("extmarkToPart", new Map())
|
||||
drafts.delete(draftKey(props.sessionID))
|
||||
}
|
||||
|
||||
const highlight = createMemo(() => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../prompt/stash"
|
||||
@@ -6,7 +6,6 @@ import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
seedSessionTabMotion,
|
||||
@@ -49,10 +48,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
// so the strip never flashes the pre-drag order while the write is in flight.
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
let strip: { screenX: number } | undefined
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
@@ -60,18 +55,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
)
|
||||
@@ -295,8 +279,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// keeping sloppy clicks indistinguishable from clean ones.
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
@@ -313,8 +295,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
const slot = slotAt(event.x)
|
||||
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
@@ -360,9 +341,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
fg={closeColor()}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
// The close mark only renders while hovered; without motion events a click can
|
||||
// land here first, and must select the tab instead of closing it invisibly.
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
|
||||
@@ -162,6 +162,11 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Mini transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
Schema.Struct({
|
||||
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
|
||||
}),
|
||||
).annotate({ description: "In-product guidance settings" }),
|
||||
debug: Schema.optional(
|
||||
Schema.Struct({
|
||||
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
|
||||
@@ -262,3 +267,7 @@ export function useConfig() {
|
||||
if (!value) throw new Error("ConfigProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useConfigOptional() {
|
||||
return useContext(ConfigContext)
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ export const Definitions = {
|
||||
session_compact: keybind("<leader>c", "Compact the session"),
|
||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||
session_child_cycle: keybind("right", "Go to next child session"),
|
||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||
session_parent: keybind("up", "Go to parent session"),
|
||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
@@ -306,6 +308,8 @@ export const CommandMap = {
|
||||
session_compact: "session.compact",
|
||||
session_queued_prompts: "session.queued_prompts",
|
||||
session_child_first: "session.child.first",
|
||||
session_child_cycle: "session.child.next",
|
||||
session_child_cycle_reverse: "session.child.previous",
|
||||
session_parent: "session.parent",
|
||||
session_pin_toggle: "session.pin.toggle",
|
||||
session_quick_switch_1: "session.quick_switch.1",
|
||||
|
||||
@@ -29,11 +29,9 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
|
||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||
}
|
||||
|
||||
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
|
||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
|
||||
// detect it by identity.
|
||||
if (index === -1) return { tabs, next: undefined }
|
||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
||||
return {
|
||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||
@@ -89,13 +87,9 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
|
||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||
}
|
||||
|
||||
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
|
||||
// session switch forever; the oldest entries fall off first.
|
||||
const SESSION_TAB_HISTORY_LIMIT = 100
|
||||
|
||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||
if (history.entries[history.index] === sessionID) return history
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
|
||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
||||
return { entries, index: entries.length - 1 }
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function update(mutation: (draft: TabsState) => void) {
|
||||
const scope = config.tabs?.scope ?? "global"
|
||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
|
||||
(error) => console.error("Failed to persist session tabs", error),
|
||||
() => {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,7 +222,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
function remove(sessionID: string, navigate: boolean) {
|
||||
const target = root(sessionID)
|
||||
const closed = closeSessionTab(state().tabs, target)
|
||||
if (closed.tabs === state().tabs) return
|
||||
if (closed.tabs.length === state().tabs.length) return
|
||||
const selected = navigate && current() === target
|
||||
const previous = selected
|
||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||
|
||||
@@ -194,6 +194,10 @@ export function resolveZedDbPath() {
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
export function isZedTerminal() {
|
||||
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return statSync(item).isFile()
|
||||
@@ -216,6 +220,11 @@ function zedWorkspacePaths(value: string | null) {
|
||||
return value.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||
if (byteOffset <= 0) return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function View() {
|
||||
const context = usePlugin()
|
||||
const theme = context.theme
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-lsp",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", () => <View />)
|
||||
},
|
||||
})
|
||||
@@ -141,6 +141,22 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
|
||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||
}
|
||||
|
||||
export function moveFileTreeSelectionToFile(
|
||||
rows: readonly FileTreeRow[],
|
||||
selected: number | undefined,
|
||||
offset: number,
|
||||
) {
|
||||
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
|
||||
if (fileRows.length === 0) return undefined
|
||||
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
||||
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
|
||||
const next =
|
||||
offset < 0
|
||||
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
|
||||
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
|
||||
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
|
||||
}
|
||||
|
||||
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
|
||||
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
|
||||
if (!node) return undefined
|
||||
|
||||
@@ -7,3 +7,5 @@ export const go = {
|
||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||
}
|
||||
|
||||
export const marks = "_^~,"
|
||||
|
||||
@@ -2,6 +2,7 @@ import HomeFooter from "../feature-plugins/home/footer"
|
||||
import PromptFooter from "../feature-plugins/prompt/footer"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
@@ -13,6 +14,7 @@ export const builtins = [
|
||||
PromptFooter,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import type { SessionPromptInput } from "@opencode-ai/client"
|
||||
import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
@@ -60,67 +61,56 @@ export const { use: usePromptHistory, provider: PromptHistoryProvider } = create
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const paths = useTuiPaths()
|
||||
const stores = new Map<string, { index: number; history: PromptInfo[] }>()
|
||||
const loaded = new Set<string>()
|
||||
const key = (sessionID?: string) => sessionID ?? "new"
|
||||
const historyPath = (sessionID?: string) =>
|
||||
path.join(paths.state, "prompt-history", encodeURIComponent(key(sessionID)) + ".jsonl")
|
||||
const store = (sessionID?: string) => {
|
||||
const id = key(sessionID)
|
||||
const current = stores.get(id)
|
||||
if (current) return current
|
||||
const next = { index: 0, history: [] as PromptInfo[] }
|
||||
stores.set(id, next)
|
||||
return next
|
||||
}
|
||||
const historyPath = path.join(paths.state, "prompt-history.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptHistory(await readText(historyPath).catch(() => ""))
|
||||
setStore("history", lines)
|
||||
|
||||
// Rewrite valid retained entries to self-heal corruption and enforce the limit.
|
||||
if (lines.length > 0)
|
||||
writeText(historyPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
|
||||
return {
|
||||
async load(sessionID?: string) {
|
||||
const id = key(sessionID)
|
||||
if (loaded.has(id)) return
|
||||
loaded.add(id)
|
||||
const lines = parsePromptHistory(await readText(historyPath(sessionID)).catch(() => ""))
|
||||
const current = stores.get(id)
|
||||
const history = [...lines, ...(current?.history ?? [])]
|
||||
.filter((entry, index, entries) => !isDuplicateEntry(entries[index - 1], entry))
|
||||
.slice(-MAX_HISTORY_ENTRIES)
|
||||
stores.set(id, { index: current?.index ?? 0, history })
|
||||
if (lines.length > 0)
|
||||
writeText(historyPath(sessionID), history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
|
||||
() => {},
|
||||
)
|
||||
},
|
||||
move(sessionID: string | undefined, direction: 1 | -1, input: string) {
|
||||
const state = store(sessionID)
|
||||
if (!state.history.length) return undefined
|
||||
const current = state.history.at(state.index)
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
if (!current) return undefined
|
||||
if (current.text !== input && input.length) return
|
||||
const next = state.index + direction
|
||||
if (Math.abs(next) > state.history.length || next > 0) return
|
||||
state.index = next
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length || next > 0) return
|
||||
setStore("index", next)
|
||||
if (next === 0) return emptyPrompt()
|
||||
return state.history.at(next)
|
||||
return store.history.at(next)
|
||||
},
|
||||
append(sessionID: string | undefined, item: PromptInfo) {
|
||||
const state = store(sessionID)
|
||||
append(item: PromptInfo) {
|
||||
const entry = structuredClone(unwrap(item))
|
||||
if (isDuplicateEntry(state.history.at(-1), entry)) {
|
||||
state.index = 0
|
||||
if (isDuplicateEntry(store.history.at(-1), entry)) {
|
||||
setStore("index", 0)
|
||||
return
|
||||
}
|
||||
state.history.push(entry)
|
||||
const trimmed = state.history.length > MAX_HISTORY_ENTRIES
|
||||
if (trimmed) state.history = state.history.slice(-MAX_HISTORY_ENTRIES)
|
||||
state.index = 0
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(entry)
|
||||
if (draft.history.length > MAX_HISTORY_ENTRIES) {
|
||||
draft.history = draft.history.slice(-MAX_HISTORY_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
writeText(historyPath(sessionID), state.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(
|
||||
() => {},
|
||||
)
|
||||
writeText(historyPath, store.history.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
return
|
||||
}
|
||||
appendText(historyPath(sessionID), JSON.stringify(entry) + "\n").catch(() => {})
|
||||
appendText(historyPath, JSON.stringify(entry) + "\n").catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -887,6 +887,22 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Next subagent",
|
||||
id: "session.child.next",
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
enabled: !!session()?.parentID,
|
||||
run: () => unavailable("Subagent navigation"),
|
||||
},
|
||||
{
|
||||
title: "Previous subagent",
|
||||
id: "session.child.previous",
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
enabled: !!session()?.parentID,
|
||||
run: () => unavailable("Subagent navigation"),
|
||||
},
|
||||
])
|
||||
|
||||
const commands = createMemo(() =>
|
||||
@@ -1909,6 +1925,122 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const theme = useTheme("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
.models()
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
|
||||
const final = createMemo(() => {
|
||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
if (!final()) return 0
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
|
||||
const exploration = createMemo(() => {
|
||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
||||
if (!ctx.groupExploration()) return grouped
|
||||
const runs = props.message.content
|
||||
.map((part) =>
|
||||
part.type === "tool" &&
|
||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: undefined,
|
||||
)
|
||||
.reduce<SessionMessageAssistantTool[][]>(
|
||||
(runs, part) => {
|
||||
if (part) runs[runs.length - 1].push(part)
|
||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
||||
return runs
|
||||
},
|
||||
[[]],
|
||||
)
|
||||
.filter((run) => run.length > 0)
|
||||
for (const run of runs) {
|
||||
const summary = {
|
||||
parts: run,
|
||||
active: false,
|
||||
}
|
||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={props.message.content}>
|
||||
{(content, index) => (
|
||||
<Switch>
|
||||
<Match when={content.type === "text"}>
|
||||
<TextPart
|
||||
part={content as SessionMessageAssistantText}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "reasoning"}>
|
||||
<ReasoningPart
|
||||
part={content as SessionMessageAssistantReasoning}
|
||||
message={props.message}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "tool"}>
|
||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
||||
<Show
|
||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
||||
>
|
||||
{(summary) => <ExplorationSummary {...summary()} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.background.default}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.text.feedback.error.default}
|
||||
>
|
||||
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
@@ -1924,6 +2056,40 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
||||
const theme = useTheme()
|
||||
const pathFormatter = usePathFormatter()
|
||||
const label = (part: SessionMessageAssistantTool) => {
|
||||
const input = typeof part.state.input === "string" ? {} : part.state.input
|
||||
const tool = toolDisplay(part.name)
|
||||
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
|
||||
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
|
||||
return `Grep "${stringValue(input.pattern)}"`
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<InlineToolRow
|
||||
icon="✱"
|
||||
color={theme.text.subdued}
|
||||
complete={!props.active}
|
||||
pending="Exploring"
|
||||
spinner={props.active}
|
||||
>
|
||||
{props.active ? "Exploring" : "Explored"}
|
||||
</InlineToolRow>
|
||||
<For each={props.parts}>
|
||||
{(part, index) => (
|
||||
<box paddingLeft={5}>
|
||||
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
|
||||
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
function ReasoningPart(props: {
|
||||
@@ -2788,6 +2954,10 @@ export function isBackgroundSubagent(
|
||||
return status === "completed" && metadata.status === "running"
|
||||
}
|
||||
|
||||
export function formatSubagentRetry(attempt: number, message: string) {
|
||||
return `Retrying (attempt ${attempt}) · ${message}`
|
||||
}
|
||||
|
||||
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||
|
||||
function executeCalls(value: unknown): ExecuteCall[] {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
|
||||
export type DialogAlertProps = {
|
||||
title: string
|
||||
@@ -56,3 +56,12 @@ export function DialogAlert(props: DialogAlertProps) {
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
|
||||
() => resolve(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
@@ -17,6 +17,8 @@ export type DialogConfirmProps = {
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogConfirmResult = boolean | undefined
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -91,3 +93,20 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
|
||||
return new Promise<DialogConfirmResult>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={() => resolve(true)}
|
||||
onCancel={() => resolve(false)}
|
||||
label={label}
|
||||
/>
|
||||
),
|
||||
() => resolve(undefined),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { tint } from "../theme/color"
|
||||
import { createAnimatable, tween } from "./animation"
|
||||
import { FilePath } from "./file-path"
|
||||
|
||||
// FilePath that crossfades when its value changes: the old path fades to the
|
||||
// background, the text swaps at the midpoint, and the new path fades back in.
|
||||
// The initial value renders immediately; only subsequent changes animate.
|
||||
export function FadeFilePath(props: {
|
||||
value: string | undefined
|
||||
maxWidth: number
|
||||
fg: RGBA
|
||||
bg: RGBA
|
||||
basenameFg?: RGBA
|
||||
}) {
|
||||
const config = useConfig().data
|
||||
const fade = createAnimatable(
|
||||
{ front: 1 },
|
||||
{
|
||||
enabled: () => config.animations ?? true,
|
||||
transition: tween({ duration: 0.3 }),
|
||||
},
|
||||
)
|
||||
const [text, setText] = createSignal(props.value)
|
||||
const [previous, setPrevious] = createSignal<string>()
|
||||
|
||||
createEffect((current: string | undefined) => {
|
||||
const next = props.value
|
||||
if (next === undefined || next === current) return current
|
||||
setText(next)
|
||||
if (current === undefined) return next
|
||||
setPrevious(current)
|
||||
fade.jump({ front: 0 })
|
||||
fade.animate({ front: 1 })
|
||||
return next
|
||||
}, props.value)
|
||||
|
||||
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
|
||||
const fg = createMemo(() => {
|
||||
if (previous() === undefined || fade.value().front >= 1) return props.fg
|
||||
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={display() !== undefined}>
|
||||
<FilePath
|
||||
value={display() ?? ""}
|
||||
maxWidth={props.maxWidth}
|
||||
fg={fg()}
|
||||
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -143,3 +143,40 @@ export function errorMessage(error: unknown): string {
|
||||
if (formatted) return formatted
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
export function errorData(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
type: error.name,
|
||||
message: errorMessage(error),
|
||||
stack: error.stack,
|
||||
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isRecord(error)) {
|
||||
return {
|
||||
type: typeof error,
|
||||
message: errorMessage(error),
|
||||
formatted: errorFormat(error),
|
||||
}
|
||||
}
|
||||
|
||||
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
|
||||
const value = error[key]
|
||||
if (value === undefined) return acc
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
acc[key] = value
|
||||
return acc
|
||||
}
|
||||
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
|
||||
acc[key] = value instanceof Error ? value.message : String(value)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (typeof data.message !== "string") data.message = errorMessage(error)
|
||||
if (typeof data.type !== "string") data.type = error.constructor?.name
|
||||
data.formatted = errorFormat(error)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function formatDuration(secs: number) {
|
||||
if (secs <= 0) return ""
|
||||
if (secs < 60) return `${secs}s`
|
||||
if (secs < 3600) {
|
||||
const mins = Math.floor(secs / 60)
|
||||
const remaining = secs % 60
|
||||
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
||||
}
|
||||
if (secs < 86400) {
|
||||
const hours = Math.floor(secs / 3600)
|
||||
const remaining = Math.floor((secs % 3600) / 60)
|
||||
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
||||
}
|
||||
if (secs < 604800) {
|
||||
const days = Math.floor(secs / 86400)
|
||||
return days === 1 ? "~1 day" : `~${days} days`
|
||||
}
|
||||
const weeks = Math.floor(secs / 604800)
|
||||
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
||||
}
|
||||
@@ -16,6 +16,19 @@ export function datetime(input: number): string {
|
||||
return `${localTime} · ${localDate}`
|
||||
}
|
||||
|
||||
export function todayTimeOrDateTime(input: number): string {
|
||||
const date = new Date(input)
|
||||
const now = new Date()
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
||||
|
||||
if (isToday) {
|
||||
return time(input)
|
||||
} else {
|
||||
return datetime(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function number(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + "M"
|
||||
@@ -95,4 +108,9 @@ export function truncateMiddle(str: string, maxLength: number = 35): string {
|
||||
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
||||
}
|
||||
|
||||
export function pluralize(count: number, singular: string, plural: string): string {
|
||||
const template = count === 1 ? singular : plural
|
||||
return template.replace("{}", count.toString())
|
||||
}
|
||||
|
||||
export * as Locale from "./locale"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { logo } from "../logo"
|
||||
const logo = {
|
||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
||||
}
|
||||
|
||||
const reset = "\x1b[0m"
|
||||
const bold = "\x1b[1m"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { parsePatch } from "diff"
|
||||
|
||||
export function getRevertDiffFiles(diffText: string) {
|
||||
if (!diffText) return []
|
||||
|
||||
try {
|
||||
return parsePatch(diffText).map((patch) => {
|
||||
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
|
||||
return {
|
||||
filename: filename.replace(/^[ab]\//, ""),
|
||||
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
|
||||
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("prioritizeFavorites", () => {
|
||||
test("moves favorites first while preserving fuzzy result order", () => {
|
||||
const prioritized = prioritizeFavorites([
|
||||
{ title: "Best match", favorite: false },
|
||||
{ title: "Favorite match", favorite: true },
|
||||
{ title: "Second best match", favorite: false },
|
||||
{ title: "Second favorite match", favorite: true },
|
||||
])
|
||||
|
||||
expect(prioritized.map((model) => model.title)).toEqual([
|
||||
"Favorite match",
|
||||
"Second favorite match",
|
||||
"Best match",
|
||||
"Second best match",
|
||||
])
|
||||
})
|
||||
})
|
||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("sortModelOptions", () => {
|
||||
test("orders opencode models before other providers", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { For } from "solid-js"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import {
|
||||
formatSubagentRetry,
|
||||
InlineToolRow,
|
||||
isBackgroundSubagent,
|
||||
parseApplyPatchFiles,
|
||||
@@ -197,6 +198,10 @@ describe("TUI inline tool wrapping", () => {
|
||||
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
||||
})
|
||||
|
||||
test("keeps retry status ahead of wrapping messages", () => {
|
||||
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
|
||||
})
|
||||
|
||||
test("labels only detached or async subagents as background", () => {
|
||||
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
|
||||
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
|
||||
|
||||
@@ -80,11 +80,6 @@ describe("session tabs", () => {
|
||||
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
|
||||
})
|
||||
|
||||
test("closing an unknown session returns the same tabs reference", () => {
|
||||
const tabs = [{ sessionID: "a" }, { sessionID: "b" }]
|
||||
expect(closeSessionTab(tabs, "missing").tabs).toBe(tabs)
|
||||
})
|
||||
|
||||
test("cycles through a filtered tab set in either direction", () => {
|
||||
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
|
||||
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
|
||||
@@ -127,15 +122,6 @@ describe("session tabs", () => {
|
||||
expect(recordSessionTabHistory(history, "b")).toBe(history)
|
||||
})
|
||||
|
||||
test("drops the oldest history entries beyond the limit", () => {
|
||||
const sessions = Array.from({ length: 150 }, (_, index) => `session-${index}`)
|
||||
const history = sessions.reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
||||
|
||||
expect(history.entries.length).toBe(100)
|
||||
expect(history.entries[0]).toBe("session-50")
|
||||
expect(history.entries[history.index]).toBe("session-149")
|
||||
})
|
||||
|
||||
test("returns to the latest history entry when no tab is active", () => {
|
||||
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
|
||||
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdtempSync, readdirSync, rmSync, watch } from "fs"
|
||||
import { mkdtempSync, rmSync, watch } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
@@ -25,32 +25,8 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||
}
|
||||
}
|
||||
|
||||
// State directories are removed after the whole suite instead of per test: persistence writes are
|
||||
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
|
||||
const stateDirs: string[] = []
|
||||
|
||||
afterAll(async () => {
|
||||
for (const dir of stateDirs) {
|
||||
// Drain any lock still held by a late write before deleting the tree beneath it.
|
||||
await wait(() => {
|
||||
try {
|
||||
return readdirSync(path.join(dir, "test", "locks")).length === 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}).catch(() => undefined)
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function stateDir(prefix: string) {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), prefix))
|
||||
stateDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
|
||||
const state = options?.state ?? stateDir("opencode-session-tabs-")
|
||||
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${initialSessionID}`) return
|
||||
@@ -108,6 +84,7 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
destroy() {
|
||||
app.renderer.destroy()
|
||||
if (!options?.state) rmSync(state, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -128,7 +105,7 @@ test("stores session tabs globally by default", async () => {
|
||||
})
|
||||
|
||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||
const state = stateDir("opencode-session-tabs-shared-")
|
||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
|
||||
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||
|
||||
@@ -167,6 +144,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
||||
} finally {
|
||||
titled?.destroy()
|
||||
untitled?.destroy()
|
||||
rmSync(state, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
movePatchFileIndex,
|
||||
orderedPatchFileIndexes,
|
||||
@@ -218,6 +219,25 @@ describe("diff viewer file tree utilities", () => {
|
||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("moves file selection relative to the highlighted row", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
||||
)
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
const index = rows.find((row) => row.name === "index.ts")!
|
||||
const readme = rows.find((row) => row.name === "README.md")!
|
||||
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
|
||||
})
|
||||
|
||||
test("selects a file tree node and expands its parents for a patch file", () => {
|
||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||
const selection = fileTreeFileSelection(tree, 1)
|
||||
|
||||
@@ -46,7 +46,7 @@ test("legacy page key aliases compile as page keys", async () => {
|
||||
|
||||
test("formats navigation keys as arrows", async () => {
|
||||
let read = () => ({}) as Record<string, string>
|
||||
const commands = ["session.parent", "session.child.first"]
|
||||
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
|
||||
|
||||
function Harness() {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -68,6 +68,8 @@ test("formats navigation keys as arrows", async () => {
|
||||
expect(read()).toEqual({
|
||||
"session.parent": "↑",
|
||||
"session.child.first": "↓",
|
||||
"session.child.previous": "←",
|
||||
"session.child.next": "→",
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -3,10 +3,6 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
|
||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
let ready!: () => void
|
||||
const promptReady = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
const events = input.events ?? []
|
||||
const commits = input.commits ?? []
|
||||
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
||||
@@ -18,7 +14,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
ready()
|
||||
return () => prompts.delete(fn)
|
||||
},
|
||||
onClose(fn) {
|
||||
@@ -55,12 +50,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
||||
events,
|
||||
commits,
|
||||
calls,
|
||||
promptReady,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
if (prompts.size === 0) return false
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,6 @@ describe("run interactive runtime", () => {
|
||||
const api = ui.api
|
||||
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
|
||||
const catalogLoaded = defer<void>()
|
||||
const defaultModelReloaded = defer<void>()
|
||||
const modelShown = defer<void>()
|
||||
const turnStarted = defer<void>()
|
||||
const model = catalogModel({
|
||||
id: "resolved",
|
||||
providerID: "test",
|
||||
@@ -72,17 +69,7 @@ describe("run interactive runtime", () => {
|
||||
providers: [catalogProvider("test", "Test Provider")],
|
||||
models: [model],
|
||||
})
|
||||
let defaultModelCalls = 0
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
|
||||
defaultModelCalls++
|
||||
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
|
||||
return selected.promise
|
||||
})
|
||||
const emit = api.event.bind(api)
|
||||
api.event = (event) => {
|
||||
emit(event)
|
||||
if (event.type === "model") modelShown.resolve()
|
||||
}
|
||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
@@ -123,7 +110,6 @@ describe("run interactive runtime", () => {
|
||||
runPromptTurn: async (input) => {
|
||||
turnAgent = input.agent
|
||||
turnModel = input.model
|
||||
turnStarted.resolve()
|
||||
api.close()
|
||||
},
|
||||
queuePromptTurn: async () => {},
|
||||
@@ -147,8 +133,8 @@ describe("run interactive runtime", () => {
|
||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||
data: model,
|
||||
})
|
||||
await defaultModelReloaded.promise
|
||||
await modelShown.promise
|
||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
||||
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
|
||||
expect(events).toContainEqual({
|
||||
type: "model",
|
||||
model: "Resolved Model · Test Provider",
|
||||
@@ -156,9 +142,8 @@ describe("run interactive runtime", () => {
|
||||
})
|
||||
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
|
||||
lifecycle.onAgentSelect?.("review")
|
||||
await ui.promptReady
|
||||
expect(ui.submit("hello")).toBe(true)
|
||||
await turnStarted.promise
|
||||
ui.submit("hello")
|
||||
while (!turnModel) await Bun.sleep(0)
|
||||
expect(turnAgent).toBe("review")
|
||||
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
|
||||
await task
|
||||
|
||||
@@ -27,15 +27,11 @@ test("down rejects at the newest history item with an empty prompt", async () =>
|
||||
))
|
||||
try {
|
||||
await app.renderOnce()
|
||||
history!.append("ses_one", { text: "previous", files: [], agents: [], pasted: [] })
|
||||
history!.append({ text: "previous", files: [], agents: [], pasted: [] })
|
||||
|
||||
expect(history!.move("ses_one", 1, "")).toBeUndefined()
|
||||
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
|
||||
expect(history!.move("ses_one", 1, "previous")?.text).toBe("")
|
||||
|
||||
history!.append("ses_two", { text: "other", files: [], agents: [], pasted: [] })
|
||||
expect(history!.move("ses_two", -1, "")?.text).toBe("other")
|
||||
expect(history!.move("ses_one", -1, "")?.text).toBe("previous")
|
||||
expect(history!.move(1, "")).toBeUndefined()
|
||||
expect(history!.move(-1, "")?.text).toBe("previous")
|
||||
expect(history!.move(1, "previous")?.text).toBe("")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { errorFormat, errorMessage } from "../../src/util/error"
|
||||
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
|
||||
|
||||
describe("util.error", () => {
|
||||
test("formats native Error instances", () => {
|
||||
const err = new Error("boom")
|
||||
expect(errorMessage(err)).toBe("boom")
|
||||
expect(errorFormat(err)).toContain("boom")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.type).toBe("Error")
|
||||
expect(data.message).toBe("boom")
|
||||
expect(String(data.formatted)).toContain("boom")
|
||||
})
|
||||
|
||||
test("extracts message from record-like values", () => {
|
||||
const err = { message: "bad input", code: "E_BAD" }
|
||||
expect(errorMessage(err)).toBe("bad input")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.message).toBe("bad input")
|
||||
expect(data.code).toBe("E_BAD")
|
||||
})
|
||||
|
||||
test("never returns bare {} for opaque object errors", () => {
|
||||
@@ -32,5 +41,9 @@ describe("util.error", () => {
|
||||
}
|
||||
|
||||
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
|
||||
|
||||
const data = errorData(err)
|
||||
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
|
||||
expect(String(data.formatted)).toContain("ResolveMessage")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { formatDuration } from "../../src/util/format"
|
||||
|
||||
describe("util.format", () => {
|
||||
describe("formatDuration", () => {
|
||||
test("returns empty string for zero or negative values", () => {
|
||||
expect(formatDuration(0)).toBe("")
|
||||
expect(formatDuration(-1)).toBe("")
|
||||
expect(formatDuration(-100)).toBe("")
|
||||
})
|
||||
|
||||
test("formats seconds under a minute", () => {
|
||||
expect(formatDuration(1)).toBe("1s")
|
||||
expect(formatDuration(30)).toBe("30s")
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
})
|
||||
|
||||
test("formats minutes under an hour", () => {
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(61)).toBe("1m 1s")
|
||||
expect(formatDuration(90)).toBe("1m 30s")
|
||||
expect(formatDuration(120)).toBe("2m")
|
||||
expect(formatDuration(330)).toBe("5m 30s")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
})
|
||||
|
||||
test("formats hours under a day", () => {
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(3660)).toBe("1h 1m")
|
||||
expect(formatDuration(7200)).toBe("2h")
|
||||
expect(formatDuration(8100)).toBe("2h 15m")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
})
|
||||
|
||||
test("formats days under a week", () => {
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(172800)).toBe("~2 days")
|
||||
expect(formatDuration(259200)).toBe("~3 days")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
})
|
||||
|
||||
test("formats weeks", () => {
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
expect(formatDuration(1209600)).toBe("~2 weeks")
|
||||
expect(formatDuration(1609200)).toBe("~2 weeks")
|
||||
})
|
||||
|
||||
test("handles boundary values correctly", () => {
|
||||
expect(formatDuration(59)).toBe("59s")
|
||||
expect(formatDuration(60)).toBe("1m")
|
||||
expect(formatDuration(3599)).toBe("59m 59s")
|
||||
expect(formatDuration(3600)).toBe("1h")
|
||||
expect(formatDuration(86399)).toBe("23h 59m")
|
||||
expect(formatDuration(86400)).toBe("~1 day")
|
||||
expect(formatDuration(604799)).toBe("~6 days")
|
||||
expect(formatDuration(604800)).toBe("~1 week")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRevertDiffFiles } from "../../src/util/revert-diff"
|
||||
|
||||
describe("revert diff", () => {
|
||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1 @@
|
||||
+new content
|
||||
diff --git a/old.txt b/old.txt
|
||||
deleted file mode 100644
|
||||
index 3b18e51..0000000
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-old content
|
||||
`)
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: "new.txt",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filename: "old.txt",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -177,7 +177,7 @@ and plugin options.
|
||||
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
|
||||
| `ctx.plugin` | `list` currently active plugin IDs |
|
||||
| `ctx.reference` | `list`, `transform`, `reload` |
|
||||
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||
| `ctx.session` | `create`, `get`, `list`, `messages`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
|
||||
| `ctx.skill` | `list`, `transform`, `reload` |
|
||||
| `ctx.tool` | `transform` and `hook` |
|
||||
| `ctx.aisdk` | `hook` |
|
||||
|
||||
Reference in New Issue
Block a user