Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 691e630ea5 refactor(tui): remove unavailable sharing commands 2026-07-31 21:45:11 +00:00
65 changed files with 1259 additions and 1175 deletions
+4
View File
@@ -88,6 +88,7 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -383,6 +384,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -403,6 +405,7 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -424,6 +427,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
+1
View File
@@ -133,6 +133,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -0,0 +1,5 @@
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
+7
View File
@@ -116,6 +116,13 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
+1
View File
@@ -35,6 +35,7 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
+4
View File
@@ -235,6 +235,10 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+1 -1
View File
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
+3
View File
@@ -56,6 +56,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -99,6 +100,7 @@
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -120,6 +122,7 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
@@ -0,0 +1,181 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "@opencode-ai/util/global"
import { Project } from "../project"
import { Session } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: Project.ID,
actual: Project.ID,
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export type Error =
| Session.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Session.DestinationNotFoundError
| Session.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* Project.Service
const sessions = yield* SessionStore.Service
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (input.moveChanges && current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* session.move({
sessionID: input.sessionID,
directory,
})
if (patch) {
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})
return Service.of({ moveSession })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
FSUtil.node,
Git.node,
Global.node,
Project.node,
Session.node,
SessionStore.node,
SessionExecution.node,
],
})
+3
View File
@@ -148,3 +148,6 @@ export function buildLocationServiceMap(
),
)
}
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
@@ -0,0 +1,94 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "@opencode-ai/util/npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+1
View File
@@ -120,6 +120,7 @@ export const providerLayerWithCell = (cell: Cell) =>
)
export const layer = layerWithCell(defaultCell)
export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+3 -37
View File
@@ -7,7 +7,6 @@ import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "../model"
import { SessionEvent } from "./event"
import type { SessionSchema } from "./schema"
import { SessionV1 } from "../v1/session"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
@@ -445,39 +444,6 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function runAndTouch(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
yield* run(db, event)
yield* touchAncestors(db, event.data.sessionID, DateTime.toEpochMillis(event.created))
})
}
function touchAncestors(
db: DatabaseService,
sessionID: SessionSchema.ID,
updated: number,
seen = new Set<string>(),
): Effect.Effect<void> {
if (seen.has(sessionID)) return Effect.void
seen.add(sessionID)
return Effect.gen(function* () {
const session = yield* db
.select({ parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!session) return
yield* db
.update(SessionTable)
.set({ time_updated: updated })
.where(and(eq(SessionTable.id, sessionID), lt(SessionTable.time_updated, updated)))
.run()
.pipe(Effect.orDie)
if (session.parentID) yield* touchAncestors(db, session.parentID, updated, seen)
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@@ -712,9 +678,9 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)
@@ -0,0 +1,16 @@
export * as ConfigConsoleStateV1 from "./console-state"
import { Schema } from "effect"
import { NonNegativeInt } from "../../schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
}) {}
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
})
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Records the execution serialization a move must perform before relocating.
const executionCalls: string[] = []
const recordingExecution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
Bus.node,
ProjectDirectories.node,
Project.node,
Session.node,
SessionProjector.node,
SessionStore.node,
]),
[[SessionExecution.node, recordingExecution]],
),
)
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.autocrlf false`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
await $`git add tracked.txt`.cwd(directory).quiet()
await $`git commit -m root`.cwd(directory).quiet()
}
describe("MoveSession", () => {
it.live("moves session changes to another project directory", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-move-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move",
directory: source,
title: "move",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
executionCalls.length = 0
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
// The move stops active execution before any relocation side effect.
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: moved, path: "" })
}),
)
it.live("moves within a checkout without transferring existing changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested",
directory: source,
title: "move nested",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
const missing = yield* Session.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
yield* Effect.promise(() => fs.mkdir(destination))
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: destination, path: "packages" })
}),
)
it.live("moves a session to another project", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-other-project`)
yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
)
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-project",
directory: source,
title: "move project",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* Session.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
expect(
yield* db
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ projectID: destinationProjectID, directory: destination })
}),
)
it.live("moves nested session changes without cleaning unrelated files", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const sourceDirectory = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
const destination = abs(`${root.path}-move-nested-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested-checkout",
directory: sourceDirectory,
title: "move nested checkout",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
"initial\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
"staged\n",
)
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
"M packages/staged.txt\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
}),
)
})
+1 -49
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect"
import { asc, eq, inArray, sql } from "drizzle-orm"
import { asc, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { Agent } from "@opencode-ai/core/agent"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -679,54 +679,6 @@ describe("SessionProjector", () => {
}),
)
it.effect("rolls terminal execution activity through every session ancestor", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
const root = Session.ID.make("ses_activity_root")
const child = Session.ID.make("ses_activity_child")
const grandchild = Session.ID.make("ses_activity_grandchild")
yield* db
.insert(SessionTable)
.values(
[
{ id: root },
{ id: child, parent_id: root },
{ id: grandchild, parent_id: child },
].map((session) => ({
...session,
project_id: Project.ID.global,
slug: session.id,
directory: "/project",
title: "test",
version: "test",
time_updated: -1,
})),
)
.run()
.pipe(Effect.orDie)
yield* (yield* Bus.Service).publish(SessionEvent.Execution.Interrupted, {
sessionID: grandchild,
reason: "shutdown",
})
const rows = yield* db
.select({ id: SessionTable.id, updated: SessionTable.time_updated })
.from(SessionTable)
.where(inArray(SessionTable.id, [root, child, grandchild]))
.all()
.pipe(Effect.orDie)
expect(rows).toHaveLength(3)
expect(rows.every((row) => row.updated > -1)).toBe(true)
expect(new Set(rows.map((row) => row.updated)).size).toBe(1)
}),
)
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
-1
View File
@@ -45,7 +45,6 @@ export type {
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
+1
View File
@@ -93,6 +93,7 @@
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+8 -47
View File
@@ -68,15 +68,13 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { SessionInbox } from "./component/session-inbox"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "./context/session-tabs-model"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import * as Model from "./util/model"
@@ -521,8 +519,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const inboxTabsEnabled = () =>
config.data.tabs?.layout === "inbox" && dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -649,7 +645,6 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
slash: { name: "new", aliases: ["clear"] },
run: () => {
sessionTabs.navigation.blur()
route.navigate({
type: "home",
location:
@@ -658,7 +653,6 @@ function App(props: { pair?: DialogPairCredentials }) {
: undefined,
})
dialog.clear()
setTimeout(() => promptRef.current?.focus(), 0)
},
},
{
@@ -678,35 +672,13 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: () => !sessionTabs.enabled(),
run: () => local.session.quickSwitch(i + 1),
})),
{
name: "session.tabs.toggle_layout",
title: !sessionTabs.enabled()
? "Enable inbox tabs"
: config.data.tabs?.layout === "inbox"
? "Move tabs to top"
: "Move tabs to inbox",
category: "Session",
slash: { name: "tabs", aliases: ["tab-layout"] },
run: () => {
void config
.update((draft) => {
draft.tabs = {
...draft.tabs,
enabled: true,
layout: sessionTabs.enabled() && config.data.tabs?.layout === "inbox" ? "top" : "inbox",
}
})
.catch(toast.error)
dialog.clear()
},
},
{
name: "session.tab.next",
title: "Next tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(1, inboxTabsEnabled() ? "recent" : "tabs"),
run: () => sessionTabs.cycle(1),
},
{
name: "session.tab.previous",
@@ -714,7 +686,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycle(-1, inboxTabsEnabled() ? "recent" : "tabs"),
run: () => sessionTabs.cycle(-1),
},
{
name: "session.tab.next_unread",
@@ -722,7 +694,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(1, inboxTabsEnabled() ? "recent" : "tabs"),
run: () => sessionTabs.cycleUnread(1),
},
{
name: "session.tab.previous_unread",
@@ -730,7 +702,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.cycleUnread(-1, inboxTabsEnabled() ? "recent" : "tabs"),
run: () => sessionTabs.cycleUnread(-1),
},
{
name: "session.tab.close",
@@ -752,7 +724,7 @@ function App(props: { pair?: DialogPairCredentials }) {
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
run: () => sessionTabs.selectIndex(i, inboxTabsEnabled() ? "recent" : "tabs"),
run: () => sessionTabs.selectIndex(i),
})),
{
name: "model.list",
@@ -1242,23 +1214,12 @@ function App(props: { pair?: DialogPairCredentials }) {
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show
when={
sessionTabs.enabled() &&
inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<SessionInbox />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show
when={
sessionTabs.enabled() &&
!inboxTabsEnabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
+3
View File
@@ -5,6 +5,7 @@ import type {
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
@@ -114,6 +115,8 @@ export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"]
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
let focus: FocusState = "unknown"
+5 -1
View File
@@ -1,4 +1,4 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import { readFile } from "node:fs/promises"
let audio: Audio | null | undefined
@@ -42,6 +42,10 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
return current.play(sound, options)
}
export function stopVoice(voice: AudioVoice) {
return audio?.stopVoice(voice) ?? false
}
export function dispose() {
audio?.dispose()
audio = undefined
@@ -43,6 +43,15 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["motion", "effects"],
},
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{
title: "Sidebar",
category: "Session",
+4 -10
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
favorite,
description: favorite ? "(Favorite)" : undefined,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,9 +96,7 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -162,10 +160,6 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
import { Locale } from "../util/locale"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "../prompt/stash"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
@@ -0,0 +1 @@
export * from "../../prompt/frecency"
@@ -0,0 +1 @@
export * from "../../prompt/history"
+1 -34
View File
@@ -54,8 +54,6 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "../../context/session-tabs-model"
registerOpencodeSpinner()
@@ -161,7 +159,6 @@ export function Prompt(props: PromptProps) {
const editor = useEditorContext()
const route = useRoute()
const data = useData()
const sessionTabs = useSessionTabs()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const config = useConfig().data
@@ -592,7 +589,7 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || props.disabled || dialog.stack.length > 0 || sessionTabs.navigation.active()) {
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
if (input.focused) input.blur()
return
}
@@ -813,35 +810,6 @@ export function Prompt(props: PromptProps) {
}
})
Keymap.createLayer(() => ({
priority: 2,
target: inputTarget,
enabled:
inputTarget() !== undefined &&
!props.disabled &&
store.mode === "normal" &&
!auto()?.visible &&
config.tabs?.layout === "inbox" &&
dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH &&
sessionTabs.enabled() &&
sessionTabs.tabs().length > 0 &&
store.prompt.text === "" &&
store.prompt.pasted.length === 0 &&
(store.prompt.files?.length ?? 0) === 0 &&
(store.prompt.agents?.length ?? 0) === 0,
commands: [
{
bind: "left",
title: "Focus session inbox",
group: "Session",
run: () => {
if (!sessionTabs.navigation.focus()) return false
input.blur()
},
},
],
}))
Keymap.createLayer(() => {
return {
target: inputTarget,
@@ -1487,7 +1455,6 @@ export function Prompt(props: PromptProps) {
}}
onMouseDown={(r: MouseEvent) => {
if (props.disabled || r.button !== 0) return
sessionTabs.navigation.blur()
r.target?.focus()
const extmark = input.extmarks
.getAtOffset(input.cursorOffset)
@@ -0,0 +1 @@
export * from "../../prompt/stash"
@@ -1,320 +0,0 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { SessionMessageAssistant } from "@opencode-ai/client"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { usePromptRef } from "../context/prompt"
import { useSessionTabs } from "../context/session-tabs"
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { getScrollAcceleration } from "../util/scroll"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { activityVerb } from "../util/activity-verb"
import { markdownPreview } from "../util/markdown-preview"
import { Spinner } from "./spinner"
import type { SessionTabsStatus } from "./session-tabs"
const labels: Record<SessionInboxGroup, string> = {
running: "Active",
today: "Today",
yesterday: "Yesterday",
earlier: "Earlier",
}
type SessionInboxRowInfo = {
sessionID: string
title: string
updated: number
preview: string
status: SessionTabsStatus
group: SessionInboxGroup
}
function SessionInboxRow(props: {
row: SessionInboxRowInfo
selected?: boolean
focused?: boolean
pendingDone?: boolean
number?: number
verb: string
onSelect?: () => void
}) {
const theme = useTheme("elevated")
const themes = useThemes()
const [hovered, setHovered] = createSignal(false)
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const background = () => {
if (props.focused) return theme.background.action.primary.hovered
if (props.selected) return theme.background.surface.offset
if (hovered()) return theme.background.action.primary.hovered
return theme.background.default
}
const feedback = () => {
if (props.row.status.attention) return theme.text.feedback.warning.default
if (props.row.status.unread === "error") return theme.text.feedback.error.default
if (props.row.status.unread) return accent()
return theme.text.subdued
}
return (
<box
height={4}
id={`session-inbox-${props.row.sessionID}`}
flexShrink={0}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
backgroundColor={background()}
border={["left"]}
borderColor={props.selected || props.focused ? accent() : background()}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
onMouseUp={props.onSelect}
>
<box height={2} flexDirection="row">
<Show when={props.number}>
{(number) => (
<text
width={String(number()).length + 1}
fg={props.selected || props.focused ? accent() : theme.text.subdued}
selectable={false}
>
{number()}
</text>
)}
</Show>
<box height={2} flexGrow={1} minWidth={0}>
<box height={1} flexDirection="row">
<text
flexGrow={1}
flexShrink={1}
fg={theme.text.default}
attributes={props.selected || props.focused ? TextAttributes.BOLD : undefined}
wrapMode="none"
truncate
selectable={false}
>
{props.row.title}
</text>
<Show when={props.row.status.attention || (!props.row.status.busy && props.row.status.unread)}>
<text width={2} fg={feedback()} selectable={false}>
</text>
</Show>
</box>
<box height={1}>
<Switch
fallback={
<text
height={1}
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
wrapMode="none"
truncate
selectable={false}
>
{props.row.preview}
</text>
}
>
<Match when={props.pendingDone}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Space again to mark done
</text>
</Match>
<Match when={props.row.status.attention}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Action required
</text>
</Match>
<Match when={props.row.status.busy}>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb}</span>
</Spinner>
</Match>
</Switch>
</box>
</box>
</box>
</box>
)
}
export function SessionInbox() {
const tabs = useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const themes = useThemes()
const config = useConfig().data
const prompt = usePromptRef()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
let scroll: ScrollBoxRenderable
const [verbCycle, setVerbCycle] = createSignal(0)
const [groupClock, setGroupClock] = createSignal(Date.now())
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
onCleanup(() => {
clearInterval(verbTimer)
clearInterval(groupTimer)
tabs.navigation.blur()
})
const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => {
return tabs
.recent()
.map((tab) => {
const session = data.session.get(tab.sessionID)
const status = tabs.status(tab.sessionID)
const assistant = data.session.message
.list(tab.sessionID)
.findLast(
(message): message is SessionMessageAssistant =>
message.type === "assistant" && (!session?.revert?.messageID || message.id < session.revert.messageID),
)
const preview = assistant?.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
const updated = tabs.updated(tab.sessionID)
return {
sessionID: tab.sessionID,
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet",
status,
group: sessionInboxGroup(updated, status.busy, groupClock()),
}
})
})
const groups = createMemo(() =>
(["running", "today", "yesterday", "earlier"] as const)
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
.filter((group) => group.rows.length > 0),
)
const numbers = createMemo(() => new Map(rows().map((row, index) => [row.sessionID, index + 1])))
createEffect(() => {
if (!tabs.navigation.active()) return
const sessionID = tabs.navigation.selected()
if (!sessionID || !scroll || scroll.isDestroyed) return
const row = scroll.getRenderable(`session-inbox-${sessionID}`)
if (!row) return
const top = scroll.scrollTop + row.y - scroll.viewport.y
const bottom = top + row.height
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
})
const leave = () => {
tabs.navigation.blur()
prompt.current?.focus()
}
const newSession = () => {
keymap.dispatch("session.new")
}
Keymap.createLayer(() => ({
mode: "global",
priority: 2,
enabled: tabs.navigation.active,
commands: [
{
bind: "up,shift+tab",
title: "Previous session",
group: "Session",
run: () => tabs.navigation.move(-1),
},
{
bind: "down,tab",
title: "Next session",
group: "Session",
run: () => tabs.navigation.move(1),
},
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
{ bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
],
}))
return (
<box
width={width()}
height="100%"
flexShrink={0}
flexDirection="column"
paddingRight={1}
backgroundColor={theme.background.default}
>
<box height={3} flexShrink={0} paddingLeft={2} paddingRight={1} flexDirection="row" alignItems="center">
<text flexGrow={1} fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<text
fg={theme.text.subdued}
onMouseUp={newSession}
selectable={false}
>
opt+T
</text>
</box>
<scrollbox
ref={(value) => (scroll = value)}
flexGrow={1}
scrollAcceleration={getScrollAcceleration(config)}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background.default,
foregroundColor: theme.scrollbar.default,
},
}}
>
<box flexShrink={0} paddingBottom={1}>
<Show when={groups().length > 0} fallback={<text marginLeft={2} fg={theme.text.subdued}>No open sessions</text>}>
<For each={groups()}>
{(group) => (
<box flexShrink={0}>
<box height={2} paddingLeft={2} paddingRight={1} alignItems="center" flexDirection="row">
<text flexGrow={1} fg={group.group === "running" ? accent() : theme.text.subdued}>
{labels[group.group]}
</text>
<text fg={theme.text.subdued}>{group.rows.length}</text>
</box>
<For each={group.rows}>
{(row) => (
<SessionInboxRow
row={row}
selected={tabs.current() === row.sessionID}
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
number={numbers().get(row.sessionID)}
verb={activityVerb(row.sessionID, verbCycle())}
onSelect={() => {
tabs.navigation.blur()
tabs.select(row.sessionID)
}}
/>
)}
</For>
</box>
)}
</For>
</Show>
</box>
</scrollbox>
<box height={2} flexShrink={0} paddingLeft={2} alignItems="center">
<text fg={theme.text.subdued} wrapMode="none" truncate>
{tabs.navigation.active() ? "↑↓/tab choose · enter open · space done" : "← empty prompt · /tabs layout"}
</text>
</box>
</box>
)
}
+2 -24
View File
@@ -6,7 +6,6 @@ import { useSessionTabs } from "../context/session-tabs"
import { useTheme, useThemes } from "../context/theme"
import {
adaptiveSessionTabLayout,
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
seedSessionTabMotion,
@@ -49,10 +48,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
let strip: { screenX: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
@@ -60,18 +55,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
@@ -295,8 +279,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
@@ -313,8 +295,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
onMouseDragEnd={release}
>
@@ -360,9 +341,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
+9 -3
View File
@@ -132,9 +132,6 @@ export const Info = Schema.Struct({
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
}),
layout: Schema.optional(Schema.Literals(["top", "inbox"])).annotate({
description: "Show tabs as a top strip or a grouped inbox rail",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
@@ -165,6 +162,11 @@ export const Info = Schema.Struct({
}),
}),
).annotate({ description: "Mini transcript presentation settings" }),
hints: Schema.optional(
Schema.Struct({
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
}),
).annotate({ description: "In-product guidance settings" }),
debug: Schema.optional(
Schema.Struct({
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
@@ -265,3 +267,7 @@ export function useConfig() {
if (!value) throw new Error("ConfigProvider is missing")
return value
}
export function useConfigOptional() {
return useContext(ConfigContext)
}
+14 -14
View File
@@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"),
session_new: keybind("alt+t,<leader>n", "Create a new session"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),
@@ -98,13 +98,13 @@ export const Definitions = {
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_background: keybind("ctrl+b", "Background blocking session tools"),
session_compact: keybind("<leader>c", "Compact the session"),
session_queued_prompts: keybind("<leader>q", "View pending work"),
session_child_first: keybind("down", "Toggle subagent picker"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
@@ -116,15 +116,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1,alt+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2,alt+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3,alt+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4,alt+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5,alt+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6,alt+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7,alt+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8,alt+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9,alt+9", "Switch to tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -299,13 +299,13 @@ export const CommandMap = {
session_fork: "session.fork",
session_rename: "session.rename",
session_delete: "session.delete",
session_share: "session.share",
session_unshare: "session.unshare",
session_interrupt: "session.interrupt",
session_background: "session.background",
session_compact: "session.compact",
session_queued_prompts: "session.queued_prompts",
session_child_first: "session.child.first",
session_child_cycle: "session.child.next",
session_child_cycle_reverse: "session.child.previous",
session_parent: "session.parent",
session_pin_toggle: "session.pin.toggle",
session_quick_switch_1: "session.quick_switch.1",
+3 -37
View File
@@ -22,34 +22,6 @@ export const SESSION_TAB_MIN_WIDTH = 8
// Overflow markers reserve one gap cell beside the arrow and count, e.g. "12 " and " 12".
export const sessionTabOverflowWidth = (count: number) => String(count).length + 2
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
export const SESSION_INBOX_MIN_TERMINAL_WIDTH = 72
export function sessionInboxWidth(terminalWidth: number) {
return Math.max(28, Math.min(40, Math.floor(terminalWidth * 0.28)))
}
export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
if (running) return "running"
const today = new Date(now)
today.setHours(0, 0, 0, 0)
if (updated >= today.getTime()) return "today"
today.setDate(today.getDate() - 1)
if (updated >= today.getTime()) return "yesterday"
return "earlier"
}
export function orderSessionTabs(
tabs: readonly SessionTab[],
status: (sessionID: string) => { busy: boolean; updated: number },
) {
return tabs
.map((tab, index) => ({ tab, index, ...status(tab.sessionID) }))
.toSorted((a, b) => Number(b.busy) - Number(a.busy) || b.updated - a.updated || a.index - b.index)
.map((item) => item.tab)
}
export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[] {
const index = tabs.findIndex((item) => item.sessionID === tab.sessionID)
if (index === -1) return [...tabs, tab]
@@ -57,11 +29,9 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
}
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
// detect it by identity.
if (index === -1) return { tabs, next: undefined }
if (index === -1) return { tabs: [...tabs], next: undefined }
return {
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
@@ -117,13 +87,9 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
return tabs[(start + direction + tabs.length) % tabs.length]
}
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
// session switch forever; the oldest entries fall off first.
const SESSION_TAB_HISTORY_LIMIT = 100
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
if (history.entries[history.index] === sessionID) return history
const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
return { entries, index: entries.length - 1 }
}
+13 -131
View File
@@ -16,7 +16,6 @@ import {
moveSessionTabHistory,
NEW_SESSION_TAB_TITLE,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
@@ -62,10 +61,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
const [lastActivity, setLastActivity] = createSignal<Record<string, number>>({})
const [navigationActive, setNavigationActive] = createSignal(false)
const [navigationSelection, setNavigationSelection] = createSignal<string>()
const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
@@ -78,35 +73,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global"
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),
() => {},
)
}
const root = (sessionID: string) => data.session.root(sessionID)
const updated = (sessionID: string) => {
const session = root(sessionID)
const members = data.session.family(session)
return (members.length > 0 ? members : [session]).reduce(
(latest, id) =>
Math.max(
latest,
data.session.get(id)?.time.updated ?? 0,
lastActivity()[id] ?? 0,
),
0,
)
}
const touch = (sessionID: string, created: number) => {
if (!enabled()) return
if ((lastActivity()[sessionID] ?? 0) >= created) return
setLastActivity((value) => ({ ...value, [sessionID]: Math.max(value[sessionID] ?? 0, created) }))
}
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
}
const normalize = (value: TabsState): TabsState => ({
const normalize = (value: TabsState) => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
@@ -137,11 +113,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}
const recent = () =>
orderSessionTabs(state().tabs, (sessionID) => ({
busy: status(sessionID).busy,
updated: updated(sessionID),
}))
function markUnread(sessionID: string, unread: SessionTabUnread) {
if (!enabled()) return
@@ -174,20 +145,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
createEffect(() => {
if (!navigationActive()) return
const tabs = state().tabs
if (tabs.length === 0) {
setNavigationActive(false)
setNavigationSelection(undefined)
setNavigationPendingDone(undefined)
return
}
if (tabs.some((tab) => tab.sessionID === navigationSelection())) return
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
})
createEffect(() => {
if (!enabled()) return
const next = normalize(state())
@@ -224,7 +181,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID, { children: true }),
data.session.sync(sessionID),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
@@ -238,28 +195,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(
event.on("session.execution.succeeded", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.interrupted", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.failed", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "error")
}),
)
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
touch(evt.data.sessionID, evt.created)
const sessionID = root(evt.data.sessionID)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
@@ -281,7 +222,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
function remove(sessionID: string, navigate: boolean) {
const target = root(sessionID)
const closed = closeSessionTab(state().tabs, target)
if (closed.tabs === state().tabs) return
if (closed.tabs.length === state().tabs.length) return
const selected = navigate && current() === target
const previous = selected
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
@@ -298,14 +239,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
setLastActivity((activity) => {
const family = data.session.family(target)
const members = family.length > 0 ? family : [target]
if (!members.some((id) => activity[id] !== undefined)) return activity
const next = { ...activity }
for (const id of members) delete next[id]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@@ -314,8 +247,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
recent,
updated,
newTab() {
return newTab()
},
@@ -359,74 +290,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
draft.tabs = moveSessionTab(draft.tabs, session, index)
})
},
cycle(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
cycle(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(order === "recent" ? recent() : state().tabs, current(), direction)
const tab = cycleSessionTab(state().tabs, current(), direction)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
cycleUnread(direction: 1 | -1, order: "tabs" | "recent" = "tabs") {
cycleUnread(direction: 1 | -1) {
if (!enabled()) return
const tab = cycleSessionTab(
(order === "recent" ? recent() : state().tabs).filter(
(tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention,
),
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
current(),
direction,
)
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
selectIndex(index: number, order: "tabs" | "recent" = "tabs") {
selectIndex(index: number) {
if (!enabled()) return
const tab = (order === "recent" ? recent() : state().tabs)[index]
const tab = state().tabs[index]
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
},
navigation: {
active: navigationActive,
selected: navigationSelection,
pendingDone: navigationPendingDone,
focus() {
if (!enabled() || state().tabs.length === 0) return false
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
setNavigationActive(true)
return true
},
blur() {
setNavigationActive(false)
setNavigationPendingDone(undefined)
},
move(direction: 1 | -1) {
const tabs = recent().map((tab) => tab.sessionID)
if (!navigationActive() || tabs.length === 0) return
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
setNavigationSelection(tabs[(start + direction + tabs.length) % tabs.length])
setNavigationPendingDone(undefined)
},
select() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
setNavigationActive(false)
setNavigationPendingDone(undefined)
route.navigate({ type: "session", sessionID })
},
done() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
if (navigationPendingDone() !== sessionID) {
setNavigationPendingDone(sessionID)
return
}
const tabs = recent().map((tab) => tab.sessionID)
const index = tabs.indexOf(sessionID)
const next = tabs[index + 1] ?? tabs[index - 1]
const selected = current() === sessionID
setNavigationPendingDone(undefined)
remove(sessionID, true)
setNavigationSelection(selected ? (current() ?? next) : next)
if (!next) setNavigationActive(false)
},
},
}
},
})
+9
View File
@@ -194,6 +194,10 @@ export function resolveZedDbPath() {
return candidates.find((item) => isFile(item))
}
export function isZedTerminal() {
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
}
function isFile(item: string) {
try {
return statSync(item).isFile()
@@ -216,6 +220,11 @@ function zedWorkspacePaths(value: string | null) {
return value.split(/\r?\n/).filter(Boolean)
}
export function offsetToPosition(text: string, offset: number) {
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
return offsetsToSelection(text, stringOffset, stringOffset).start
}
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
if (byteOffset <= 0) return 0
@@ -0,0 +1,21 @@
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
function View() {
const context = usePlugin()
const theme = context.theme
return (
<box>
<text fg={theme.text.default}>
<b>LSP</b>
</text>
<text fg={theme.text.subdued}>LSP status unavailable</text>
</box>
)
}
export default Plugin.define({
id: "opencode.sidebar-lsp",
setup(context) {
context.ui.slot("sidebar.content", () => <View />)
},
})
@@ -141,6 +141,22 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
}
export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[],
selected: number | undefined,
offset: number,
) {
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
if (fileRows.length === 0) return undefined
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
const next =
offset < 0
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
}
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
if (!node) return undefined
+2
View File
@@ -7,3 +7,5 @@ export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"
+2
View File
@@ -2,6 +2,7 @@ import HomeFooter from "../feature-plugins/home/footer"
import PromptFooter from "../feature-plugins/prompt/footer"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
@@ -13,6 +14,7 @@ export const builtins = [
PromptFooter,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFooter,
Notifications,
Plugins,
+175 -29
View File
@@ -73,8 +73,6 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH, sessionInboxWidth } from "../../context/session-tabs-model"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@@ -192,7 +190,6 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const sessionTabs = useSessionTabs()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
@@ -202,20 +199,14 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const viewportWidth = createMemo(() => {
const width = dimensions().width
if (!sessionTabs.enabled() || config.tabs?.layout !== "inbox" || width < SESSION_INBOX_MIN_TERMINAL_WIDTH)
return width
return width - sessionInboxWidth(width)
})
const wide = createMemo(() => viewportWidth() > 120)
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => viewportWidth() - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -290,6 +281,7 @@ export function Session() {
return
}
editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
setSynced(true)
})().catch((error) => {
if (route.sessionID !== sessionID) return
@@ -517,14 +509,6 @@ export function Session() {
]
const baseCommands = createMemo(() => [
{
title: "Share session",
id: "session.share",
suggested: route.type === "session",
group: "Session",
slash: { name: "share" },
run: () => unavailable("Sharing"),
},
{
title: "Rename session",
id: "session.rename",
@@ -577,14 +561,6 @@ export function Session() {
dialog.clear()
},
},
{
title: "Unshare session",
id: "session.unshare",
group: "Session",
enabled: false,
slash: { name: "unshare" },
run: () => unavailable("Unsharing"),
},
{
title: "Undo previous message",
id: "session.undo",
@@ -895,6 +871,22 @@ export function Session() {
dialog.clear()
},
},
{
title: "Next subagent",
id: "session.child.next",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
{
title: "Previous subagent",
id: "session.child.previous",
group: "Session",
palette: undefined,
enabled: !!session()?.parentID,
run: () => unavailable("Subagent navigation"),
},
])
const commands = createMemo(() =>
@@ -923,8 +915,8 @@ export function Session() {
bindings: [...baseAndUnfocusedCommands, ...baseCommands()].map((command) => command.id),
}))
// The keyed Session remount and stickyStart="bottom" establish the initial position before draw.
// A deferred scroll here causes a visible second jump whenever tabs switch.
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))
createEffect(
on(
() => route.sessionID,
@@ -1917,6 +1909,122 @@ function UserMessage(props: { message: SessionMessageUser }) {
)
}
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
const ctx = use()
const local = useLocal()
const theme = useTheme("elevated")
const model = createMemo(
() =>
ctx
.models()
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
)
const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
})
const duration = createMemo(() => {
if (!final()) return 0
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
})
const exploration = createMemo(() => {
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
if (!ctx.groupExploration()) return grouped
const runs = props.message.content
.map((part) =>
part.type === "tool" &&
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
part.state.status !== "streaming"
? part
: undefined,
)
.reduce<SessionMessageAssistantTool[][]>(
(runs, part) => {
if (part) runs[runs.length - 1].push(part)
if (!part && runs[runs.length - 1].length) runs.push([])
return runs
},
[[]],
)
.filter((run) => run.length > 0)
for (const run of runs) {
const summary = {
parts: run,
active: false,
}
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
}
return grouped
})
return (
<>
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.background.default}
customBorderChars={SplitBorder.customBorderChars}
borderColor={theme.text.feedback.error.default}
>
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
</Switch>
</>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const theme = useTheme()
return (
@@ -1932,6 +2040,40 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
)
}
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
const theme = useTheme()
const pathFormatter = usePathFormatter()
const label = (part: SessionMessageAssistantTool) => {
const input = typeof part.state.input === "string" ? {} : part.state.input
const tool = toolDisplay(part.name)
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
return `Grep "${stringValue(input.pattern)}"`
}
return (
<box flexDirection="column">
<InlineToolRow
icon="✱"
color={theme.text.subdued}
complete={!props.active}
pending="Exploring"
spinner={props.active}
>
{props.active ? "Exploring" : "Explored"}
</InlineToolRow>
<For each={props.parts}>
{(part, index) => (
<box paddingLeft={5}>
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
</text>
</box>
)}
</For>
</box>
)
}
const INLINE_TOOL_ICON_WIDTH = 2
function ReasoningPart(props: {
@@ -2796,6 +2938,10 @@ export function isBackgroundSubagent(
return status === "completed" && metadata.status === "running"
}
export function formatSubagentRetry(attempt: number, message: string) {
return `Retrying (attempt ${attempt}) · ${message}`
}
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
function executeCalls(value: unknown): ExecuteCall[] {
+10 -1
View File
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
import { useDialog, type DialogContext } from "./dialog"
export type DialogAlertProps = {
title: string
@@ -56,3 +56,12 @@ export function DialogAlert(props: DialogAlertProps) {
</box>
)
}
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
return new Promise<void>((resolve) => {
dialog.replace(
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
() => resolve(),
)
})
}
+20 -1
View File
@@ -1,7 +1,7 @@
import { TextAttributes } from "@opentui/core"
import { Keymap } from "../context/keymap"
import { useTheme } from "../context/theme"
import { useDialog } from "./dialog"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { Locale } from "../util/locale"
@@ -17,6 +17,8 @@ export type DialogConfirmProps = {
}
}
export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog()
const theme = useTheme("elevated")
@@ -91,3 +93,20 @@ export function DialogConfirm(props: DialogConfirmProps) {
</box>
)
}
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
<DialogConfirm
title={title}
message={message}
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
label={label}
/>
),
() => resolve(undefined),
)
})
}
+56
View File
@@ -0,0 +1,56 @@
import type { RGBA } from "@opentui/core"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useConfig } from "../config"
import { tint } from "../theme/color"
import { createAnimatable, tween } from "./animation"
import { FilePath } from "./file-path"
// FilePath that crossfades when its value changes: the old path fades to the
// background, the text swaps at the midpoint, and the new path fades back in.
// The initial value renders immediately; only subsequent changes animate.
export function FadeFilePath(props: {
value: string | undefined
maxWidth: number
fg: RGBA
bg: RGBA
basenameFg?: RGBA
}) {
const config = useConfig().data
const fade = createAnimatable(
{ front: 1 },
{
enabled: () => config.animations ?? true,
transition: tween({ duration: 0.3 }),
},
)
const [text, setText] = createSignal(props.value)
const [previous, setPrevious] = createSignal<string>()
createEffect((current: string | undefined) => {
const next = props.value
if (next === undefined || next === current) return current
setText(next)
if (current === undefined) return next
setPrevious(current)
fade.jump({ front: 0 })
fade.animate({ front: 1 })
return next
}, props.value)
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
const fg = createMemo(() => {
if (previous() === undefined || fade.value().front >= 1) return props.fg
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
})
return (
<Show when={display() !== undefined}>
<FilePath
value={display() ?? ""}
maxWidth={props.maxWidth}
fg={fg()}
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
/>
</Show>
)
}
-67
View File
@@ -1,67 +0,0 @@
export const ACTIVITY_VERBS = [
"mogging",
"glazing",
"larping",
"canoodling",
"scheming",
"fuming",
"coping",
"seething",
"plotting",
"yapping",
"fumbling",
"malding",
"grandstanding",
"doomscrolling",
"clowning",
"gremlining",
"goblining",
"waffling",
"dillydallying",
"catastrophizing",
"overthinking",
"underdelivering",
"vibecoding",
"yak-shaving",
"bikeshedding",
"nitpicking",
"procrastinating",
"overengineering",
"spaghettiweaving",
"bugfarming",
"cachepoisoning",
"stacktracing",
"tokenburning",
"contextstuffing",
"promptwrangling",
"diffdivining",
"lintworshipping",
"testdodging",
"mergebegging",
"branchhaunting",
"commitcosplaying",
"semicolonhoarding",
"typesquinting",
"regexsummoning",
"dependencyjuggling",
"logstaring",
"packetwhispering",
"daemonpoking",
"terminalpeacocking",
"sandboxrattling",
"copypasting",
"tabcollecting",
"scopecreeping",
"deadlinehaunting",
"yakstacking",
"slopshoveling",
"codefermenting",
"pixelslandering",
"buildgaslighting",
"syntaxgrooming",
] as const
export function activityVerb(key: string, offset = 0) {
const hash = Array.from(key).reduce((value, character) => (value * 31 + character.charCodeAt(0)) >>> 0, 0)
return ACTIVITY_VERBS[(hash + offset) % ACTIVITY_VERBS.length]
}
+37
View File
@@ -143,3 +143,40 @@ export function errorMessage(error: unknown): string {
if (formatted) return formatted
return "unknown error"
}
export function errorData(error: unknown) {
if (error instanceof Error) {
return {
type: error.name,
message: errorMessage(error),
stack: error.stack,
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
formatted: errorFormat(error),
}
}
if (!isRecord(error)) {
return {
type: typeof error,
message: errorMessage(error),
formatted: errorFormat(error),
}
}
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
const value = error[key]
if (value === undefined) return acc
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
acc[key] = value
return acc
}
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
acc[key] = value instanceof Error ? value.message : String(value)
return acc
}, {})
if (typeof data.message !== "string") data.message = errorMessage(error)
if (typeof data.type !== "string") data.type = error.constructor?.name
data.formatted = errorFormat(error)
return data
}
+20
View File
@@ -0,0 +1,20 @@
export function formatDuration(secs: number) {
if (secs <= 0) return ""
if (secs < 60) return `${secs}s`
if (secs < 3600) {
const mins = Math.floor(secs / 60)
const remaining = secs % 60
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
}
if (secs < 86400) {
const hours = Math.floor(secs / 3600)
const remaining = Math.floor((secs % 3600) / 60)
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
}
if (secs < 604800) {
const days = Math.floor(secs / 86400)
return days === 1 ? "~1 day" : `~${days} days`
}
const weeks = Math.floor(secs / 604800)
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
}
+18
View File
@@ -16,6 +16,19 @@ export function datetime(input: number): string {
return `${localTime} · ${localDate}`
}
export function todayTimeOrDateTime(input: number): string {
const date = new Date(input)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
if (isToday) {
return time(input)
} else {
return datetime(input)
}
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
@@ -95,4 +108,9 @@ export function truncateMiddle(str: string, maxLength: number = 35): string {
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
}
export function pluralize(count: number, singular: string, plural: string): string {
const template = count === 1 ? singular : plural
return template.replace("{}", count.toString())
}
export * as Locale from "./locale"
-17
View File
@@ -1,17 +0,0 @@
export function markdownPreview(input: string) {
return input
.replace(/```[^\n]*\n?([\s\S]*?)```/g, "$1")
.replace(/`([^`\n]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\((?:\\.|[^)])*\)/g, "$1")
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
.replace(/^ {0,3}(?:#{1,6}\s+|>\s?|(?:[-+*]|\d+[.)])\s+)/gm, "")
.replace(/(?<!\\)~~(?=\S)([\s\S]*?\S)(?<!\\)~~/g, "$1")
.replace(/(?<!\\)\*\*(?=\S)([\s\S]*?\S)(?<!\\)\*\*/g, "$1")
.replace(/(?<!\\)__(?=\S)([\s\S]*?\S)(?<!\\)__/g, "$1")
.replace(/(?<![\\*])\*(?=\S)([^*\n]*?\S)(?<!\\)\*(?!\*)/g, "$1")
.replace(/(?<![\\_])_(?=\S)([^_\n]*?\S)(?<!\\)_(?!_)/g, "$1")
.replace(/\\([\\`*_[\]{}()#+\-.!>|~])/g, "$1")
.replace(/\s+/g, " ")
.trim()
}
+4 -1
View File
@@ -1,4 +1,7 @@
import { logo } from "../logo"
const logo = {
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
const reset = "\x1b[0m"
const bold = "\x1b[1m"
+18
View File
@@ -0,0 +1,18 @@
import { parsePatch } from "diff"
export function getRevertDiffFiles(diffText: string) {
if (!diffText) return []
try {
return parsePatch(diffText).map((patch) => {
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
return {
filename: filename.replace(/^[ab]\//, ""),
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
}
})
} catch {
return []
}
}
@@ -1,23 +1,5 @@
import { describe, expect, test } from "bun:test"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("moves favorites first while preserving fuzzy result order", () => {
const prioritized = prioritizeFavorites([
{ title: "Best match", favorite: false },
{ title: "Favorite match", favorite: true },
{ title: "Second best match", favorite: false },
{ title: "Second favorite match", favorite: true },
])
expect(prioritized.map((model) => model.title)).toEqual([
"Favorite match",
"Second favorite match",
"Best match",
"Second best match",
])
})
})
import { sortModelOptions } from "../../../../src/component/dialog-model"
describe("sortModelOptions", () => {
test("orders opencode models before other providers", () => {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatSubagentRetry,
InlineToolRow,
isBackgroundSubagent,
parseApplyPatchFiles,
@@ -197,6 +198,10 @@ describe("TUI inline tool wrapping", () => {
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
})
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("labels only detached or async subagents as background", () => {
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
+1 -4
View File
@@ -17,11 +17,8 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "inbox" } })).toEqual({
tabs: { enabled: true, layout: "inbox" },
})
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(() => decode({ tabs: { layout: "sidebar" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
+1 -3
View File
@@ -117,11 +117,9 @@ test("navigates session tabs with leader arrows", () => {
test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,<leader>n" }])
expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1,alt+1" }])
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
})
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
@@ -6,49 +6,15 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
orderSessionTabs,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionInboxGroup,
sessionInboxWidth,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("orders running sessions first and completed sessions by recent update", () => {
const tabs = ["old", "running-old", "new", "running-new"].map((sessionID) => ({ sessionID }))
const state = {
old: { busy: false, updated: 10 },
"running-old": { busy: true, updated: 20 },
new: { busy: false, updated: 40 },
"running-new": { busy: true, updated: 30 },
}
expect(orderSessionTabs(tabs, (sessionID) => state[sessionID as keyof typeof state]).map((tab) => tab.sessionID)).toEqual([
"running-new",
"running-old",
"new",
"old",
])
})
test("groups inbox tabs by running state and local calendar day", () => {
const now = new Date(2026, 6, 31, 12).getTime()
expect(sessionInboxGroup(new Date(2026, 6, 20).getTime(), true, now)).toBe("running")
expect(sessionInboxGroup(new Date(2026, 6, 31, 1).getTime(), false, now)).toBe("today")
expect(sessionInboxGroup(new Date(2026, 6, 30, 1).getTime(), false, now)).toBe("yesterday")
expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
})
test("sizes the inbox within its minimum and maximum rails", () => {
expect(sessionInboxWidth(72)).toBe(28)
expect(sessionInboxWidth(120)).toBe(33)
expect(sessionInboxWidth(200)).toBe(40)
})
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
@@ -114,11 +80,6 @@ describe("session tabs", () => {
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
})
test("closing an unknown session returns the same tabs reference", () => {
const tabs = [{ sessionID: "a" }, { sessionID: "b" }]
expect(closeSessionTab(tabs, "missing").tabs).toBe(tabs)
})
test("cycles through a filtered tab set in either direction", () => {
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
@@ -161,15 +122,6 @@ describe("session tabs", () => {
expect(recordSessionTabHistory(history, "b")).toBe(history)
})
test("drops the oldest history entries beyond the limit", () => {
const sessions = Array.from({ length: 150 }, (_, index) => `session-${index}`)
const history = sessions.reduce(recordSessionTabHistory, { entries: [], index: -1 })
expect(history.entries.length).toBe(100)
expect(history.entries[0]).toBe("session-50")
expect(history.entries[history.index]).toBe("session-149")
})
test("returns to the latest history entry when no tab is active", () => {
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
+15 -174
View File
@@ -1,8 +1,8 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, expect, test } from "bun:test"
import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdtempSync, readdirSync, rmSync, watch } from "fs"
import { mkdtempSync, rmSync, watch } from "fs"
import { tmpdir } from "os"
import path from "path"
import { ConfigProvider } from "../../src/config"
@@ -25,67 +25,22 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
}
}
// State directories are removed after the whole suite instead of per test: persistence writes are
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
const stateDirs: string[] = []
afterAll(async () => {
for (const dir of stateDirs) {
// Drain any lock still held by a late write before deleting the tree beneath it.
await wait(() => {
try {
return readdirSync(path.join(dir, "test", "locks")).length === 0
} catch {
return true
}
}).catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
function stateDir(prefix: string) {
const dir = mkdtempSync(path.join(tmpdir(), prefix))
stateDirs.push(dir)
return dir
}
type SessionFixture = { parentID?: string; title?: string; updated?: number }
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; sessions?: Record<string, SessionFixture> },
) {
const state = options?.state ?? stateDir("opencode-session-tabs-")
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
fixture.parentID === parentID ? [session(id, fixture)] : [],
),
cursor: {},
})
}
const match = /^\/api\/session\/([^/]+)$/.exec(url.pathname)
if (!match) return
const id = match[1]!
const fixture = options?.sessions?.[id] ?? (id === initialSessionID ? { title: options?.title } : undefined)
if (!fixture) return
return json({ data: session(id, fixture) })
function session(id: string, fixture: SessionFixture) {
return {
id,
parentID: fixture.parentID,
title: fixture.title,
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: initialSessionID,
title: options?.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: fixture.updated ?? 0 },
}
}
time: { created: 0, updated: 0 },
},
})
}, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
@@ -129,6 +84,7 @@ async function renderSessionTabs(
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
if (!options?.state) rmSync(state, { recursive: true, force: true })
},
}
}
@@ -149,7 +105,7 @@ test("stores session tabs globally by default", async () => {
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
const state = stateDir("opencode-session-tabs-shared-")
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
@@ -188,6 +144,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
} finally {
titled?.destroy()
untitled?.destroy()
rmSync(state, { recursive: true, force: true })
}
})
@@ -239,68 +196,6 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
}
})
test("tracks live inbox recency beyond cached session metadata", async () => {
const setup = await renderSessionTabs("background")
try {
expect(setup.tabs.updated("background")).toBe(0)
setup.emit({
id: "evt_admitted",
created: 100,
type: "session.input.admitted",
durable: { aggregateID: "background", seq: 1, version: 1 },
data: {
sessionID: "background",
inputID: "msg_1",
input: { type: "user", data: { text: "work" }, delivery: "steer" },
},
})
await wait(() => setup.tabs.updated("background") === 100)
setup.emit({
id: "evt_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "background", seq: 2, version: 1 },
data: { sessionID: "background" },
})
await wait(() => setup.tabs.updated("background") === 200)
} finally {
setup.destroy()
}
})
test("tracks child activity before family hydration and after restart", async () => {
const state = stateDir("opencode-session-tabs-recency-")
const sessions: Record<string, SessionFixture> = { child: { parentID: "parent" } }
const first = await renderSessionTabs("parent", { state, sessions })
try {
first.emit({
id: "evt_child_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
})
expect(first.tabs.updated("parent")).toBe(0)
await first.data.session.sync("child")
await wait(() => first.tabs.updated("parent") === 200)
} finally {
first.destroy()
}
sessions.child.updated = 200
const second = await renderSessionTabs("parent", { state, sessions })
try {
expect(second.tabs.updated("parent")).toBe(0)
await second.data.session.sync("parent", { children: true })
await wait(() => second.tabs.updated("parent") === 200)
} finally {
second.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
@@ -331,57 +226,3 @@ test("tracks a temporary new session tab across close and creation", async () =>
setup.destroy()
}
})
test("navigates the inbox without changing sessions and confirms done twice", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first")
expect(setup.tabs.navigation.focus()).toBe(true)
expect(setup.tabs.navigation.selected()).toBe("first")
setup.tabs.navigation.move(1)
expect(setup.tabs.navigation.selected()).toBe("second")
expect(setup.tabs.current()).toBe("first")
setup.tabs.navigation.done()
expect(setup.tabs.navigation.pendingDone()).toBe("second")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 1)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
expect(setup.tabs.current()).toBe("first")
expect(setup.tabs.navigation.selected()).toBe("first")
} finally {
setup.destroy()
}
})
test("keeps inbox focus aligned with history after marking the current session done", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second")
setup.route.navigate({ type: "session", sessionID: "third" })
await wait(() => setup.tabs.current() === "third")
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().length === 3)
setup.tabs.navigation.focus()
setup.tabs.navigation.done()
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 2)
expect(setup.tabs.current()).toBe("third")
expect(setup.tabs.navigation.selected()).toBe("third")
} finally {
setup.destroy()
}
})
@@ -6,6 +6,7 @@ import {
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
@@ -218,6 +219,25 @@ describe("diff viewer file tree utilities", () => {
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
})
test("moves file selection relative to the highlighted row", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
)
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
const tui = rows.find((row) => row.name === "tui.ts")!
const index = rows.find((row) => row.name === "index.ts")!
const readme = rows.find((row) => row.name === "README.md")!
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
})
test("selects a file tree node and expands its parents for a patch file", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
const selection = fileTreeFileSelection(tree, 1)
+3 -38
View File
@@ -46,7 +46,7 @@ test("legacy page key aliases compile as page keys", async () => {
test("formats navigation keys as arrows", async () => {
let read = () => ({}) as Record<string, string>
const commands = ["session.parent", "session.child.first"]
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
function Harness() {
const shortcuts = Keymap.useShortcuts()
@@ -68,6 +68,8 @@ test("formats navigation keys as arrows", async () => {
expect(read()).toEqual({
"session.parent": "↑",
"session.child.first": "↓",
"session.child.previous": "←",
"session.child.next": "→",
})
} finally {
app.renderer.destroy()
@@ -139,40 +141,3 @@ test("global commands stay reachable when the mode changes", async () => {
app.renderer.destroy()
}
})
test("dispatches direct and leader tab-number bindings", async () => {
const calls: number[] = []
function Harness() {
Keymap.createLayer(() => ({
mode: "global",
commands: Array.from({ length: 2 }, (_, index) => ({
id: `session.tab.select.${index + 1}`,
run: () => void calls.push(index + 1),
})),
}))
Keymap.createLayer(() => ({
mode: "global",
bindings: ["session.tab.select.1", "session.tab.select.2"],
}))
return <box />
}
const app = await testRender(() => (
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<Harness />
</Keymap.Provider>
</ConfigProvider>
))
try {
app.mockInput.pressKey("1", { ctrl: true })
expect(calls).toEqual([])
app.mockInput.pressKey("1", { meta: true })
app.mockInput.pressKey("x", { ctrl: true })
app.mockInput.pressKey("2")
expect(calls).toEqual([1, 2])
} finally {
app.renderer.destroy()
}
})
@@ -3,10 +3,6 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>()
const closes = new Set<() => void>()
let ready!: () => void
const promptReady = new Promise<void>((resolve) => {
ready = resolve
})
const events = input.events ?? []
const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
@@ -18,7 +14,6 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
},
onPrompt(fn) {
prompts.add(fn)
ready()
return () => prompts.delete(fn)
},
onClose(fn) {
@@ -55,12 +50,9 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
events,
commits,
calls,
promptReady,
submit(text: string, mode?: RunPrompt["mode"]) {
if (prompts.size === 0) return false
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
return true
},
}
}
+5 -20
View File
@@ -55,9 +55,6 @@ describe("run interactive runtime", () => {
const api = ui.api
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
const catalogLoaded = defer<void>()
const defaultModelReloaded = defer<void>()
const modelShown = defer<void>()
const turnStarted = defer<void>()
const model = catalogModel({
id: "resolved",
providerID: "test",
@@ -72,17 +69,7 @@ describe("run interactive runtime", () => {
providers: [catalogProvider("test", "Test Provider")],
models: [model],
})
let defaultModelCalls = 0
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
defaultModelCalls++
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
return selected.promise
})
const emit = api.event.bind(api)
api.event = (event) => {
emit(event)
if (event.type === "model") modelShown.resolve()
}
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
const task = runInteractiveDeferredMode(
{
@@ -123,7 +110,6 @@ describe("run interactive runtime", () => {
runPromptTurn: async (input) => {
turnAgent = input.agent
turnModel = input.model
turnStarted.resolve()
api.close()
},
queuePromptTurn: async () => {},
@@ -147,8 +133,8 @@ describe("run interactive runtime", () => {
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
await defaultModelReloaded.promise
await modelShown.promise
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
expect(events).toContainEqual({
type: "model",
model: "Resolved Model · Test Provider",
@@ -156,9 +142,8 @@ describe("run interactive runtime", () => {
})
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
lifecycle.onAgentSelect?.("review")
await ui.promptReady
expect(ui.submit("hello")).toBe(true)
await turnStarted.promise
ui.submit("hello")
while (!turnModel) await Bun.sleep(0)
expect(turnAgent).toBe("review")
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
await task
@@ -1,9 +0,0 @@
import { expect, test } from "bun:test"
import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
test("rotates through 60 stable activity verbs", () => {
expect(ACTIVITY_VERBS).toHaveLength(60)
expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
})
+14 -1
View File
@@ -1,16 +1,25 @@
import { describe, expect, test } from "bun:test"
import { errorFormat, errorMessage } from "../../src/util/error"
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
describe("util.error", () => {
test("formats native Error instances", () => {
const err = new Error("boom")
expect(errorMessage(err)).toBe("boom")
expect(errorFormat(err)).toContain("boom")
const data = errorData(err)
expect(data.type).toBe("Error")
expect(data.message).toBe("boom")
expect(String(data.formatted)).toContain("boom")
})
test("extracts message from record-like values", () => {
const err = { message: "bad input", code: "E_BAD" }
expect(errorMessage(err)).toBe("bad input")
const data = errorData(err)
expect(data.message).toBe("bad input")
expect(data.code).toBe("E_BAD")
})
test("never returns bare {} for opaque object errors", () => {
@@ -32,5 +41,9 @@ describe("util.error", () => {
}
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
const data = errorData(err)
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
expect(String(data.formatted)).toContain("ResolveMessage")
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import { formatDuration } from "../../src/util/format"
describe("util.format", () => {
describe("formatDuration", () => {
test("returns empty string for zero or negative values", () => {
expect(formatDuration(0)).toBe("")
expect(formatDuration(-1)).toBe("")
expect(formatDuration(-100)).toBe("")
})
test("formats seconds under a minute", () => {
expect(formatDuration(1)).toBe("1s")
expect(formatDuration(30)).toBe("30s")
expect(formatDuration(59)).toBe("59s")
})
test("formats minutes under an hour", () => {
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(61)).toBe("1m 1s")
expect(formatDuration(90)).toBe("1m 30s")
expect(formatDuration(120)).toBe("2m")
expect(formatDuration(330)).toBe("5m 30s")
expect(formatDuration(3599)).toBe("59m 59s")
})
test("formats hours under a day", () => {
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(3660)).toBe("1h 1m")
expect(formatDuration(7200)).toBe("2h")
expect(formatDuration(8100)).toBe("2h 15m")
expect(formatDuration(86399)).toBe("23h 59m")
})
test("formats days under a week", () => {
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(172800)).toBe("~2 days")
expect(formatDuration(259200)).toBe("~3 days")
expect(formatDuration(604799)).toBe("~6 days")
})
test("formats weeks", () => {
expect(formatDuration(604800)).toBe("~1 week")
expect(formatDuration(1209600)).toBe("~2 weeks")
expect(formatDuration(1609200)).toBe("~2 weeks")
})
test("handles boundary values correctly", () => {
expect(formatDuration(59)).toBe("59s")
expect(formatDuration(60)).toBe("1m")
expect(formatDuration(3599)).toBe("59m 59s")
expect(formatDuration(3600)).toBe("1h")
expect(formatDuration(86399)).toBe("23h 59m")
expect(formatDuration(86400)).toBe("~1 day")
expect(formatDuration(604799)).toBe("~6 days")
expect(formatDuration(604800)).toBe("~1 week")
})
})
})
@@ -1,25 +0,0 @@
import { expect, test } from "bun:test"
import { markdownPreview } from "../../src/util/markdown-preview"
test("strips common Markdown presentation syntax from previews", () => {
expect(markdownPreview("The capture shows **Reset** and a _short-lived_ **override**.")).toBe(
"The capture shows Reset and a short-lived override.",
)
expect(markdownPreview("Use [`sessionRename`](https://example.com) with ~~old~~ new behavior.")).toBe(
"Use sessionRename with old new behavior.",
)
expect(markdownPreview("![diagram](image.png) Review [the result][result].")).toBe("diagram Review the result.")
})
test("flattens block syntax and code without losing its content", () => {
expect(markdownPreview("# Result\n\n- First item\n- Second `item`\n> Finished")).toBe(
"Result First item Second item Finished",
)
expect(markdownPreview("```ts\nconst answer = 42\n```")).toBe("const answer = 42")
})
test("preserves literal asterisks, globs, and escaped punctuation", () => {
expect(markdownPreview("Search **/*.ts, calculate 2 * 3, and keep \\*literal\\*. ")).toBe(
"Search **/*.ts, calculate 2 * 3, and keep *literal*.",
)
})
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test"
import { getRevertDiffFiles } from "../../src/util/revert-diff"
describe("revert diff", () => {
test("prefers the actual file path over /dev/null for added and deleted files", () => {
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
new file mode 100644
index 0000000..3b18e51
--- /dev/null
+++ b/new.txt
@@ -0,0 +1 @@
+new content
diff --git a/old.txt b/old.txt
deleted file mode 100644
index 3b18e51..0000000
--- a/old.txt
+++ /dev/null
@@ -1 +0,0 @@
-old content
`)
expect(files).toEqual([
{
filename: "new.txt",
additions: 1,
deletions: 0,
},
{
filename: "old.txt",
additions: 0,
deletions: 1,
},
])
})
})