From 5d8a01dedc5f39d90e2a6403a7d3badf7eaadd2b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:50:21 +0000 Subject: [PATCH] feat(tui): add copy session ID command (#47064) Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com> --- packages/tui/src/config/keybind.ts | 1 + packages/tui/src/routes/session/index.tsx | 12 ++++ packages/tui/test/copy-session-id.test.tsx | 78 ++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 packages/tui/test/copy-session-id.test.tsx diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index ec5beb94a4e..2cf6e767b06 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -103,6 +103,7 @@ export const Definitions = { "session.export": keybind("x", "Export session to editor"), "session.copy": keybind("none", "Copy session transcript"), + "session.copy.id": keybind("none", "Copy session ID"), "session.move": keybind("none", "Move session"), "session.new": keybind("n", "Create a new session"), "session.list": keybind("l", "List all sessions"), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 7ecc44ce0bd..8d7d1d4605f 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1182,6 +1182,18 @@ export function Session(props: { dialog.clear() }, }, + { + title: "Copy session ID", + id: "session.copy.id", + group: "Session", + run: () => { + clipboard + .write(route.sessionID) + .then(() => toast.show({ message: "Session ID copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to copy session ID", variant: "error" })) + dialog.clear() + }, + }, { title: "Copy session transcript", id: "session.copy", diff --git a/packages/tui/test/copy-session-id.test.tsx b/packages/tui/test/copy-session-id.test.tsx new file mode 100644 index 00000000000..4e5f50153df --- /dev/null +++ b/packages/tui/test/copy-session-id.test.tsx @@ -0,0 +1,78 @@ +import { expect, spyOn, test } from "bun:test" +import { InputRenderable, TextareaRenderable } from "@opentui/core" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect, FileSystem } from "effect" +import { Global } from "@opencode-ai/util/global" +import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" +import { tmpdir } from "./fixture/fixture" + +test.each(["success", "failure", "home"])("Copy session ID from Ctrl+P (%s)", async (mode) => { + await using state = await tmpdir() + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + Object.defineProperty(setup.renderer, "capabilities", { get: () => null }) + const copy = spyOn(setup.renderer, "copyToClipboardOSC52").mockReturnValue(mode === "success") + const sessionID = "ses_copy_id" + const events = createEventStream() + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "proj_test", + title: "Copy ID fixture", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + }) + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/permission`) + return json({ data: [] }) + return undefined + }, events) + const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false }), update: async () => ({}) }, + packages: { prepare: async () => ({ directory: "" }) }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }), + args: mode === "home" ? {} : { sessionID }, + log: () => {}, + }).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))), + ) + try { + await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable) + setup.mockInput.pressKey("p", { ctrl: true }) + await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable) + await setup.mockInput.typeText("Copy session ID") + if (mode === "home") { + await setup.waitForVisualIdle() + expect(setup.captureCharFrame()).not.toMatch(/Copy session ID\s+Session/) + expect(copy).not.toHaveBeenCalled() + return + } + await setup.waitForFrame((frame) => /Copy session ID\s+Session/.test(frame)) + setup.mockInput.pressEnter() + const frame = await setup.waitForFrame((frame) => + frame.includes(mode === "success" ? "Session ID copied to clipboard!" : "Failed to copy session ID"), + ) + expect(copy).toHaveBeenCalledTimes(1) + expect(copy.mock.calls[0]?.[0]).toBe(sessionID) + expect(frame).not.toContain("Copy session ID") + await setup.waitFor( + () => + setup.renderer.currentFocusedEditor instanceof TextareaRenderable && + !(setup.renderer.currentFocusedEditor instanceof InputRenderable), + ) + } finally { + copy.mockRestore() + setup.renderer.destroy() + await task + await server.stop() + } +})