refactor(tui): migrate shared data to v2 API
This commit is contained in:
@@ -5,6 +5,8 @@ import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { createRoutes } from "@opencode-ai/server/routes"
|
||||
import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
@@ -15,7 +17,14 @@ export default Runtime.handler(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
const password = yield* daemon.password()
|
||||
const address = yield* listen(input.hostname, input.port, password)
|
||||
yield* Effect.tryPromise(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: HttpServer.formatAddress(address),
|
||||
headers: ServerAuth.headers({ password }),
|
||||
}).v2.location.get(undefined, { throwOnError: true }),
|
||||
)
|
||||
if (input.register) yield* daemon.register(address)
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
return yield* Effect.never
|
||||
|
||||
@@ -8,6 +8,6 @@ export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const url = yield* (yield* Daemon.Service).status()
|
||||
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||
process.stdout.write((url ? url : "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,17 +2,23 @@ import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
let disposeSlots: (() => void) | undefined
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
async start(input) {
|
||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
||||
},
|
||||
async dispose() {
|
||||
disposeSlots?.()
|
||||
},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { createBuiltinPlugins, type BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
|
||||
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
export type InternalTuiPlugin = BuiltinTuiPlugin
|
||||
|
||||
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
|
||||
return createBuiltinPlugins({
|
||||
experimentalEventSystem: flags.experimentalEventSystem,
|
||||
})
|
||||
export function internalTuiPlugins(): InternalTuiPlugin[] {
|
||||
return createBuiltinPlugins()
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,7 @@ async function load(input: {
|
||||
if (Flag.OPENCODE_PURE && pluginOrigins.length) {
|
||||
}
|
||||
|
||||
for (const item of internalTuiPlugins(flags)) {
|
||||
for (const item of internalTuiPlugins()) {
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
addPluginEntry(next, {
|
||||
|
||||
@@ -646,6 +646,7 @@ const scenarios: Scenario[] = [
|
||||
object(body)
|
||||
check(body.healthy === true, "v2 server should report healthy")
|
||||
}),
|
||||
http.protected.get("/api/location", "v2.location.get").json(200, object),
|
||||
http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)),
|
||||
http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)),
|
||||
@@ -698,6 +699,14 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, data(array)),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/question", "v2.session.question.list")
|
||||
.seeded((ctx) => ctx.session({ title: "Question list owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, data(array)),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
|
||||
@@ -807,6 +816,14 @@ const scenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.status(400, undefined, "none"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}", "v2.session.get")
|
||||
.seeded((ctx) => ctx.session({ title: "Session get" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, data(object)),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/context", "v2.session.context")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -274,6 +274,8 @@ import type {
|
||||
V2FsReadResponses,
|
||||
V2HealthGetErrors,
|
||||
V2HealthGetResponses,
|
||||
V2LocationGetErrors,
|
||||
V2LocationGetResponses,
|
||||
V2ModelListErrors,
|
||||
V2ModelListResponses,
|
||||
V2PermissionRequestListErrors,
|
||||
@@ -294,6 +296,8 @@ import type {
|
||||
V2SessionCompactResponses,
|
||||
V2SessionContextErrors,
|
||||
V2SessionContextResponses,
|
||||
V2SessionGetErrors,
|
||||
V2SessionGetResponses,
|
||||
V2SessionListErrors,
|
||||
V2SessionListResponses,
|
||||
V2SessionMessagesErrors,
|
||||
@@ -304,6 +308,8 @@ import type {
|
||||
V2SessionPermissionReplyResponses,
|
||||
V2SessionPromptErrors,
|
||||
V2SessionPromptResponses,
|
||||
V2SessionQuestionListErrors,
|
||||
V2SessionQuestionListResponses,
|
||||
V2SessionQuestionRejectErrors,
|
||||
V2SessionQuestionRejectResponses,
|
||||
V2SessionQuestionReplyErrors,
|
||||
@@ -5013,6 +5019,30 @@ export class Health extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Location extends HeyApiClient {
|
||||
/**
|
||||
* Get location
|
||||
*
|
||||
* Resolve the requested location or the server default location.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2LocationGetResponses, V2LocationGetErrors, ThrowOnError>({
|
||||
url: "/api/location",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Agent extends HeyApiClient {
|
||||
/**
|
||||
* List agents
|
||||
@@ -5106,6 +5136,29 @@ export class Permission2 extends HeyApiClient {
|
||||
}
|
||||
|
||||
export class Question2 extends HeyApiClient {
|
||||
/**
|
||||
* List session question requests
|
||||
*
|
||||
* Retrieve pending question requests owned by a session.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
|
||||
return (options?.client ?? this.client).get<
|
||||
V2SessionQuestionListResponses,
|
||||
V2SessionQuestionListErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/question",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to pending question request
|
||||
*
|
||||
@@ -5225,6 +5278,25 @@ export class Session3 extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session
|
||||
*
|
||||
* Retrieve a session by ID.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
|
||||
return (options?.client ?? this.client).get<V2SessionGetResponses, V2SessionGetErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message
|
||||
*
|
||||
@@ -5779,6 +5851,11 @@ export class V2 extends HeyApiClient {
|
||||
return (this._health ??= new Health({ client: this.client }))
|
||||
}
|
||||
|
||||
private _location?: Location
|
||||
get location(): Location {
|
||||
return (this._location ??= new Location({ client: this.client }))
|
||||
}
|
||||
|
||||
private _agent?: Agent
|
||||
get agent(): Agent {
|
||||
return (this._agent ??= new Agent({ client: this.client }))
|
||||
|
||||
@@ -2737,18 +2737,18 @@ export type InvalidCursorError = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConflictError = {
|
||||
_tag: "ConflictError"
|
||||
message: string
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export type SessionNotFoundError = {
|
||||
_tag: "SessionNotFoundError"
|
||||
sessionID: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ConflictError = {
|
||||
_tag: "ConflictError"
|
||||
message: string
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
_tag: "ServiceUnavailableError"
|
||||
message: string
|
||||
@@ -9453,6 +9453,40 @@ export type V2HealthGetResponses = {
|
||||
|
||||
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
||||
|
||||
export type V2LocationGetData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
location?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
}
|
||||
url: "/api/location"
|
||||
}
|
||||
|
||||
export type V2LocationGetErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
}
|
||||
|
||||
export type V2LocationGetError = V2LocationGetErrors[keyof V2LocationGetErrors]
|
||||
|
||||
export type V2LocationGetResponses = {
|
||||
/**
|
||||
* Location.Info
|
||||
*/
|
||||
200: LocationInfo
|
||||
}
|
||||
|
||||
export type V2LocationGetResponse = V2LocationGetResponses[keyof V2LocationGetResponses]
|
||||
|
||||
export type V2AgentListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -9531,6 +9565,43 @@ export type V2SessionListResponses = {
|
||||
|
||||
export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses]
|
||||
|
||||
export type V2SessionGetData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}"
|
||||
}
|
||||
|
||||
export type V2SessionGetErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionGetError = V2SessionGetErrors[keyof V2SessionGetErrors]
|
||||
|
||||
export type V2SessionGetResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: SessionV2Info
|
||||
}
|
||||
}
|
||||
|
||||
export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses]
|
||||
|
||||
export type V2SessionPromptData = {
|
||||
body: {
|
||||
id?: string
|
||||
@@ -10310,6 +10381,43 @@ export type V2QuestionRequestListResponses = {
|
||||
|
||||
export type V2QuestionRequestListResponse = V2QuestionRequestListResponses[keyof V2QuestionRequestListResponses]
|
||||
|
||||
export type V2SessionQuestionListData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: never
|
||||
url: "/api/session/{sessionID}/question"
|
||||
}
|
||||
|
||||
export type V2SessionQuestionListErrors = {
|
||||
/**
|
||||
* InvalidRequestError
|
||||
*/
|
||||
400: InvalidRequestError
|
||||
/**
|
||||
* UnauthorizedError
|
||||
*/
|
||||
401: UnauthorizedError
|
||||
/**
|
||||
* SessionNotFoundError
|
||||
*/
|
||||
404: SessionNotFoundError
|
||||
}
|
||||
|
||||
export type V2SessionQuestionListError = V2SessionQuestionListErrors[keyof V2SessionQuestionListErrors]
|
||||
|
||||
export type V2SessionQuestionListResponses = {
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
200: {
|
||||
data: Array<QuestionV2Request>
|
||||
}
|
||||
}
|
||||
|
||||
export type V2SessionQuestionListResponse = V2SessionQuestionListResponses[keyof V2SessionQuestionListResponses]
|
||||
|
||||
export type V2SessionQuestionReplyData = {
|
||||
body: QuestionV2Reply
|
||||
path: {
|
||||
|
||||
@@ -14,9 +14,11 @@ import { HealthGroup } from "./groups/health"
|
||||
import { QuestionGroup } from "./groups/question"
|
||||
import { ReferenceGroup } from "./groups/reference"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { LocationGroup } from "./groups/location"
|
||||
|
||||
export const Api = HttpApi.make("server")
|
||||
.add(HealthGroup)
|
||||
.add(LocationGroup)
|
||||
.add(AgentGroup)
|
||||
.add(SessionGroup)
|
||||
.add(MessageGroup)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
@@ -54,6 +54,23 @@ export class LocationMiddleware extends HttpApiMiddleware.Service<
|
||||
}
|
||||
>()("@opencode/HttpApiLocation") {}
|
||||
|
||||
export const LocationGroup = HttpApiGroup.make("server.location")
|
||||
.add(
|
||||
HttpApiEndpoint.get("location.get", "/api/location", {
|
||||
query: LocationQuery,
|
||||
success: Location.Info,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.location.get",
|
||||
summary: "Get location",
|
||||
description: "Resolve the requested location or the server default location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(LocationMiddleware)
|
||||
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
||||
|
||||
@@ -24,6 +24,21 @@ export const QuestionGroup = HttpApiGroup.make("server.question")
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
|
||||
.middleware(LocationMiddleware)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(QuestionV2.Request) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(SessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.list",
|
||||
summary: "List session question requests",
|
||||
description: "Retrieve pending question requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
|
||||
params: { sessionID: SessionV2.ID, requestID: QuestionV2.ID },
|
||||
|
||||
@@ -105,6 +105,21 @@ export const SessionGroup = HttpApiGroup.make("server.session")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.get", "/api/session/:sessionID", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
success: Schema.Struct({ data: SessionV2.Info }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(SessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.get",
|
||||
summary: "Get session",
|
||||
description: "Retrieve a session by ID.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionV2.ID },
|
||||
|
||||
@@ -18,9 +18,11 @@ import { HealthHandler } from "./handlers/health"
|
||||
import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local"
|
||||
import { LocationHandler } from "./handlers/location"
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
LocationHandler,
|
||||
AgentHandler,
|
||||
SessionHandler,
|
||||
MessageHandler,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
|
||||
handlers.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const location = yield* Location.Service
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -29,6 +29,13 @@ export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (han
|
||||
return yield* response((yield* QuestionV2.Service).list())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const requests = yield* (yield* QuestionV2.Service).list()
|
||||
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.reply",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -61,6 +61,23 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
return {
|
||||
data: yield* session.get(ctx.params.sessionID).pipe(
|
||||
Effect.catchTag(
|
||||
"Session.NotFoundError",
|
||||
(error) =>
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
+92
-123
@@ -5,7 +5,6 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ClipboardProvider, useClipboard } from "./context/clipboard"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import { EpilogueProvider } from "./context/epilogue"
|
||||
import * as Selection from "./util/selection"
|
||||
import { createCliRenderer, MouseButton, type CliRenderer } from "@opentui/core"
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
onCleanup,
|
||||
batch,
|
||||
Show,
|
||||
on,
|
||||
} from "solid-js"
|
||||
import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime"
|
||||
import { DialogProvider, useDialog } from "./ui/dialog"
|
||||
@@ -81,7 +79,6 @@ import { createTuiAttention } from "./attention"
|
||||
import * as TuiAudio from "./audio"
|
||||
import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-win32"
|
||||
import { destroyRenderer } from "./util/renderer"
|
||||
import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
|
||||
const appGlobalBindingCommands = [
|
||||
"session.list",
|
||||
@@ -177,8 +174,8 @@ function isVersionGreater(left: string, right: string) {
|
||||
|
||||
export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
const global = yield* Global.Service
|
||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||
yield* Effect.scoped(
|
||||
const epilogue = { value: undefined as string | undefined }
|
||||
const output = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* Effect.acquireRelease(
|
||||
Effect.tryPromise(() =>
|
||||
@@ -196,10 +193,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
},
|
||||
}),
|
||||
),
|
||||
(renderer) =>
|
||||
Effect.sync(() => {
|
||||
destroyRenderer(renderer)
|
||||
}),
|
||||
(renderer) => Effect.sync(() => destroyRenderer(renderer)),
|
||||
)
|
||||
win32DisableProcessedInput()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
@@ -217,7 +211,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(TuiAudio.dispose))
|
||||
const shutdown = yield* Deferred.make<unknown>()
|
||||
const shutdown = yield* Deferred.make<void>()
|
||||
const onSighup = () => destroyRenderer(renderer)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => process.on("SIGHUP", onSighup)),
|
||||
@@ -234,116 +228,103 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
|
||||
await render(() => {
|
||||
return (
|
||||
<ExitProvider
|
||||
exit={(reason) => {
|
||||
if (renderer.isDestroyed) return
|
||||
exit.reason = reason
|
||||
destroyRenderer(renderer)
|
||||
}}
|
||||
>
|
||||
<EpilogueProvider set={(value) => (exit.epilogue = value)}>
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
<ErrorBoundary fallback={(error, reset) => <ErrorComponent error={error} reset={reset} mode={mode} />}>
|
||||
<TuiPathsProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY ? "wayland" : process.env.DISPLAY ? "x11" : undefined,
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
cwd: process.cwd(),
|
||||
home: global.home,
|
||||
state: global.state,
|
||||
worktree: global.data + "/worktree",
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_ROUTE ? JSON.parse(process.env.OPENCODE_ROUTE) : undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</EpilogueProvider>
|
||||
</ExitProvider>
|
||||
<ClipboardProvider>
|
||||
<EpilogueProvider set={(value) => (epilogue.value = value)}>
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<ArgsProvider {...input.args}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TuiConfigProvider config={input.config}>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<SDKProvider
|
||||
url={input.url}
|
||||
directory={input.directory}
|
||||
fetch={input.fetch}
|
||||
headers={input.headers}
|
||||
events={input.events}
|
||||
>
|
||||
<ProjectProvider>
|
||||
<SyncProvider>
|
||||
<DataProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<App
|
||||
onSnapshot={input.onSnapshot}
|
||||
pluginHost={input.pluginHost}
|
||||
/>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</SyncProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</PluginRuntimeProvider>
|
||||
</TuiConfigProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ArgsProvider>
|
||||
</OpencodeKeymapProvider>
|
||||
</EpilogueProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}, renderer)
|
||||
})
|
||||
yield* Deferred.await(shutdown)
|
||||
return epilogue.value
|
||||
}),
|
||||
)
|
||||
yield* Effect.sync(() => {
|
||||
win32FlushInputBuffer()
|
||||
if (exit.reason !== undefined)
|
||||
process.stderr.write((cliErrorMessage(exit.reason) ?? errorFormat(exit.reason)) + "\n")
|
||||
if (exit.epilogue) process.stdout.write(exit.epilogue + "\n")
|
||||
if (output) process.stdout.write(output + "\n")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -364,7 +345,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
const { theme, mode, setMode, locked, lock, unlock } = themeState
|
||||
const sync = useSync()
|
||||
const project = useProject()
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
const pluginRuntime = usePluginRuntime()
|
||||
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
|
||||
@@ -522,17 +502,6 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.status === "complete" && sync.data.provider.length === 0,
|
||||
(isEmpty, wasEmpty) => {
|
||||
// only trigger when we transition into an empty-provider state
|
||||
if (!isEmpty || wasEmpty) return
|
||||
dialog.replace(() => <DialogProviderList />)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const connected = useConnected()
|
||||
const currentWorktreeWorkspace = createMemo(() => {
|
||||
const workspaceID = project.workspace.current()
|
||||
@@ -805,7 +774,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
title: "Exit the app",
|
||||
slashName: "exit",
|
||||
slashAliases: ["quit", "q"],
|
||||
run: () => exit(),
|
||||
run: () => destroyRenderer(renderer),
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
@@ -1039,7 +1008,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
|
||||
)
|
||||
|
||||
void exit()
|
||||
destroyRenderer(renderer)
|
||||
})
|
||||
|
||||
const plugin = createMemo(() => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "../context/local"
|
||||
import { useSync } from "../context/sync"
|
||||
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
|
||||
import { map, pipe, filter, sortBy, take } from "remeda"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useData } from "../context/data"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
@@ -29,19 +29,21 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
function toOptions(items: typeof favorites, category: string) {
|
||||
if (!showSections) return []
|
||||
return items.flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)
|
||||
const provider = data.location.provider.list()?.find((provider) => provider.id === item.providerID)
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
const model = data.location.model
|
||||
.list()
|
||||
?.find((model) => model.providerID === item.providerID && model.id === item.modelID)
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: { providerID: provider.id, modelID: model.id },
|
||||
title: model.name ?? item.modelID,
|
||||
title: model.name,
|
||||
description: provider.name,
|
||||
category,
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
footer: model.cost[0]?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
onSelect(provider.id, model.id)
|
||||
},
|
||||
@@ -59,42 +61,45 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
data.location.model.list() ?? [],
|
||||
filter((model) => model.status !== "deprecated"),
|
||||
filter((model) => (props.providerID ? model.providerID === props.providerID : true)),
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
||||
map(([model, info]) => ({
|
||||
value: { providerID: provider.id, modelID: model },
|
||||
title: info.name ?? model,
|
||||
releaseDate: info.release_date,
|
||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(provider.id, model)
|
||||
},
|
||||
})),
|
||||
filter((x) => {
|
||||
if (!showSections) return true
|
||||
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
|
||||
return false
|
||||
if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
|
||||
return false
|
||||
return true
|
||||
}),
|
||||
(options) => sortModelOptions(options, props.providerID !== undefined),
|
||||
),
|
||||
(model) => model.providerID !== "opencode",
|
||||
(model) => data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name ?? "",
|
||||
[(model) => model.time.released, "desc"],
|
||||
),
|
||||
map((model) => ({
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected()
|
||||
? data.location.provider.list()?.find((provider) => provider.id === model.providerID)?.name
|
||||
: undefined,
|
||||
disabled: !model.enabled || (model.providerID === "opencode" && model.id.includes("-nano")),
|
||||
footer: model.cost[0]?.input === 0 && model.providerID === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(model.providerID, model.id)
|
||||
},
|
||||
})),
|
||||
filter((option) => {
|
||||
if (!showSections) return true
|
||||
if (
|
||||
favorites.some(
|
||||
(item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID,
|
||||
)
|
||||
)
|
||||
return false
|
||||
if (
|
||||
recents.some((item) => item.providerID === option.value.providerID && item.modelID === option.value.modelID)
|
||||
)
|
||||
return false
|
||||
return true
|
||||
}),
|
||||
(options) => sortModelOptions(options, props.providerID !== undefined),
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
@@ -119,7 +124,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
})
|
||||
|
||||
const provider = createMemo(() =>
|
||||
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
|
||||
props.providerID ? data.location.provider.list()?.find((item) => item.id === props.providerID) : null,
|
||||
)
|
||||
|
||||
const title = createMemo(() => {
|
||||
@@ -172,7 +177,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function sortModelOptions<T extends { footer?: string; releaseDate: string; title: string }>(
|
||||
export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>(
|
||||
options: T[],
|
||||
newestFirst: boolean,
|
||||
) {
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function warpWorkspaceSession(input: {
|
||||
|
||||
input.project.workspace.set(input.workspaceID)
|
||||
|
||||
await input.sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
await input.sync.bootstrap()
|
||||
|
||||
const dir = input.project.instance.directory() || input.sync.path.directory
|
||||
if (dir) {
|
||||
|
||||
@@ -82,7 +82,7 @@ export function DialogWorkspaceList() {
|
||||
route.navigate({ type: "home" })
|
||||
}
|
||||
await project.workspace.sync()
|
||||
await sync.bootstrap({ fatal: false }).catch(() => undefined)
|
||||
await sync.bootstrap()
|
||||
setRemoving(undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export function Autocomplete(props: {
|
||||
const editor = useEditorContext()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const syncV2 = useData()
|
||||
const data = useData()
|
||||
const project = useProject()
|
||||
const slashes = useCommandSlashes()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
@@ -273,14 +273,6 @@ export function Autocomplete(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const referenceMatch = createMemo(() => {
|
||||
if (!store.visible || store.visible === "/") return
|
||||
const { baseQuery } = extractLineRange(search())
|
||||
const slash = baseQuery.indexOf("/")
|
||||
const alias = slash === -1 ? baseQuery : baseQuery.slice(0, slash)
|
||||
return syncV2.data.reference.find((item) => !item.hidden && item.name === alias)
|
||||
})
|
||||
|
||||
function normalizeMentionPath(filePath: string) {
|
||||
const baseDir = sync.path.directory || paths.cwd
|
||||
const absolute = path.resolve(filePath)
|
||||
@@ -311,15 +303,12 @@ export function Autocomplete(props: {
|
||||
() => search(),
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
if (referenceMatch()) return []
|
||||
|
||||
const { lineRange, baseQuery } = extractLineRange(query ?? "")
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.v2.fs.find({
|
||||
const result = await sdk.client.find.files({
|
||||
query: baseQuery,
|
||||
limit: "20",
|
||||
location: { workspace: project.workspace.current() },
|
||||
workspace: project.workspace.current(),
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
@@ -329,14 +318,15 @@ export function Autocomplete(props: {
|
||||
if (!result.error && result.data) {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...result.data.data.map((item): AutocompleteOption => {
|
||||
const { filename, url, part } = createFilePart(item.path, lineRange)
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
const { filename, url, part } = createFilePart(item, lineRange)
|
||||
|
||||
const isDir = item.endsWith("/")
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
value: filename,
|
||||
isDirectory: item.type === "directory",
|
||||
path: item.path,
|
||||
isDirectory: isDir,
|
||||
path: item,
|
||||
onSelect: () => {
|
||||
insertPart(filename, part)
|
||||
},
|
||||
@@ -389,16 +379,15 @@ export function Autocomplete(props: {
|
||||
})
|
||||
|
||||
const agents = createMemo(() => {
|
||||
const agents = sync.data.agent
|
||||
return agents
|
||||
return (data.location.agent.list() ?? [])
|
||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||
.map(
|
||||
(agent): AutocompleteOption => ({
|
||||
display: "@" + agent.name,
|
||||
display: "@" + agent.id,
|
||||
onSelect: () => {
|
||||
insertPart(agent.name, {
|
||||
insertPart(agent.id, {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
name: agent.id,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
@@ -410,30 +399,6 @@ export function Autocomplete(props: {
|
||||
)
|
||||
})
|
||||
|
||||
const referenceAliases = createMemo(() =>
|
||||
syncV2.data.reference
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map(
|
||||
(reference): AutocompleteOption => ({
|
||||
display: "@" + reference.name,
|
||||
description: ` ${reference.source.type === "git" ? reference.source.repository : reference.source.path}`,
|
||||
onSelect: () => {
|
||||
insertPart(reference.name, {
|
||||
type: "file",
|
||||
mime: "application/x-directory",
|
||||
filename: reference.name,
|
||||
url: pathToFileURL(reference.path).href,
|
||||
source: {
|
||||
type: "file",
|
||||
text: { start: 0, end: 0, value: "" },
|
||||
path: reference.name,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = [...slashes()]
|
||||
|
||||
@@ -465,23 +430,15 @@ export function Autocomplete(props: {
|
||||
|
||||
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
|
||||
const filesValue = files()
|
||||
const referenceMatchValue = referenceMatch()
|
||||
const agentsValue = agents()
|
||||
const referenceAliasesValue = referenceAliases()
|
||||
const commandsValue = commands()
|
||||
const searchValue = search()
|
||||
|
||||
// @<alias>/... — narrow to the matched reference, files come from fff
|
||||
// already ranked so there is no re-ranking here.
|
||||
if (store.visible === "@" && referenceMatchValue) {
|
||||
return referenceAliasesValue.filter((item) => item.display === `@${referenceMatchValue.name}`)
|
||||
}
|
||||
|
||||
// Files come from fff already fuzzy ranked and filtered
|
||||
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
|
||||
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
|
||||
const nonFileOptions: AutocompleteOption[] =
|
||||
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
|
||||
store.visible === "@" ? [...agentsValue, ...mcpResources()] : [...commandsValue]
|
||||
|
||||
if (!searchValue) {
|
||||
return [...nonFileOptions, ...fileOptions]
|
||||
@@ -563,7 +520,7 @@ export function Autocomplete(props: {
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText("@" + path + "/")
|
||||
input.insertText("@" + path)
|
||||
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "../context/sync"
|
||||
import { useData } from "../context/data"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
return createMemo(() =>
|
||||
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
|
||||
)
|
||||
const data = useData()
|
||||
return createMemo(() => (data.location.provider.list() ?? []).some((provider) => provider.enabled !== false))
|
||||
}
|
||||
|
||||
+271
-185
@@ -1,144 +1,127 @@
|
||||
import { useEvent } from "./event"
|
||||
import type {
|
||||
AgentV2Info,
|
||||
CommandV2Info,
|
||||
Event,
|
||||
ReferenceInfo,
|
||||
LocationRef,
|
||||
ModelV2Info,
|
||||
PermissionSavedInfo,
|
||||
PermissionV2Request,
|
||||
ProviderV2Info,
|
||||
QuestionV2Request,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionV2Info,
|
||||
SkillV2Info,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useProject } from "./project"
|
||||
import { createEffect } from "solid-js"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
|
||||
function activeAssistant(messages: SessionMessage[]) {
|
||||
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
|
||||
if (index < 0) return
|
||||
const assistant = messages[index]
|
||||
return assistant?.type === "assistant" ? assistant : undefined
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
model?: ModelV2Info[]
|
||||
provider?: ProviderV2Info[]
|
||||
skill?: SkillV2Info[]
|
||||
}
|
||||
|
||||
function ownedAssistant(messages: SessionMessage[], messageID: string) {
|
||||
const message = messages.find((message) => message.type === "assistant" && message.id === messageID)
|
||||
return message?.type === "assistant" ? message : undefined
|
||||
type Data = {
|
||||
session: {
|
||||
info: Record<string, SessionV2Info>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
}
|
||||
project: {
|
||||
permission: Record<string, PermissionSavedInfo[]>
|
||||
}
|
||||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
function activeShell(messages: SessionMessage[], callID: string) {
|
||||
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
|
||||
if (index < 0) return
|
||||
const shell = messages[index]
|
||||
return shell?.type === "shell" ? shell : undefined
|
||||
function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
}
|
||||
|
||||
function latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||
)
|
||||
}
|
||||
|
||||
function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
)
|
||||
}
|
||||
|
||||
function prepend(messages: SessionMessage[], message: SessionMessage) {
|
||||
if (messages.some((item) => item.id === message.id)) return
|
||||
messages.unshift(message)
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
messages: {
|
||||
[sessionID: string]: SessionMessage[]
|
||||
}
|
||||
reference: ReferenceInfo[]
|
||||
}>({
|
||||
messages: {},
|
||||
reference: [],
|
||||
const [store, setStore] = createStore<Data>({
|
||||
session: {
|
||||
info: {},
|
||||
message: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
},
|
||||
project: {
|
||||
permission: {},
|
||||
},
|
||||
location: {},
|
||||
})
|
||||
|
||||
const event = useEvent()
|
||||
const sdk = useSDK()
|
||||
const project = useProject()
|
||||
const applied = new Set<string>()
|
||||
const buffering = new Map<string, Event[]>()
|
||||
const syncing = new Map<string, Promise<void>>()
|
||||
|
||||
function duplicate(id: string) {
|
||||
if (applied.has(id)) return true
|
||||
applied.add(id)
|
||||
if (applied.size <= 1000) return false
|
||||
const oldest = applied.values().next()
|
||||
if (!oldest.done) applied.delete(oldest.value)
|
||||
return false
|
||||
}
|
||||
|
||||
function update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
"messages",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function hydrate(sessionID: string) {
|
||||
const pending: Event[] = []
|
||||
const before = JSON.parse(JSON.stringify(store.messages[sessionID] ?? [])) as SessionMessage[]
|
||||
buffering.set(sessionID, pending)
|
||||
try {
|
||||
const response = await sdk.client.v2.session.messages({ sessionID })
|
||||
const messages = response.data?.data ?? []
|
||||
const snapshotIDs = new Set(messages.map((message) => message.id))
|
||||
setStore(
|
||||
"messages",
|
||||
sessionID,
|
||||
reconcile([...messages, ...before.filter((message) => !snapshotIDs.has(message.id))]),
|
||||
)
|
||||
buffering.delete(sessionID)
|
||||
for (const event of pending) apply(event)
|
||||
} catch (error) {
|
||||
buffering.delete(sessionID)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sync(sessionID: string) {
|
||||
const existing = syncing.get(sessionID)
|
||||
if (existing) return existing
|
||||
const result = hydrate(sessionID).finally(() => syncing.delete(sessionID))
|
||||
syncing.set(sessionID, result)
|
||||
return result
|
||||
}
|
||||
|
||||
async function syncReferences(workspace = project.workspace.current()) {
|
||||
const result = await sdk.client.v2.reference.list({ location: { workspace } })
|
||||
if (workspace !== project.workspace.current()) return
|
||||
setStore("reference", reconcile(result.data?.data ?? []))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
project.workspace.current()
|
||||
void syncReferences()
|
||||
const [defaultLocation, setDefaultLocation] = createSignal<LocationRef>({
|
||||
directory: sdk.directory ?? process.cwd(),
|
||||
})
|
||||
|
||||
function apply(event: Event) {
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[]) => void) {
|
||||
setStore(
|
||||
"session",
|
||||
"message",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []))
|
||||
}),
|
||||
)
|
||||
},
|
||||
prepend(messages: SessionMessage[], item: SessionMessage) {
|
||||
if (messages.some((existing) => existing.id === item.id)) return
|
||||
messages.unshift(item)
|
||||
},
|
||||
activeAssistant(messages: SessionMessage[]) {
|
||||
const item = messages.find((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessage[], messageID: string) {
|
||||
const item = messages.find((item) => item.type === "assistant" && item.id === messageID)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
activeShell(messages: SessionMessage[], callID: string) {
|
||||
const item = messages.find((item) => item.type === "shell" && item.callID === callID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool =>
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined, textID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantText => item.type === "text" && item.id === textID,
|
||||
)
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "session.next.agent.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "agent-switched",
|
||||
agent: event.properties.agent,
|
||||
@@ -147,8 +130,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.model.switched":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "model-switched",
|
||||
model: event.properties.model,
|
||||
@@ -157,8 +140,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.prompted": {
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
@@ -172,8 +155,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.next.prompt.admitted":
|
||||
break
|
||||
case "session.next.prompt.promoted":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "user",
|
||||
text: event.properties.prompt.text,
|
||||
@@ -184,8 +167,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.context.updated":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "system",
|
||||
text: event.properties.text,
|
||||
@@ -194,8 +177,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.synthetic":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "synthetic",
|
||||
sessionID: event.properties.sessionID,
|
||||
@@ -205,8 +188,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.shell.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "shell",
|
||||
callID: event.properties.callID,
|
||||
@@ -217,19 +200,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.shell.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = activeShell(draft, event.properties.callID)
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.activeShell(draft, event.properties.callID)
|
||||
if (!match) return
|
||||
match.output = event.properties.output
|
||||
match.time.completed = event.properties.timestamp
|
||||
})
|
||||
break
|
||||
case "session.next.step.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
if (draft.some((message) => message.id === event.properties.assistantMessageID)) return
|
||||
const currentAssistant = activeAssistant(draft)
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
|
||||
prepend(draft, {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.assistantMessageID,
|
||||
type: "assistant",
|
||||
agent: event.properties.agent,
|
||||
@@ -241,8 +224,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.step.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = event.properties.finish
|
||||
@@ -253,8 +236,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.step.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = ownedAssistant(draft, event.properties.assistantMessageID)
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const currentAssistant = message.assistant(draft, event.properties.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.properties.timestamp
|
||||
currentAssistant.finish = "error"
|
||||
@@ -262,8 +245,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.text.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
id: event.properties.textID,
|
||||
text: "",
|
||||
@@ -271,26 +254,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.text.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.text.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestText(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestText(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.textID,
|
||||
)
|
||||
if (match) match.text = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "tool",
|
||||
id: event.properties.callID,
|
||||
name: event.properties.name,
|
||||
@@ -300,27 +283,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (match?.state.status === "pending") match.state.input += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.tool.input.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (match?.state.status === "pending") match.state.input = event.properties.text
|
||||
})
|
||||
break
|
||||
case "session.next.tool.called":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (!match) return
|
||||
@@ -330,9 +313,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.progress":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
@@ -341,9 +324,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.success":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
@@ -363,9 +346,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.tool.failed":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestTool(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||
@@ -386,8 +369,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.started":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
ownedAssistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.assistant(draft, event.properties.assistantMessageID)?.content.push({
|
||||
type: "reasoning",
|
||||
id: event.properties.reasoningID,
|
||||
text: "",
|
||||
@@ -396,18 +379,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.delta":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) match.text += event.properties.delta
|
||||
})
|
||||
break
|
||||
case "session.next.reasoning.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
const match = latestReasoning(
|
||||
ownedAssistant(draft, event.properties.assistantMessageID),
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
const match = message.latestReasoning(
|
||||
message.assistant(draft, event.properties.assistantMessageID),
|
||||
event.properties.reasoningID,
|
||||
)
|
||||
if (match) {
|
||||
@@ -422,8 +405,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
case "session.next.compaction.delta":
|
||||
break
|
||||
case "session.next.compaction.ended":
|
||||
update(event.properties.sessionID, (draft) => {
|
||||
prepend(draft, {
|
||||
message.update(event.properties.sessionID, (draft) => {
|
||||
message.prepend(draft, {
|
||||
id: event.properties.messageID,
|
||||
type: "compaction",
|
||||
reason: event.properties.reason,
|
||||
@@ -433,33 +416,136 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
break
|
||||
case "reference.updated":
|
||||
void syncReferences()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
if (duplicate(event.id)) return
|
||||
if ("sessionID" in event.properties && typeof event.properties.sessionID === "string")
|
||||
buffering.get(event.properties.sessionID)?.push(event)
|
||||
apply(event)
|
||||
})
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
return store.session.info[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "info", sessionID, result.data.data)
|
||||
},
|
||||
message: {
|
||||
sync,
|
||||
fromSession(sessionID: string) {
|
||||
const messages = store.messages[sessionID]
|
||||
if (!messages) return []
|
||||
return messages
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.messages({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "message", sessionID, result.data.data)
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
list(sessionID: string) {
|
||||
return store.session.permission[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "permission", sessionID, result.data.data)
|
||||
},
|
||||
},
|
||||
question: {
|
||||
list(sessionID: string) {
|
||||
return store.session.question[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true })
|
||||
setStore("session", "question", sessionID, result.data.data)
|
||||
},
|
||||
},
|
||||
},
|
||||
project: {
|
||||
permission: {
|
||||
list(projectID: string) {
|
||||
return store.project.permission[projectID]
|
||||
},
|
||||
async refresh(projectID: string) {
|
||||
const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true })
|
||||
setStore("project", "permission", projectID, result.data.data)
|
||||
},
|
||||
},
|
||||
},
|
||||
location: {
|
||||
default() {
|
||||
return defaultLocation()
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const location = response.data
|
||||
const key = locationKey(location)
|
||||
if (!store.location[key]) setStore("location", key, {})
|
||||
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||
},
|
||||
agent: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "agent", result.data.data)
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "command", result.data.data)
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "model", result.data.data)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "provider", result.data.data)
|
||||
},
|
||||
},
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true })
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, "skill", result.data.data)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void Promise.allSettled([
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.command.refresh(),
|
||||
result.location.skill.refresh(),
|
||||
])
|
||||
.then((settled) => {
|
||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||
console.error("Failed to refresh default location data", failure.reason)
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||
import { useTheme } from "./theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
|
||||
export type LocalTheme = {
|
||||
secondary: RGBA
|
||||
@@ -51,15 +52,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const theme = useTheme().theme
|
||||
const route = useRoute()
|
||||
const paths = useTuiPaths()
|
||||
const providers = createMemo(() => data.location.provider.list() ?? [])
|
||||
const models = createMemo(() => data.location.model.list() ?? [])
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
return models().some((item) => item.providerID === model.providerID && item.id === model.modelID && item.enabled)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => { providerID: string; modelID: string } | undefined)[]) {
|
||||
@@ -71,8 +74,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
|
||||
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
|
||||
const all = createMemo(() =>
|
||||
(data.location.agent.list() ?? []).map((agent) => ({
|
||||
...agent,
|
||||
name: agent.id,
|
||||
native: false,
|
||||
model: agent.model
|
||||
? { providerID: agent.model.providerID, modelID: agent.model.id, variant: agent.model.variant }
|
||||
: undefined,
|
||||
})),
|
||||
)
|
||||
const agents = createMemo(() => all().filter((agent) => agent.mode !== "subagent" && !agent.hidden))
|
||||
const visibleAgents = createMemo(() => all().filter((agent) => !agent.hidden))
|
||||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
@@ -218,15 +231,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const provider = sync.data.provider[0]
|
||||
if (!provider) return undefined
|
||||
const defaultModel = sync.data.provider_default[provider.id]
|
||||
const firstModel = Object.values(provider.models)[0]
|
||||
const model = defaultModel ?? firstModel?.id
|
||||
const model = models().find((item) => item.enabled && providers().some((provider) => provider.id === item.providerID))
|
||||
if (!model) return undefined
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -261,12 +270,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)
|
||||
const info = provider?.models[value.modelID]
|
||||
const provider = providers().find((item) => item.id === value.providerID)
|
||||
const info = models().find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||
return {
|
||||
provider: provider?.name ?? value.providerID,
|
||||
model: info?.name ?? value.modelID,
|
||||
reasoning: info?.capabilities?.reasoning ?? false,
|
||||
reasoning: false,
|
||||
}
|
||||
}),
|
||||
cycle(direction: 1 | -1) {
|
||||
@@ -372,10 +381,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
list() {
|
||||
const m = currentModel()
|
||||
if (!m) return []
|
||||
const provider = sync.data.provider.find((x) => x.id === m.providerID)
|
||||
const info = provider?.models[m.modelID]
|
||||
if (!info?.variants) return []
|
||||
return Object.keys(info.variants)
|
||||
const info = models().find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||
return info?.variants.map((variant) => variant.id) ?? []
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
|
||||
@@ -24,9 +24,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useProject } from "./project"
|
||||
import { useEvent } from "./event"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useTuiStartup } from "./runtime"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useExit } from "./exit"
|
||||
import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
@@ -57,7 +55,6 @@ export const {
|
||||
} = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const startup = useTuiStartup()
|
||||
const kv = useKV()
|
||||
const [store, setStore] = createStore<{
|
||||
status: "loading" | "partial" | "complete"
|
||||
@@ -422,11 +419,9 @@ export const {
|
||||
}
|
||||
})
|
||||
|
||||
const exit = useExit()
|
||||
const args = useArgs()
|
||||
|
||||
async function bootstrap(input: { fatal?: boolean } = {}) {
|
||||
const fatal = input.fatal ?? true
|
||||
async function bootstrap() {
|
||||
const workspace = project.workspace.current()
|
||||
const projectPromise = project.sync()
|
||||
const sessionListPromise = projectPromise.then(() => listSessions())
|
||||
@@ -440,14 +435,26 @@ export const {
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
await Promise.all([
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
agentsPromise,
|
||||
configPromise,
|
||||
projectPromise,
|
||||
...(args.continue ? [sessionListPromise] : []),
|
||||
])
|
||||
const blockingRequests: { name: string; promise: Promise<unknown> }[] = [
|
||||
{ name: "config.providers", promise: providersPromise },
|
||||
{ name: "provider.list", promise: providerListPromise },
|
||||
{ name: "app.agents", promise: agentsPromise },
|
||||
{ name: "config.get", promise: configPromise },
|
||||
{ name: "project.sync", promise: projectPromise },
|
||||
...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []),
|
||||
]
|
||||
|
||||
await Promise.allSettled(blockingRequests.map((r) => r.promise))
|
||||
.then((settled) => {
|
||||
// Surface every failed endpoint in one labeled message instead of
|
||||
// letting the first rejection drown its siblings as unhandled
|
||||
// rejections.
|
||||
const failures = blockingRequests.flatMap((request, index) => {
|
||||
const result = settled[index]
|
||||
return result?.status === "rejected" ? [`${request.name}: ${String(result.reason)}`] : []
|
||||
})
|
||||
if (failures.length) throw new Error(failures.join("\n"))
|
||||
})
|
||||
.then(async () => {
|
||||
const providersResponse = providersPromise.then((x) => x.data!)
|
||||
const providerListResponse = providerListPromise.then((x) => x.data!)
|
||||
@@ -511,11 +518,7 @@ export const {
|
||||
name: e instanceof Error ? e.name : undefined,
|
||||
stack: e instanceof Error ? e.stack : undefined,
|
||||
})
|
||||
if (fatal) {
|
||||
exit(e)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
setStore("status", "partial")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -530,8 +533,7 @@ export const {
|
||||
return store.status
|
||||
},
|
||||
get ready() {
|
||||
if (startup.skipInitialLoading) return true
|
||||
return store.status !== "loading"
|
||||
return true
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
import type { PluginRuntime } from "../plugin/runtime"
|
||||
import HomeFooter from "./home/footer"
|
||||
import HomeTips from "./home/tips"
|
||||
import SidebarContext from "./sidebar/context"
|
||||
@@ -10,8 +11,8 @@ import SidebarTodo from "./sidebar/todo"
|
||||
import DiffViewer from "./system/diff-viewer"
|
||||
import Notifications from "./system/notifications"
|
||||
import PluginManager from "./system/plugins"
|
||||
import SessionV2Debug from "./system/session-v2"
|
||||
import WhichKey from "./system/which-key"
|
||||
import Scrap from "./system/scrap"
|
||||
|
||||
export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
id: string
|
||||
@@ -19,7 +20,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export function createBuiltinPlugins(options: { experimentalEventSystem: boolean }): BuiltinTuiPlugin[] {
|
||||
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
|
||||
return [
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
@@ -32,7 +33,45 @@ export function createBuiltinPlugins(options: { experimentalEventSystem: boolean
|
||||
Notifications,
|
||||
PluginManager,
|
||||
WhichKey,
|
||||
Scrap,
|
||||
DiffViewer,
|
||||
...(options.experimentalEventSystem ? [SessionV2Debug] : []),
|
||||
]
|
||||
}
|
||||
|
||||
export async function loadBuiltinPlugins(
|
||||
api: TuiPluginApi,
|
||||
runtime: PluginRuntime,
|
||||
) {
|
||||
const slots = runtime.setupSlots(api)
|
||||
const dispose: Array<() => void> = []
|
||||
|
||||
for (const plugin of createBuiltinPlugins()) {
|
||||
if (plugin.enabled === false) continue
|
||||
const scoped = Object.assign(Object.create(api), {
|
||||
slots: {
|
||||
register(input: Parameters<typeof slots.register>[0]) {
|
||||
dispose.push(slots.register({ ...input, id: plugin.id }))
|
||||
return plugin.id
|
||||
},
|
||||
},
|
||||
}) as TuiPluginApi
|
||||
const now = Date.now()
|
||||
await plugin.tui(scoped, undefined, {
|
||||
id: plugin.id,
|
||||
source: "internal",
|
||||
spec: plugin.id,
|
||||
target: plugin.id,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: plugin.id,
|
||||
state: "first",
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of dispose.reverse()) fn()
|
||||
slots.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,19 +4,21 @@ import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
const id = "internal:home-footer"
|
||||
|
||||
function Directory(props: { api: TuiPluginApi }) {
|
||||
const theme = () => props.api.theme.current
|
||||
const destination = useHomeSessionDestination()
|
||||
const data = useData()
|
||||
const paths = useTuiPaths()
|
||||
const dir = createMemo(() => {
|
||||
const selected = destination?.destination()
|
||||
if (!selected || selected.type === "new") return
|
||||
const out = abbreviateHome(selected.directory, paths.home)
|
||||
const directory = !selected || selected.type === "new" ? data.location.default().directory : selected.directory
|
||||
const out = abbreviateHome(directory, paths.home)
|
||||
const branch =
|
||||
selected.directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
||||
directory === (props.api.state.path.directory || paths.cwd) ? props.api.state.vcs?.branch : undefined
|
||||
if (branch) return out + ":" + branch
|
||||
return out
|
||||
})
|
||||
|
||||
@@ -1,1196 +0,0 @@
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { useData } from "../../context/data"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { reasoningSummary, useThinkingMode } from "../../context/thinking"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { RGBA, TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
|
||||
import { useBindings } from "../../keymap"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
|
||||
import { toolDisplayMetadata, webSearchProviderLabel } from "../../util/tool-display"
|
||||
import path from "path"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type {
|
||||
SessionMessage,
|
||||
SessionMessageAgentSwitched,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageCompaction,
|
||||
SessionMessageModelSwitched,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
ToolFileContent,
|
||||
ToolTextContent,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { setPreLayoutSiblingMargin } from "../../util/layout"
|
||||
|
||||
const id = "internal:session-v2-debug"
|
||||
const route = "session.v2.messages"
|
||||
|
||||
function currentSessionID(api: TuiPluginApi) {
|
||||
const current = api.route.current
|
||||
if (current.name !== "session") return
|
||||
const sessionID = current.params?.sessionID
|
||||
return typeof sessionID === "string" ? sessionID : undefined
|
||||
}
|
||||
|
||||
function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
const sync = useData()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme, syntax, subtleSyntax } = useTheme()
|
||||
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
|
||||
const renderedMessages = createMemo(() => messages().toReversed())
|
||||
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
|
||||
const lastUserCreated = (index: number) =>
|
||||
renderedMessages()
|
||||
.slice(0, index)
|
||||
.findLast((message) => message.type === "user")?.time.created
|
||||
|
||||
createEffect(() => {
|
||||
void sync.session.message.sync(props.sessionID)
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Back to session",
|
||||
group: "Session",
|
||||
cmd() {
|
||||
props.api.route.navigate("session", { sessionID: props.sessionID })
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width={dimensions().width} height={dimensions().height} backgroundColor={theme.background}>
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<scrollbox
|
||||
viewportOptions={{ paddingRight: 0 }}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
>
|
||||
<box height={1} />
|
||||
<Show when={messages().length === 0}>
|
||||
<MissingData label="Messages" detail="No v2 messages loaded from useData yet." />
|
||||
</Show>
|
||||
<For each={renderedMessages()}>
|
||||
{(message, index) => (
|
||||
<Switch>
|
||||
<Match when={message.type === "user"}>
|
||||
<UserMessage message={message as SessionMessageUser} index={index()} />
|
||||
</Match>
|
||||
<Match when={message.type === "assistant"}>
|
||||
<AssistantMessage
|
||||
message={message as SessionMessageAssistant}
|
||||
sessionID={props.sessionID}
|
||||
last={lastAssistant()?.id === message.id}
|
||||
syntax={syntax()}
|
||||
subtleSyntax={subtleSyntax()}
|
||||
start={lastUserCreated(index())}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={message.type === "synthetic"}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={message.type === "system"}>
|
||||
<></>
|
||||
</Match>
|
||||
<Match when={message.type === "shell"}>
|
||||
<ShellMessage message={message as SessionMessageShell} />
|
||||
</Match>
|
||||
<Match when={message.type === "compaction"}>
|
||||
<CompactionMessage message={message as SessionMessageCompaction} />
|
||||
</Match>
|
||||
<Match when={message.type === "agent-switched"}>
|
||||
<AgentSwitchedMessage message={message as SessionMessageAgentSwitched} />
|
||||
</Match>
|
||||
<Match when={message.type === "model-switched"}>
|
||||
<ModelSwitchedMessage message={message as SessionMessageModelSwitched} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<UnknownMessage message={message} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<MissingData
|
||||
label="Session prompt, permission prompt, question prompt, sidebar"
|
||||
detail="The v2 message endpoint only exposes messages, so these session UI regions cannot be rendered here. Press Esc to return to the live session."
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingData(props: { label: string; detail: string }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.warning}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
paddingLeft={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.warning, fg: theme.background, bold: true }}> MISSING DATA </span> {props.label}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{props.detail}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UserMessage(props: { message: SessionMessageUser; index: number }) {
|
||||
const { theme } = useTheme()
|
||||
const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])])
|
||||
return (
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={theme.secondary}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
marginTop={props.index === 0 ? 0 : 1}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>{props.message.text}</text>
|
||||
<Show when={attachments().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={props.message.files ?? []}>
|
||||
{(file) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.message.agents ?? []}>
|
||||
{(agent) => (
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
|
||||
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellMessage(props: { message: SessionMessageShell }) {
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const output = createMemo(() => stripAnsi(props.message.output.trim()))
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
|
||||
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !collapsed().overflow) return output()
|
||||
return collapsed().output
|
||||
})
|
||||
return (
|
||||
<BlockTool
|
||||
title="# Shell"
|
||||
spinner={!props.message.time.completed}
|
||||
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {props.message.command}</text>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
</Show>
|
||||
<Show when={collapsed().overflow}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactionMessage(props: { message: SessionMessageCompaction }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box
|
||||
marginTop={1}
|
||||
border={["top"]}
|
||||
title={props.message.reason === "auto" ? " Auto Compaction " : " Compaction "}
|
||||
titleAlignment="center"
|
||||
borderColor={theme.borderActive}
|
||||
flexShrink={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) {
|
||||
const { theme } = useTheme()
|
||||
const local = useLocal()
|
||||
return (
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<text>
|
||||
<span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
|
||||
<span style={{ fg: theme.textMuted }}>Switched agent to </span>
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) {
|
||||
const { theme } = useTheme()
|
||||
const model = createMemo(() => {
|
||||
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
|
||||
return `${props.message.model.providerID}/${props.message.model.id}${variant}`
|
||||
})
|
||||
return (
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0}>
|
||||
<text>
|
||||
<span style={{ fg: theme.secondary }}>◇ </span>
|
||||
<span style={{ fg: theme.textMuted }}>Switched model to </span>
|
||||
<span style={{ fg: theme.text }}>{model()}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function UnknownMessage(props: { message: SessionMessage }) {
|
||||
return <MissingData label="Unknown message type" detail={JSON.stringify(props.message)} />
|
||||
}
|
||||
|
||||
function AssistantMessage(props: {
|
||||
message: SessionMessageAssistant
|
||||
sessionID: string
|
||||
last: boolean
|
||||
syntax: SyntaxStyle
|
||||
subtleSyntax: SyntaxStyle
|
||||
start?: number
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const local = useLocal()
|
||||
const duration = createMemo(() => {
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - (props.start ?? props.message.time.created)
|
||||
})
|
||||
const model = createMemo(() => {
|
||||
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
|
||||
return `${props.message.model.providerID}/${props.message.model.id}${variant}`
|
||||
})
|
||||
const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish))
|
||||
return (
|
||||
<>
|
||||
<For each={props.message.content}>
|
||||
{(part) => (
|
||||
<Switch>
|
||||
<Match when={part.type === "text"}>
|
||||
<AssistantText part={part as SessionMessageAssistantText} syntax={props.syntax} />
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning"}>
|
||||
<AssistantReasoning
|
||||
part={part as SessionMessageAssistantReasoning}
|
||||
subtleSyntax={props.subtleSyntax}
|
||||
completedAt={() => props.message.time.completed}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={part.type === "tool"}>
|
||||
<AssistantTool part={part as SessionMessageAssistantTool} sessionID={props.sessionID} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.message.content.length === 0}>
|
||||
<MissingData label="Assistant content" detail={`Assistant message ${props.message.id} has no content items.`} />
|
||||
</Show>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.error}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.textMuted}>{props.message.error}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
<text marginTop={1}>
|
||||
<span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.agent)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} marginTop={1} flexShrink={0} id={`text-${props.part.id}`}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.syntax}
|
||||
content={props.part.text.trim()}
|
||||
conceal={true}
|
||||
fg={theme.text}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantReasoning(props: {
|
||||
part: SessionMessageAssistantReasoning
|
||||
subtleSyntax: SyntaxStyle
|
||||
completedAt: () => number | undefined
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const thinking = useThinkingMode()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim())
|
||||
const inMinimal = createMemo(() => thinking.mode() === "hide")
|
||||
// v2 reasoning parts have no per-part `time.end` (see SessionMessageAssistantReasoning
|
||||
// in the v2 SDK); we settle on parent-message completion instead.
|
||||
const isDone = createMemo(() => props.completedAt() !== undefined)
|
||||
const summary = createMemo(() => reasoningSummary(content()))
|
||||
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content()}>
|
||||
<box paddingLeft={3} marginTop={1} flexDirection="column" flexShrink={0}>
|
||||
<box onMouseUp={toggle}>
|
||||
<ReasoningHeader
|
||||
toggleable={inMinimal()}
|
||||
open={!inMinimal() || expanded()}
|
||||
done={isDone()}
|
||||
title={summary().title}
|
||||
/>
|
||||
</box>
|
||||
<Show when={(!inMinimal() || expanded()) && summary().body}>
|
||||
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={props.subtleSyntax}
|
||||
content={summary().body}
|
||||
conceal={true}
|
||||
fg={theme.textMuted}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: boolean; title: string | null }) {
|
||||
const { theme } = useTheme()
|
||||
const fg = () =>
|
||||
props.open
|
||||
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
|
||||
: theme.warning
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!props.done}>
|
||||
<box flexDirection="row">
|
||||
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title}>
|
||||
<span>: </span>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) {
|
||||
const input = createMemo(() => toolInputRecord(props.part.state.input))
|
||||
const toolprops = {
|
||||
get input() {
|
||||
return input()
|
||||
},
|
||||
get metadata() {
|
||||
return toolDisplayMetadata(props.part.state)
|
||||
},
|
||||
get output() {
|
||||
return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content)
|
||||
},
|
||||
sessionID: props.sessionID,
|
||||
part: props.part,
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.part.name === "bash"}>
|
||||
<Bash {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "glob"}>
|
||||
<Glob {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "read"}>
|
||||
<Read {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "grep"}>
|
||||
<Grep {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "webfetch"}>
|
||||
<WebFetch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "websearch"}>
|
||||
<WebSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "write"}>
|
||||
<Write {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "edit"}>
|
||||
<Edit {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "apply_patch"}>
|
||||
<ApplyPatch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "todowrite"}>
|
||||
<TodoWrite {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "question"}>
|
||||
<Question {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "skill"}>
|
||||
<Skill {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.name === "task"}>
|
||||
<Task {...toolprops} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<GenericTool {...toolprops} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
input: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
output?: string
|
||||
sessionID: string
|
||||
part: SessionMessageAssistantTool
|
||||
}
|
||||
|
||||
function GenericTool(props: ToolProps) {
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const output = createMemo(() => props.output?.trim() ?? "")
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const maxLines = 3
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
|
||||
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !collapsed().overflow) return output()
|
||||
return collapsed().output
|
||||
})
|
||||
return (
|
||||
<Show
|
||||
when={output()}
|
||||
fallback={
|
||||
<InlineTool icon="⚙" pending="Writing command..." complete={toolComplete(props.part)} part={props.part}>
|
||||
{props.part.name} {input(props.input)}
|
||||
</InlineTool>
|
||||
}
|
||||
>
|
||||
<BlockTool
|
||||
title={`# ${props.part.name} ${input(props.input)}`}
|
||||
part={props.part}
|
||||
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
<Show when={collapsed().overflow}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineTool(props: {
|
||||
icon: string
|
||||
complete: unknown
|
||||
pending: string
|
||||
spinner?: boolean
|
||||
children: JSX.Element
|
||||
part: SessionMessageAssistantTool
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const [showError, setShowError] = createSignal(false)
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const complete = createMemo(() => !!props.complete)
|
||||
const denied = createMemo(() => {
|
||||
const message = error()
|
||||
if (!message) return false
|
||||
return (
|
||||
message.includes("QuestionRejectedError") ||
|
||||
message.includes("rejected permission") ||
|
||||
message.includes("specified a rule") ||
|
||||
message.includes("user dismissed")
|
||||
)
|
||||
})
|
||||
const fg = createMemo(() => {
|
||||
if (error()) return theme.error
|
||||
if (complete()) return theme.textMuted
|
||||
return theme.text
|
||||
})
|
||||
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
|
||||
return (
|
||||
<box
|
||||
paddingLeft={3}
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
|
||||
onMouseOver={() => error() && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (!error()) return
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
setShowError((prev) => !prev)
|
||||
}}
|
||||
ref={(el: BoxRenderable) => {
|
||||
setPreLayoutSiblingMargin(el, (previous) => (previous?.id.startsWith("text-") ? 1 : 0))
|
||||
}}
|
||||
>
|
||||
<box flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Spinner color={theme.text} />
|
||||
</Match>
|
||||
<Match when={complete()}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.icon}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
~
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<box>
|
||||
<Switch>
|
||||
<Match when={complete()}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.children}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} attributes={attributes()}>
|
||||
{props.pending}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={showError() && error()}>
|
||||
<box>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function BlockTool(props: {
|
||||
title: string
|
||||
children: JSX.Element
|
||||
part?: SessionMessageAssistantTool
|
||||
onClick?: () => void
|
||||
spinner?: boolean
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
return (
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
marginTop={1}
|
||||
gap={1}
|
||||
backgroundColor={hover() ? theme.backgroundMenu : theme.backgroundPanel}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background}
|
||||
onMouseOver={() => props.onClick && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
props.onClick?.()
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show
|
||||
when={props.spinner}
|
||||
fallback={
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
{props.title}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
|
||||
</Show>
|
||||
{props.children}
|
||||
<Show when={error()}>
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Bash(props: ToolProps) {
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim()))
|
||||
const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part))
|
||||
const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6))
|
||||
const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars()))
|
||||
const limited = createMemo(() => {
|
||||
if (expanded() || !collapsed().overflow) return output()
|
||||
return collapsed().output
|
||||
})
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={output()}>
|
||||
<BlockTool
|
||||
title={title()}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "running"}
|
||||
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
|
||||
>
|
||||
<box gap={1}>
|
||||
<text fg={theme.text}>$ {command()}</text>
|
||||
<text fg={theme.text}>{limited()}</text>
|
||||
<Show when={collapsed().overflow}>
|
||||
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="$" pending="Writing command..." complete={command()} part={props.part}>
|
||||
{command()}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Glob(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Finding files..." complete={toolComplete(props.part)} part={props.part}>
|
||||
Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.count)}>
|
||||
{(count) => (
|
||||
<>
|
||||
({count()} {count() === 1 ? "match" : "matches"})
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Read(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
const { theme } = useTheme()
|
||||
const loaded = createMemo(() =>
|
||||
arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<InlineTool
|
||||
icon="→"
|
||||
pending="Reading file..."
|
||||
complete={stringValue(props.input.filePath) ?? pendingInput(props.part)}
|
||||
spinner={props.part.state.status === "running"}
|
||||
part={props.part}
|
||||
>
|
||||
Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "}
|
||||
{input(props.input, ["filePath"])}
|
||||
</InlineTool>
|
||||
<For each={loaded()}>
|
||||
{(filepath) => (
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
<text paddingLeft={3} fg={theme.textMuted}>
|
||||
↳ Loaded {normalizePath(filepath)}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Grep(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
return (
|
||||
<InlineTool icon="✱" pending="Searching content..." complete={toolComplete(props.part)} part={props.part}>
|
||||
Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "}
|
||||
<Show when={stringValue(props.input.path)}>in {normalizePath(stringValue(props.input.path))} </Show>
|
||||
<Show when={numberValue(props.metadata.matches)}>
|
||||
{(matches) => (
|
||||
<>
|
||||
({matches()} {matches() === 1 ? "match" : "matches"})
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function WebFetch(props: ToolProps) {
|
||||
return (
|
||||
<InlineTool icon="%" pending="Fetching from the web..." complete={toolComplete(props.part)} part={props.part}>
|
||||
WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function WebSearch(props: ToolProps) {
|
||||
const label = createMemo(() => webSearchProviderLabel(props.metadata.provider))
|
||||
return (
|
||||
<InlineTool icon="◈" pending="Searching web..." complete={toolComplete(props.part)} part={props.part}>
|
||||
{label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "}
|
||||
<Show when={numberValue(props.metadata.numResults)}>{(results) => <>({results()} results)</>}</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Write(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
|
||||
const content = createMemo(() => stringValue(props.input.content) ?? "")
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={content() && props.part.state.status === "completed"}>
|
||||
<BlockTool title={"# Wrote " + normalizePath(filePath())} part={props.part}>
|
||||
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
|
||||
<code
|
||||
conceal={false}
|
||||
fg={theme.text}
|
||||
filetype={filetype(filePath())}
|
||||
syntaxStyle={syntax()}
|
||||
content={content()}
|
||||
/>
|
||||
</line_number>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing write..." complete={filePath()} part={props.part}>
|
||||
Write {normalizePath(filePath())}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Edit(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const filePath = createMemo(() => stringValue(props.input.filePath) ?? "")
|
||||
const diff = createMemo(() => stringValue(props.metadata.diff))
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={diff()}>
|
||||
{(diff) => (
|
||||
<BlockTool title={"← Edit " + normalizePath(filePath())} part={props.part}>
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={diff()}
|
||||
view={dimensions().width > 120 ? "split" : "unified"}
|
||||
filetype={filetype(filePath())}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.text}
|
||||
addedBg={theme.diffAddedBg}
|
||||
removedBg={theme.diffRemovedBg}
|
||||
contextBg={theme.diffContextBg}
|
||||
addedSignColor={theme.diffHighlightAdded}
|
||||
removedSignColor={theme.diffHighlightRemoved}
|
||||
lineNumberFg={theme.diffLineNumber}
|
||||
lineNumberBg={theme.diffContextBg}
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={filePath()} />
|
||||
</BlockTool>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={filePath()} part={props.part}>
|
||||
Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function ApplyPatch(props: ToolProps) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
const { theme, syntax } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : [])))
|
||||
const fileTitle = (file: Record<string, unknown>) => {
|
||||
const type = stringValue(file.type)
|
||||
const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch"
|
||||
if (type === "delete") return "# Deleted " + relativePath
|
||||
if (type === "add") return "# Created " + relativePath
|
||||
if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath
|
||||
return "← Patched " + relativePath
|
||||
}
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={files().length > 0}>
|
||||
<For each={files()}>
|
||||
{(file) => (
|
||||
<BlockTool title={fileTitle(file)} part={props.part}>
|
||||
<Show
|
||||
when={stringValue(file.patch)}
|
||||
fallback={
|
||||
<text fg={theme.diffRemoved}>
|
||||
-{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(patch) => (
|
||||
<box paddingLeft={1}>
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={dimensions().width > 120 ? "split" : "unified"}
|
||||
filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))}
|
||||
syntaxStyle={syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="word"
|
||||
fg={theme.text}
|
||||
addedBg={theme.diffAddedBg}
|
||||
removedBg={theme.diffRemovedBg}
|
||||
contextBg={theme.diffContextBg}
|
||||
addedSignColor={theme.diffHighlightAdded}
|
||||
removedSignColor={theme.diffHighlightRemoved}
|
||||
lineNumberFg={theme.diffLineNumber}
|
||||
lineNumberBg={theme.diffContextBg}
|
||||
addedLineNumberBg={theme.diffAddedLineNumberBg}
|
||||
removedLineNumberBg={theme.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</BlockTool>
|
||||
)}
|
||||
</For>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="%" pending="Preparing patch..." complete={false} part={props.part}>
|
||||
Patch
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function TodoWrite(props: ToolProps) {
|
||||
const { theme } = useTheme()
|
||||
const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : [])))
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={todos().length > 0 && props.part.state.status === "completed"}>
|
||||
<BlockTool title="# Todos" part={props.part}>
|
||||
<box>
|
||||
<For each={todos()}>
|
||||
{(todo) => (
|
||||
<text fg={theme.text}>
|
||||
{todoIcon(stringValue(todo.status))} {stringValue(todo.content)}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="⚙" pending="Updating todos..." complete={false} part={props.part}>
|
||||
Updating todos...
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Question(props: ToolProps) {
|
||||
const { theme } = useTheme()
|
||||
const questions = createMemo(() =>
|
||||
arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])),
|
||||
)
|
||||
const answers = createMemo(() => arrayValue(props.metadata.answers))
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={answers().length > 0}>
|
||||
<BlockTool title="# Questions" part={props.part}>
|
||||
<box gap={1}>
|
||||
<For each={questions()}>
|
||||
{(question, index) => (
|
||||
<box>
|
||||
<text fg={theme.textMuted}>{stringValue(question.question)}</text>
|
||||
<text fg={theme.text}>{formatAnswer(answers()[index()])}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</BlockTool>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="→" pending="Asking questions..." complete={questions().length} part={props.part}>
|
||||
Asked {questions().length} question{questions().length === 1 ? "" : "s"}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function Skill(props: ToolProps) {
|
||||
return (
|
||||
<InlineTool icon="→" pending="Loading skill..." complete={toolComplete(props.part)} part={props.part}>
|
||||
Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}"
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Task(props: ToolProps) {
|
||||
const content = createMemo(() => {
|
||||
const description = stringValue(props.input.description)
|
||||
if (!description) return pendingInput(props.part)
|
||||
return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}`
|
||||
})
|
||||
return (
|
||||
<InlineTool
|
||||
icon="│"
|
||||
spinner={props.part.state.status === "running"}
|
||||
complete={toolComplete(props.part)}
|
||||
pending="Delegating..."
|
||||
part={props.part}
|
||||
>
|
||||
{content()}
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function Diagnostics(props: { diagnostics: unknown; filePath: string }) {
|
||||
const normalizePath = usePathNormalizer()
|
||||
const { theme } = useTheme()
|
||||
const errors = createMemo(() => {
|
||||
if (!isRecord(props.diagnostics)) return []
|
||||
const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath]
|
||||
return arrayValue(value)
|
||||
.flatMap((item) => (isRecord(item) ? [item] : []))
|
||||
.filter((diagnostic) => diagnostic.severity === 1)
|
||||
.slice(0, 3)
|
||||
})
|
||||
return (
|
||||
<Show when={errors().length}>
|
||||
<box>
|
||||
<For each={errors()}>
|
||||
{(diagnostic) => <text fg={theme.error}>Error {stringValue(diagnostic.message)}</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function toolOutput(content?: Array<ToolTextContent | ToolFileContent>) {
|
||||
return (content ?? [])
|
||||
.map((item) => {
|
||||
if (item.type === "text") return item.text.trim()
|
||||
const source = item.uri
|
||||
return `[file ${item.name ?? source}]`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function toolInputRecord(input: string | Record<string, unknown>) {
|
||||
if (typeof input === "string") return {}
|
||||
return input
|
||||
}
|
||||
|
||||
function pendingInput(part: SessionMessageAssistantTool) {
|
||||
if (part.state.status !== "pending") return ""
|
||||
return part.state.input.trim()
|
||||
}
|
||||
|
||||
function toolComplete(part: SessionMessageAssistantTool) {
|
||||
if (part.state.status === "pending") return pendingInput(part)
|
||||
return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running"
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" ? value : undefined
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function input(input: Record<string, unknown>, omit?: string[]) {
|
||||
const primitives = Object.entries(input).filter(([key, value]) => {
|
||||
if (omit?.includes(key)) return false
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
})
|
||||
if (primitives.length === 0) return ""
|
||||
return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]`
|
||||
}
|
||||
|
||||
function usePathNormalizer() {
|
||||
const cwd = useTuiPaths().cwd
|
||||
return (input?: string) => normalizePath(input, cwd)
|
||||
}
|
||||
|
||||
function normalizePath(input: string | undefined, cwd: string) {
|
||||
if (!input) return ""
|
||||
const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input)
|
||||
const relative = path.relative(cwd, absolute)
|
||||
if (!relative) return "."
|
||||
if (!relative.startsWith("..")) return relative
|
||||
return absolute
|
||||
}
|
||||
|
||||
function filetype(input?: string) {
|
||||
if (!input) return "none"
|
||||
const language = LANGUAGE_EXTENSIONS[path.extname(input)]
|
||||
if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript"
|
||||
return language
|
||||
}
|
||||
|
||||
function todoIcon(status?: string) {
|
||||
if (status === "completed") return "✓"
|
||||
if (status === "in_progress") return "~"
|
||||
if (status === "cancelled") return "✕"
|
||||
return "☐"
|
||||
}
|
||||
|
||||
function formatAnswer(answer: unknown) {
|
||||
if (!Array.isArray(answer)) return "(no answer)"
|
||||
if (answer.length === 0) return "(no answer)"
|
||||
return answer.filter((item): item is string => typeof item === "string").join(", ")
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
api.route.register([
|
||||
{
|
||||
name: route,
|
||||
render(input) {
|
||||
const sessionID = input.params?.sessionID
|
||||
if (typeof sessionID !== "string") {
|
||||
return <text fg={api.theme.current.error}>Missing sessionID</text>
|
||||
}
|
||||
return <View api={api} sessionID={sessionID} />
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
name: route,
|
||||
title: "View v2 session messages",
|
||||
category: "Debug",
|
||||
namespace: "palette",
|
||||
suggested: () => api.route.current.name === "session",
|
||||
enabled: () => api.route.current.name === "session",
|
||||
run() {
|
||||
const sessionID = currentSessionID(api)
|
||||
if (!sessionID) return
|
||||
api.route.navigate(route, { sessionID })
|
||||
api.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: BuiltinTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
type ParentProps,
|
||||
type Setter,
|
||||
} from "solid-js"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
|
||||
@@ -21,11 +20,10 @@ type Context = {
|
||||
const HomeSessionDestinationContext = createContext<Context>()
|
||||
|
||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||
const sync = useSync()
|
||||
const paths = useTuiPaths()
|
||||
const data = useData()
|
||||
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
||||
const destination = createMemo<HomeSessionDestination>(
|
||||
() => selected() ?? { type: "directory", directory: sync.path.directory || paths.cwd, subdirectory: false },
|
||||
() => selected() ?? { type: "directory", directory: data.location.default().directory, subdirectory: false },
|
||||
)
|
||||
return (
|
||||
<HomeSessionDestinationContext.Provider
|
||||
|
||||
@@ -298,9 +298,7 @@ export function Session() {
|
||||
// workspace may not exist anymore which is why this is not
|
||||
// fatal. If it doesn't we still want to show the session
|
||||
// (which will be non-interactive)
|
||||
try {
|
||||
await sync.bootstrap({ fatal: false })
|
||||
} catch {}
|
||||
await sync.bootstrap()
|
||||
}
|
||||
editor.reconnect(result.data.directory)
|
||||
await sync.session.sync(sessionID)
|
||||
|
||||
@@ -21,31 +21,46 @@ function global(payload: Event): GlobalEvent {
|
||||
return { directory, project: "proj_test", payload }
|
||||
}
|
||||
|
||||
function emitTwice(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||
const event = global(payload)
|
||||
events.emit(event)
|
||||
events.emit(event)
|
||||
function emitEvent(events: ReturnType<typeof createEventSource>, payload: Event) {
|
||||
events.emit(global(payload))
|
||||
}
|
||||
|
||||
test("sync v2 refreshes references after updates", async () => {
|
||||
const events = createEventSource()
|
||||
let requests = 0
|
||||
test("refreshes resources into reactive getters", async () => {
|
||||
const location = {
|
||||
directory,
|
||||
project: { id: "proj_test", directory },
|
||||
}
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/reference") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: requests === 1 ? [] : [{ name: "docs", path: "/docs", source: { type: "local", path: "/docs" } }],
|
||||
})
|
||||
if (url.pathname === "/api/session/ses_test")
|
||||
return json({
|
||||
data: {
|
||||
id: "ses_test",
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Test session",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [
|
||||
{ id: "build", request: { headers: {}, body: {} }, mode: "primary", hidden: false, permissions: [] },
|
||||
],
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useData>
|
||||
const events = createEventSource()
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
@@ -64,10 +79,16 @@ test("sync v2 refreshes references after updates", async () => {
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await wait(() => requests === 1)
|
||||
events.emit(global({ id: "evt_reference_1", type: "reference.updated", properties: {} }))
|
||||
await wait(() => sync.data.reference.length === 1)
|
||||
expect(sync.data.reference[0]?.name).toBe("docs")
|
||||
expect(data.location.default()).toEqual({ directory })
|
||||
expect(data.session.get("ses_test")).toBeUndefined()
|
||||
expect(data.location.agent.list(location)).toBeUndefined()
|
||||
|
||||
await data.session.refresh("ses_test")
|
||||
await data.location.agent.refresh()
|
||||
|
||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
|
||||
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -102,12 +123,12 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_model_1",
|
||||
type: "session.next.model.switched",
|
||||
properties: {
|
||||
@@ -117,7 +138,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
@@ -128,7 +149,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_input_1",
|
||||
type: "session.next.tool.input.started",
|
||||
properties: {
|
||||
@@ -139,7 +160,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
name: "bash",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_called_1",
|
||||
type: "session.next.tool.called",
|
||||
properties: {
|
||||
@@ -152,7 +173,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
provider: { executed: false, metadata: { fake: { call: true } } },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_1",
|
||||
type: "session.next.tool.failed",
|
||||
properties: {
|
||||
@@ -166,7 +187,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
})
|
||||
|
||||
await wait(() => {
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
return (
|
||||
assistant?.type === "assistant" &&
|
||||
assistant.content[0]?.type === "tool" &&
|
||||
@@ -174,7 +195,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
const assistant = sync.session.message.fromSession("session-1")[0]
|
||||
const assistant = sync.session.message.list("session-1")?.[0]
|
||||
expect(assistant?.type).toBe("assistant")
|
||||
if (assistant?.type !== "assistant") return
|
||||
expect(assistant.id).toBe("msg_explicit_assistant_9")
|
||||
@@ -192,7 +213,7 @@ test("sync v2 settles pending tools when a live failure arrives", async () => {
|
||||
metadata: { fake: { call: true } },
|
||||
resultMetadata: { fake: { result: true } },
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.type)).toEqual([
|
||||
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
|
||||
"assistant",
|
||||
"model-switched",
|
||||
"agent-switched",
|
||||
@@ -231,7 +252,7 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_admitted_1",
|
||||
type: "session.next.prompt.admitted",
|
||||
properties: {
|
||||
@@ -242,9 +263,9 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
expect(sync.session.message.fromSession("session-1")).toEqual([])
|
||||
expect(sync.session.message.list("session-1") ?? []).toEqual([])
|
||||
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
@@ -256,8 +277,8 @@ test("sync v2 renders admitted prompts only after promotion", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
const message = sync.session.message.fromSession("session-1")[0]
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
const message = sync.session.message.list("session-1")?.[0]
|
||||
expect(message?.type).toBe("user")
|
||||
if (message?.type !== "user") return
|
||||
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
|
||||
@@ -295,7 +316,7 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
@@ -307,8 +328,8 @@ test("sync v2 renders a promoted prompt when admission was missed", async () =>
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]?.id).toBe("msg_user_1")
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -343,7 +364,7 @@ test("sync v2 projects live context updates with their message ID", async () =>
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
emitEvent(events, {
|
||||
id: "evt_context_1",
|
||||
type: "session.next.context.updated",
|
||||
properties: {
|
||||
@@ -354,8 +375,8 @@ test("sync v2 projects live context updates with their message ID", async () =>
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.fromSession("session-1").length === 1)
|
||||
expect(sync.session.message.fromSession("session-1")[0]).toMatchObject({
|
||||
await wait(() => sync.session.message.list("session-1")?.length === 1)
|
||||
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||
id: "msg_context_1",
|
||||
type: "system",
|
||||
text: "Updated context",
|
||||
@@ -364,256 +385,3 @@ test("sync v2 projects live context updates with their message ID", async () =>
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves live events while snapshot hydration is in flight", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
|
||||
})
|
||||
response.resolve(json({ data: [] }))
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 replaces stale cached rows while preserving in-flight live rows", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_promoted_1",
|
||||
type: "session.next.prompt.promoted",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
messageID: "msg_user_1",
|
||||
timestamp: 1,
|
||||
prompt: { text: "stale" },
|
||||
timeCreated: 0,
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_user_1")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_agent_1",
|
||||
type: "session.next.agent.switched",
|
||||
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 2, agent: "build" },
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_agent_1")
|
||||
response.resolve(
|
||||
json({
|
||||
data: [{ id: "msg_user_1", type: "user", text: "fresh", time: { created: 0 } }],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => [message.id, message.type])).toEqual([
|
||||
["msg_agent_1", "agent-switched"],
|
||||
["msg_user_1", "user"],
|
||||
])
|
||||
expect(sync.session.message.fromSession("session-1")[1]).toMatchObject({ text: "fresh" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sync v2 preserves snapshot order and metadata for in-flight updates", async () => {
|
||||
const events = createEventSource()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/session-1/message") return response.promise
|
||||
return undefined
|
||||
})
|
||||
let sync!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
sync = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
emitTwice(events, {
|
||||
id: "evt_step_older",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 0,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_step_1",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
await wait(() => sync.session.message.fromSession("session-1")[0]?.id === "msg_assistant_old")
|
||||
const hydration = sync.session.message.sync("session-1")
|
||||
emitTwice(events, {
|
||||
id: "evt_text_1",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 2,
|
||||
textID: "text-1",
|
||||
},
|
||||
})
|
||||
emitTwice(events, {
|
||||
id: "evt_text_older",
|
||||
type: "session.next.text.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_older",
|
||||
timestamp: 2,
|
||||
textID: "text-older",
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const messages = sync.session.message.fromSession("session-1")
|
||||
return messages.every((message) => message.type !== "assistant" || message.content[0]?.type === "text")
|
||||
})
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_assistant_new",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_assistant_old",
|
||||
type: "assistant",
|
||||
metadata: { source: "snapshot" },
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await hydration
|
||||
emitTwice(events, {
|
||||
id: "evt_step_late_duplicate",
|
||||
type: "session.next.step.started",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
assistantMessageID: "msg_assistant_old",
|
||||
timestamp: 1,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(sync.session.message.fromSession("session-1").map((message) => message.id)).toEqual([
|
||||
"msg_assistant_new",
|
||||
"msg_assistant_old",
|
||||
"msg_assistant_older",
|
||||
])
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[1]))).toMatchObject({
|
||||
metadata: { source: "snapshot" },
|
||||
content: [{ type: "text", id: "text-1", text: "" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(sync.session.message.fromSession("session-1")[2]))).toMatchObject({
|
||||
content: [{ type: "text", id: "text-older", text: "" }],
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -59,6 +59,13 @@ export function createFetch(override?: FetchHandler) {
|
||||
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (["/api/agent", "/api/model", "/api/provider", "/api/command", "/api/skill"].includes(url.pathname))
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/project/current") return json({ id: "proj_test" })
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] })
|
||||
|
||||
Reference in New Issue
Block a user