diff --git a/.changeset/fix-session-scope-toggle.md b/.changeset/fix-session-scope-toggle.md new file mode 100644 index 00000000000..38396df275e --- /dev/null +++ b/.changeset/fix-session-scope-toggle.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show current-worktree sessions by default in the TUI sessions dialog and keep all/current scope toggling working when a scope has no sessions. diff --git a/flake.nix b/flake.nix index 56438ab332e..7383da725db 100644 --- a/flake.nix +++ b/flake.nix @@ -76,8 +76,11 @@ }; kilo-dev = pkgs.writeShellScriptBin "kilo-dev" '' - cd "$KILO_ROOT" - exec ${bun}/bin/bun dev "$@" + set -euo pipefail + + : "''${KILO_ROOT:?KILO_ROOT is not set. Enter the flake dev shell from the repo root.}" + export KILO_DEV_CWD="$PWD" + exec ${bun}/bin/bun --cwd "$KILO_ROOT/packages/opencode" --conditions=browser ./src/index.ts "$@" ''; kilo-install-bin = pkgs.writeShellScriptBin "kilo-install" '' diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 1be189658c8..b42966dd8ed 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -14,7 +14,6 @@ import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" -import path from "path" // kilocode_change import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { WorkspaceLabel } from "./workspace-label" @@ -31,7 +30,7 @@ export function DialogSessionList() { const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const [global, setGlobal] = createSignal(true) // kilocode_change - show all worktrees by default + const [global, setGlobal] = createSignal(false) // kilocode_change - show current worktree by default const deleteHint = useCommandShortcut("session.delete") const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") @@ -39,15 +38,17 @@ export function DialogSessionList() { // kilocode_change start - always fetch from experimental endpoint (returns GlobalSession with worktree info) // TODO: extend /experimental/session to accept `scope`/`path` so this dialog can respect the // upstream `session_directory_filter_enabled` KV toggle (via sync.session.query()) while - // keeping worktree grouping. Currently the toggle has no effect here. + // keeping worktree grouping. const [searchResults, searchActions] = createResource( - () => search(), - async (query) => { + () => ({ query: search(), global: global(), directory: project.instance.directory() }), // kilocode_change + async (input) => { const result = await sdk.client.experimental.session.list( { - search: query || undefined, + search: input.query || undefined, roots: true, worktrees: true, + current: input.global ? undefined : "true", + directory: input.global ? undefined : input.directory || undefined, limit: 30, }, { throwOnError: true }, @@ -59,15 +60,7 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - // kilocode_change start - client-side worktree filtering when global is off - const sessions = createMemo(() => { - const all = searchResults() ?? [] - if (global()) return all - const root = project.instance.path().worktree - if (!root || root === "/") return all - return all.filter((s) => s.directory === root || s.directory.startsWith(root + path.sep)) - }) - // kilocode_change end + const sessions = createMemo(() => searchResults() ?? []) // kilocode_change - endpoint applies worktree scope function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) @@ -154,8 +147,6 @@ export function DialogSessionList() { .map((x) => x.id) } - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) - const quickSwitchHint = createMemo(() => { const first = quickSwitch1() const last = quickSwitch9() @@ -176,8 +167,7 @@ export function DialogSessionList() { .map((x) => [x.id, x]), ) - const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + const displayOrder = orderByRecency(sessions()) // kilocode_change - respect current scope const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) @@ -335,6 +325,7 @@ export function DialogSessionList() { { command: "session.scope.toggle", title: global() ? "current" : "all", + requiresSelection: false, onTrigger: async () => { setToDelete(undefined) setGlobal((v) => !v) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 0a8c34865cc..e4615ce000a 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -84,12 +84,13 @@ async function input(value?: string) { } export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) { - // kilocode_change start - ignore stale PWD from wrappers such as `bun --cwd` + // kilocode_change start - ignore stale PWD from wrappers such as `bun --cwd`, except kilo-dev's caller cwd + const dev = process.env.KILO_DEV_CWD const real = Filesystem.resolve(cwd) - const root = envPWD && Filesystem.resolve(envPWD) === real ? Filesystem.resolve(envPWD) : real + const root = dev ? Filesystem.resolve(dev) : envPWD && Filesystem.resolve(envPWD) === real ? Filesystem.resolve(envPWD) : real // kilocode_change end if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project)) - return real // kilocode_change + return dev ? root : real // kilocode_change } export const TuiThreadCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 700735d38cb..a211155ce4c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -31,13 +31,7 @@ export interface DialogSelectProps { onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean renderFilter?: boolean - actions?: { - command: string - title: string - side?: "left" | "right" - disabled?: boolean - onTrigger: (option: DialogSelectOption) => void - }[] + actions?: DialogSelectAction[] // kilocode_change - supports actions without a selected option footerHints?: { title: string label: string @@ -47,6 +41,27 @@ export interface DialogSelectProps { current?: T } +// kilocode_change start - support list-level actions when no option is selected +type DialogSelectActionBase = { + command: string + title: string + side?: "left" | "right" + disabled?: boolean +} + +type DialogSelectAction = DialogSelectActionBase & + ( + | { + requiresSelection?: true + onTrigger: (option: DialogSelectOption) => void + } + | { + requiresSelection: false + onTrigger: () => void + } + ) +// kilocode_change end + export interface DialogSelectOption { title: string value: T @@ -303,6 +318,12 @@ export function DialogSelect(props: DialogSelectProps) { category: "Dialog", run() { setStore("input", "keyboard") + // kilocode_change start - allow actions such as scope toggles on empty lists + if (item.requiresSelection === false) { + item.onTrigger() + return + } + // kilocode_change end const option = selected() if (!option) return item.onTrigger(option) diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 086db38c0a8..3899296bcc3 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -17,6 +17,7 @@ import type { Provider } from "@/provider/provider" import { zod as toZod } from "@opencode-ai/core/effect-zod" import { ENV_FEATURE } from "@kilocode/kilo-gateway" import { fn } from "@/kilocode/fn" +import path from "path" export namespace KiloSession { const log = Log.create({ service: "session.kilo" }) @@ -316,6 +317,7 @@ export namespace KiloSession { projectID?: string directory?: string directories?: string[] + currentDirectory?: string roots?: boolean start?: number cursor?: number @@ -360,6 +362,19 @@ export namespace KiloSession { const limit = input.limit ?? 100 const dirs = [...new Set((input.directories ?? []).map((dir) => Filesystem.resolve(dir)))] + const sorted = [...dirs].sort((a, b) => b.length - a.length) + const worktree = (dir: string) => { + for (const root of sorted) { + if (!Filesystem.contains(root, dir)) continue + const rel = path.relative(root, dir) + const parts = rel.split(path.sep) + if ((parts[0] === ".kilo" || parts[0] === ".kilocode") && parts[1] === "worktrees" && parts[2]) { + return path.join(root, parts[0], parts[1], parts[2]) + } + return root + } + } + const current = input.currentDirectory ? worktree(Filesystem.resolve(input.currentDirectory)) : undefined const rows = Database.use((db) => { const query = @@ -377,7 +392,10 @@ export namespace KiloSession { dirs.length > 0 ? rows.filter((row) => { const dir = Filesystem.resolve(row.directory) - return dirs.some((root) => Filesystem.contains(root, dir)) + const root = worktree(dir) + if (!root) return false + if (input.currentDirectory) return root === current + return true }) : rows diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 5edc8bbc1c2..06e34d61c13 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -77,6 +77,7 @@ export const SessionListQuery = Schema.Struct({ // kilocode_change start projectID: Schema.optional(Schema.String), worktrees: Schema.optional(QueryBoolean), + current: Schema.optional(QueryBoolean), // kilocode_change end roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index e21cfe11d01..f845db8b0fc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -195,14 +195,18 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper // kilocode_change start const state = yield* InstanceState.context const projectID = ctx.query.worktrees && !ctx.query.projectID ? state.project.id : ctx.query.projectID - const directories = ctx.query.worktrees ? yield* WorktreeFamily.list() : undefined - const sorted = directories ? [...directories].sort((a, b) => b.length - a.length) : undefined + const roots = ctx.query.worktrees ? yield* WorktreeFamily.list() : undefined + const directory = ctx.query.current ? ctx.query.directory : undefined + const sorted = roots ? [...roots].sort((a, b) => b.length - a.length) : undefined + const current = sorted && directory ? sorted.find((dir) => Filesystem.contains(dir, directory)) : undefined // kilocode_change end + if (roots && directory && !current) return HttpServerResponse.jsonUnsafe([]) // kilocode_change const sessions = Array.from( Session.listGlobal({ projectID, // kilocode_change directory: ctx.query.worktrees ? undefined : ctx.query.directory, // kilocode_change - directories, // kilocode_change + directories: roots, // kilocode_change + currentDirectory: directory, // kilocode_change roots: ctx.query.roots, start: ctx.query.start, cursor: ctx.query.cursor, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 30a14fb56e7..aa9eb83f401 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1027,6 +1027,7 @@ export function* listGlobal(input?: { projectID?: string directory?: string directories?: string[] + currentDirectory?: string roots?: boolean start?: number cursor?: number diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 2bd256bc510..15ea3d9d199 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -30,9 +30,9 @@ const readRuntimeFlags = () => export function getChannelPath(flags: Pick = readRuntimeFlags()) { if (["latest", "beta", "prod"].includes(InstallationChannel) || flags.disableChannelDb) - return path.join(Global.Path.data, "kilo.db") + return path.join(Global.Path.data, "kilo.db") // kilocode_change const safe = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-") - return path.join(Global.Path.data, `opencode-${safe}.db`) + return path.join(Global.Path.data, `kilo-${safe}.db`) // kilocode_change } export const getPath = (flags?: Pick) => { diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index 5cb6e89fe3d..ba692626ca8 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -12,4 +12,20 @@ describe("kilo tui thread", () => { expect(resolveThreadDirectory(".", root.path, pkg)).toBe(pkg) }) + + test("uses kilo-dev caller directory when running through package cwd", async () => { + await using root = await tmpdir() + const pkg = path.join(root.path, "packages", "opencode") + await fs.mkdir(pkg, { recursive: true }) + + const prev = process.env.KILO_DEV_CWD + process.env.KILO_DEV_CWD = root.path + try { + expect(resolveThreadDirectory(".", root.path, pkg)).toBe(root.path) + expect(resolveThreadDirectory(undefined, root.path, pkg)).toBe(root.path) + } finally { + if (prev === undefined) delete process.env.KILO_DEV_CWD + else process.env.KILO_DEV_CWD = prev + } + }) }) diff --git a/packages/opencode/test/server/experimental-session-list.test.ts b/packages/opencode/test/server/experimental-session-list.test.ts index c4f01d8494d..ab106799ff1 100644 --- a/packages/opencode/test/server/experimental-session-list.test.ts +++ b/packages/opencode/test/server/experimental-session-list.test.ts @@ -156,4 +156,114 @@ describe("experimental.session.list", () => { await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() } }) + + test("current=true narrows worktrees to the directory containing worktree", async () => { + await using first = await tmpdir({ git: true }) + const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + + try { + await $`git worktree add ${worktree} -b test-branch-current-${Date.now()}`.cwd(first.path).quiet() + + try { + const { Server } = await import("../../src/server/server") + + const branch = await WithInstance.provide({ + directory: worktree, + fn: () => create("worktree-session"), + }) + + const root = await WithInstance.provide({ + directory: first.path, + fn: async () => ({ + app: Server.Default().app, + project: await Server.Default().app.request("/project/current", { + headers: { "x-kilo-directory": first.path }, + }), + session: await create("root-session"), + }), + }) + + const app = root.app + const project = await root.project.json() + const cwd = path.join(worktree, "packages", "opencode") + const response = await app.request( + `/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true¤t=true&directory=${encodeURIComponent(cwd)}`, + { + headers: { "x-kilo-directory": first.path }, + }, + ) + + expect(response.status).toBe(200) + const body = await response.json() + const ids = body.map((item: { id: string }) => item.id) + + expect(ids).toContain(branch.id) + expect(ids).not.toContain(root.session.id) + } finally { + mock.restore() + } + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) + + test("current=true excludes Agent Manager worktrees from the root worktree", async () => { + await using first = await tmpdir({ git: true }) + const worktree = path.join(first.path, ".kilo", "worktrees", "nested-current") + + try { + await $`git worktree add --quiet -b test-branch-nested-current-${Date.now()} ${worktree} HEAD`.cwd( + first.path, + ) + + try { + const { Server } = await import("../../src/server/server") + + const branch = await WithInstance.provide({ + directory: worktree, + fn: () => create("worktree-session"), + }) + + const root = await WithInstance.provide({ + directory: first.path, + fn: async () => ({ + app: Server.Default().app, + project: await Server.Default().app.request("/project/current", { + headers: { "x-kilo-directory": first.path }, + }), + session: await create("root-session"), + }), + }) + + const app = root.app + const project = await root.project.json() + const all = await app.request( + `/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true&directory=${encodeURIComponent(first.path)}`, + { + headers: { "x-kilo-directory": first.path }, + }, + ) + const current = await app.request( + `/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true¤t=true&directory=${encodeURIComponent(first.path)}`, + { + headers: { "x-kilo-directory": first.path }, + }, + ) + + expect(all.status).toBe(200) + expect(current.status).toBe(200) + const allIds = (await all.json()).map((item: { id: string }) => item.id) + const currentIds = (await current.json()).map((item: { id: string }) => item.id) + + expect(allIds).toContain(root.session.id) + expect(allIds).toContain(branch.id) + expect(currentIds).toContain(root.session.id) + expect(currentIds).not.toContain(branch.id) + } finally { + mock.restore() + } + } finally { + await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow() + } + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0a1652757f1..d0b1bf30be0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1195,6 +1195,7 @@ export class Session extends HeyApiClient { workspace?: string projectID?: string worktrees?: boolean + current?: "true" | "false" roots?: boolean | "true" | "false" start?: number cursor?: number @@ -1213,6 +1214,7 @@ export class Session extends HeyApiClient { { in: "query", key: "workspace" }, { in: "query", key: "projectID" }, { in: "query", key: "worktrees" }, + { in: "query", key: "current" }, { in: "query", key: "roots" }, { in: "query", key: "start" }, { in: "query", key: "cursor" }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 914f4bb7ebd..30f8df438a3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4935,6 +4935,7 @@ export type ExperimentalSessionListData = { workspace?: string projectID?: string worktrees?: boolean + current?: "true" | "false" roots?: boolean | "true" | "false" start?: number cursor?: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 9e4e318453d..093a4c2ca5e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1522,6 +1522,15 @@ }, "required": false }, + { + "name": "current", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false + }, { "name": "roots", "in": "query", diff --git a/packages/ui/src/components/provider-icons/sprite.svg b/packages/ui/src/components/provider-icons/sprite.svg index 190d2d8661b..32592999bca 100644 --- a/packages/ui/src/components/provider-icons/sprite.svg +++ b/packages/ui/src/components/provider-icons/sprite.svg @@ -1860,13 +1860,11 @@ OjAwvs/J9QAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMy0yNVQwODowMToxNyswMDowMOna > - - - - - - - +