Skip to content

Commit fb726e6

Browse files
rootuccnb
authored andcommitted
feat(tui): auto-open session deep link on start/resume via --open
1 parent 16c56fe commit fb726e6

6 files changed

Lines changed: 97 additions & 4 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { sessionDeepLink } from "./session-deep-link"
3+
4+
describe("sessionDeepLink", () => {
5+
test("builds a deep link with base64url-encoded server address", () => {
6+
expect(sessionDeepLink("http://127.0.0.1:38245", "ses_x")).toBe(
7+
"http://127.0.0.1:38245/server/aHR0cDovLzEyNy4wLjAuMTozODI0NQ/session/ses_x",
8+
)
9+
})
10+
11+
test("encodes the server address without padding", () => {
12+
expect(sessionDeepLink("http://localhost:9009", "ses_y")).toContain("aHR0cDovL2xvY2FsaG9zdDo5MDA5")
13+
})
14+
15+
test("normalizes a trailing slash on the server address", () => {
16+
expect(sessionDeepLink("http://127.0.0.1:4096/", "ses_x")).toBe(
17+
"http://127.0.0.1:4096/server/aHR0cDovLzEyNy4wLjAuMTo0MDk2/session/ses_x",
18+
)
19+
})
20+
})
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { base64Encode } from "@opencode-ai/core/util/encode"
2+
3+
export function sessionDeepLink(serverUrl: string, sessionID: string) {
4+
const base = serverUrl.replace(/\/+$/, "")
5+
return `${base}/server/${base64Encode(base)}/session/${sessionID}`
6+
}

‎packages/opencode/src/cli/cmd/tui.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { writeHeapSnapshot } from "v8"
1414
import { ServerAuth } from "@/server/auth"
1515
import { validateSession } from "../tui/validate-session"
1616
import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32"
17+
import open from "open"
18+
import { sessionDeepLink } from "./session-deep-link"
1719

1820
declare global {
1921
const OPENCODE_WORKER_PATH: string
@@ -140,6 +142,10 @@ export const TuiThreadCommand = cmd({
140142
.option("demo", {
141143
type: "boolean",
142144
hidden: true,
145+
})
146+
.option("open", {
147+
type: "boolean",
148+
describe: "open the web interface in your browser after the server starts",
143149
}),
144150
handler: async (args) => {
145151
if (args.replay === true) {
@@ -248,6 +254,18 @@ export const TuiThreadCommand = cmd({
248254
events: createEventSource(client),
249255
}
250256

257+
let openedSession: string | undefined
258+
const openSession = (sessionID: string) => {
259+
if (!external || !args.open) return
260+
if (openedSession === sessionID || !sessionID.startsWith("ses_")) return
261+
openedSession = sessionID
262+
open(sessionDeepLink(transport.url, sessionID)).catch(() => {})
263+
}
264+
265+
if (external && args.open) {
266+
open(transport.url).catch(() => {})
267+
}
268+
251269
try {
252270
await validateSession({
253271
url: transport.url,
@@ -284,6 +302,7 @@ export const TuiThreadCommand = cmd({
284302
fetch: transport.fetch,
285303
headers: transport.headers,
286304
events: transport.events,
305+
onActiveSession: openSession,
287306
args: {
288307
continue: args.continue,
289308
sessionID: args.session,

‎packages/tui/src/app.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export type TuiInput = {
144144
args: Args
145145
config: TuiConfig.Resolved
146146
onSnapshot?: () => Promise<string[]>
147+
onActiveSession?: (sessionID: string) => void
147148
directory?: string
148149
fetch?: typeof fetch
149150
headers?: RequestInit["headers"]
@@ -292,6 +293,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
292293
}
293294
: undefined
294295
}
296+
onActiveSession={input.onActiveSession}
295297
>
296298
<TuiConfigProvider config={input.config}>
297299
<PluginRuntimeProvider value={pluginRuntime}>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { nextActiveSession } from "./route"
3+
4+
describe("nextActiveSession", () => {
5+
test("navigating from home to a session triggers with the session id", () => {
6+
expect(nextActiveSession({ type: "home" }, { type: "session", sessionID: "ses_a" })).toBe("ses_a")
7+
})
8+
9+
test("navigating between different sessions triggers with the new id", () => {
10+
expect(nextActiveSession({ type: "session", sessionID: "ses_a" }, { type: "session", sessionID: "ses_b" })).toBe(
11+
"ses_b",
12+
)
13+
})
14+
15+
test("navigating to the same session does not retrigger", () => {
16+
expect(nextActiveSession({ type: "session", sessionID: "ses_a" }, { type: "session", sessionID: "ses_a" })).toBeUndefined()
17+
})
18+
19+
test("leaving a session for home does not trigger", () => {
20+
expect(nextActiveSession({ type: "session", sessionID: "ses_a" }, { type: "home" })).toBeUndefined()
21+
})
22+
23+
test("starting directly on a session triggers once", () => {
24+
expect(nextActiveSession(undefined, { type: "session", sessionID: "ses_a" })).toBe("ses_a")
25+
})
26+
27+
test("plugin routes never trigger", () => {
28+
expect(nextActiveSession(undefined, { type: "plugin", id: "p" })).toBeUndefined()
29+
expect(nextActiveSession({ type: "session", sessionID: "ses_a" }, { type: "plugin", id: "p" })).toBeUndefined()
30+
})
31+
32+
test("starting directly on home does not trigger", () => {
33+
expect(nextActiveSession(undefined, { type: "home" })).toBeUndefined()
34+
})
35+
})

‎packages/tui/src/context/route.tsx‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,34 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
2424

2525
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
2626
name: "Route",
27-
init: (props: { initialRoute?: Route }) => {
27+
init: (props: { initialRoute?: Route; onActiveSession?: (sessionID: string) => void }) => {
2828
const startup = useTuiStartup()
29-
const [store, setStore] = createStore<Route>(
30-
props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" },
31-
)
29+
const initial = props.initialRoute ?? initialRoute(startup.initialRoute) ?? { type: "home" }
30+
const [store, setStore] = createStore<Route>(initial)
31+
if (initial.type === "session") {
32+
const sessionID = nextActiveSession(undefined, initial)
33+
if (sessionID) queueMicrotask(() => props.onActiveSession?.(sessionID))
34+
}
3235

3336
return {
3437
get data() {
3538
return store
3639
},
3740
navigate(route: Route) {
41+
const sessionID = nextActiveSession(store, route)
3842
setStore(reconcile(route))
43+
if (sessionID) props.onActiveSession?.(sessionID)
3944
},
4045
}
4146
},
4247
})
4348

49+
export function nextActiveSession(prev: Route | undefined, next: Route): string | undefined {
50+
if (next.type !== "session") return undefined
51+
if (prev?.type === "session" && prev.sessionID === next.sessionID) return undefined
52+
return next.sessionID
53+
}
54+
4455
function initialRoute(value: unknown): Route | undefined {
4556
if (!value || typeof value !== "object" || !("type" in value)) return
4657
if (value.type === "home") return { type: "home" }

0 commit comments

Comments
 (0)