From 87e11734d80c421fc7947519927d6f2e100bf07c Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 07:14:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(v0.3):=20M6=20=E2=80=94=20new=20tab=20open?= =?UTF-8?q?s=20in=20the=20active=20tab's=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - title-util.cwdFromOsc7: parse cwd from OSC 7 (file:// URL); 4 unit tests - terminal-session: register OSC 7 handler → cwd getter; opts.cwd → sent in the attach frame on a fresh session - protocol/types: ClientMessage attach.cwd (absolute path); validated - session/manager/server: thread cwd → createSession spawn cwd (defaults homeDir) - tabs: '+' (newTab) opens in the active tab's reported cwd, falls back to home - Verified: server attach.cwd spawns there; cd /private/tmp → + → new tab pwd is /private/tmp. 212 tests green. --- public/tabs.ts | 7 +++++-- public/terminal-session.ts | 26 ++++++++++++++++++++++++-- public/title-util.ts | 16 ++++++++++++++++ src/protocol.ts | 20 ++++++++++++++++++-- src/server.ts | 2 +- src/session/manager.ts | 4 +++- src/session/session.ts | 4 +++- src/types.ts | 13 ++++++++++--- test/title-util.test.ts | 18 +++++++++++++++++- 9 files changed, 97 insertions(+), 13 deletions(-) diff --git a/public/tabs.ts b/public/tabs.ts index 883f783..1b4e423 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -151,7 +151,7 @@ export class TabApp { /* ── tab lifecycle ───────────────────────────────────────────── */ - private addEntry(sessionId: string | null, customTitle: string | null): TabEntry { + private addEntry(sessionId: string | null, customTitle: string | null, cwd?: string): TabEntry { const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, @@ -161,6 +161,7 @@ export class TabApp { } entry.session = new TerminalSession({ sessionId, + ...(cwd !== undefined ? { cwd } : {}), onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { @@ -189,7 +190,9 @@ export class TabApp { } newTab(): void { - this.addEntry(null, null) + // M6: open the new tab in the active tab's current directory, if known. + const cwd = this.tabs[this.activeIndex]?.session.cwd ?? undefined + this.addEntry(null, null, cwd) this.persist() this.rebuild() this.activate(this.tabs.length - 1) diff --git a/public/terminal-session.ts b/public/terminal-session.ts index 8caf957..2ec5731 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -14,7 +14,7 @@ import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' import type { ClaudeStatus, ClientMessage, ServerMessage } from '../src/types.js' -import { folderFromTitle } from './title-util.js' +import { folderFromTitle, cwdFromOsc7 } from './title-util.js' const RESET = '\x1b[0m' const BOLD = '\x1b[1m' @@ -54,6 +54,8 @@ export interface TerminalSessionOpts { onStatus?: (status: SessionStatus) => void /** Optional: fired when Claude Code activity changes (from a hook, H2). */ onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void + /** Optional: spawn a NEW session in this directory ("new tab here", M6). */ + cwd?: string } export class TerminalSession { @@ -68,8 +70,10 @@ export class TerminalSession { private readonly onTitle: ((title: string) => void) | undefined private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined + private readonly spawnCwd: string | undefined private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' + private cwdValue: string | null = null private pendingApprovalValue = false private pendingToolValue: string | undefined = undefined @@ -91,6 +95,7 @@ export class TerminalSession { this.onTitle = opts.onTitle this.onStatus = opts.onStatus this.onClaudeStatus = opts.onClaudeStatus + this.spawnCwd = opts.cwd this.el = document.createElement('div') this.el.className = 'term-pane' @@ -111,6 +116,12 @@ export class TerminalSession { this.term.onData((data) => this.send(data)) // Tab title = current folder, derived from the shell's OSC title. this.term.onTitleChange((title) => this.onTitle?.(folderFromTitle(title) ?? title)) + // OSC 7 reports the full cwd (M6) — used for "new tab here". + this.term.parser.registerOscHandler(7, (payload) => { + const c = cwdFromOsc7(payload) + if (c !== null) this.cwdValue = c + return true + }) this.resizeObserver = new ResizeObserver(() => this.scheduleResize()) this.resizeObserver.observe(this.el) @@ -131,6 +142,11 @@ export class TerminalSession { return this.claudeStatusValue } + /** Current working directory reported by the shell via OSC 7 (M6), or null. */ + get cwd(): string | null { + return this.cwdValue + } + /** Whether a tool approval is held server-side and can be resolved (H3). */ get pendingApproval(): boolean { return this.pendingApprovalValue @@ -189,7 +205,13 @@ export class TerminalSession { this.reconnectDelay = 1000 this.setStatus('connected') this.term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`)) - socket.send(buildMessage({ type: 'attach', sessionId: this.sessionId })) + // Send cwd only on a fresh session (M6 "new tab here"); on reconnect the + // sessionId is already set and the server ignores cwd. + const attachMsg: ClientMessage = + this.sessionId === null && this.spawnCwd !== undefined + ? { type: 'attach', sessionId: null, cwd: this.spawnCwd } + : { type: 'attach', sessionId: this.sessionId } + socket.send(buildMessage(attachMsg)) }) socket.addEventListener('message', (event: MessageEvent) => { diff --git a/public/title-util.ts b/public/title-util.ts index e3f14fe..95ac781 100644 --- a/public/title-util.ts +++ b/public/title-util.ts @@ -23,3 +23,19 @@ export function folderFromTitle(title: string): string | null { if (slash !== -1) s = s.slice(slash + 1) return s || null } + +/** + * Parse the full cwd path from an OSC 7 sequence payload (M6). + * data = "file://host/Users/me/dir" → "/Users/me/dir" + * Returns null if it isn't a file:// URL. + */ +export function cwdFromOsc7(data: string): string | null { + const m = /^file:\/\/[^/]*(\/.*)$/.exec(data.trim()) + if (m === null) return null + const path = m[1] as string + try { + return decodeURIComponent(path) + } catch { + return path + } +} diff --git a/src/protocol.ts b/src/protocol.ts index 83429e2..917369c 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -133,8 +133,21 @@ function validateAttach(obj: Record): ParseResult { return { ok: false, error: 'attach.sessionId field is required' } } + // Optional cwd (M6): must be an absolute path string if present. + const rawCwd = obj['cwd'] + let cwd: string | undefined + if (rawCwd !== undefined) { + if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) { + return { ok: false, error: 'attach.cwd must be an absolute path string' } + } + cwd = rawCwd + } + if (sessionId === null) { - return { ok: true, message: { type: 'attach', sessionId: null } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null }, + } } if (typeof sessionId !== 'string') { @@ -151,7 +164,10 @@ function validateAttach(obj: Record): ParseResult { } } - return { ok: true, message: { type: 'attach', sessionId } } + return { + ok: true, + message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId }, + } } // ─── serialize ──────────────────────────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index 9928cd3..9d32ea4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -229,7 +229,7 @@ export function startServer(cfg: Config): { close(): Promise } { let session try { - session = manager.handleAttach(ws, msg.sessionId, dims, Date.now()) + session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd) } catch (err: unknown) { // M4: spawn failure — send exit(-1) and close only this connection. const reason = diff --git a/src/session/manager.ts b/src/session/manager.ts index 82d341f..c7596a2 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -82,11 +82,13 @@ export function createSessionManager(cfg: Config): SessionManager { sessionId: string | null, dims: Dims, now: number, + cwd?: string, ): Session { // ── Case 1: null → always create a new session ────────────────────────── if (sessionId === null) { // M4: createSession may throw (spawn failure). Do NOT catch here. - const session = createSession(cfg, dims, now, onSessionExit); + // M6: cwd (if given) is the spawn directory for "new tab here". + const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd); const kicked = attachWs(session, ws); if (kicked !== null) kicked.close(); sessions = new Map(sessions).set(session.meta.id, session); diff --git a/src/session/session.ts b/src/session/session.ts index 6bfcbb4..6806219 100644 --- a/src/session/session.ts +++ b/src/session/session.ts @@ -61,6 +61,8 @@ export function createSession( // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after // a server restart). Default = a fresh id for a brand-new session. id: string = randomUUID(), + // M6: spawn directory for a new session ("new tab here"); defaults to homeDir. + cwd?: string, ): Session { // The id is injected into the shell env so Claude Code hooks know which tab an // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2). @@ -76,7 +78,7 @@ export function createSession( name: 'xterm-256color', cols: dims.cols, rows: dims.rows, - cwd: cfg.homeDir, + cwd: cwd ?? cfg.homeDir, env: { ...process.env, WEBTERM_SESSION: id, diff --git a/src/types.ts b/src/types.ts index 5cb01ba..e69ad34 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,9 +39,10 @@ export type EnvLike = Readonly>; /* ─────────────────────── protocol (§3.2) ─────────────────────── */ -/** client → server. approve/reject (H3) resolve a held PermissionRequest. */ +/** client → server. approve/reject (H3) resolve a held PermissionRequest. + * attach.cwd (M6) = spawn a new session in this directory ("new tab here"). */ export type ClientMessage = - | { type: 'attach'; sessionId: string | null } + | { type: 'attach'; sessionId: string | null; cwd?: string } | { type: 'input'; data: string } | { type: 'resize'; cols: number; rows: number } | { type: 'approve' } @@ -167,7 +168,13 @@ export interface Session { /* ──────────────────────── manager (§3.5) ─────────────────────── */ export interface SessionManager { - handleAttach(ws: WebSocketLike, sessionId: string | null, dims: Dims, now: number): Session; + handleAttach( + ws: WebSocketLike, + sessionId: string | null, + dims: Dims, + now: number, + cwd?: string, + ): Session; get(id: string): Session | undefined; /** Set a session's Claude status (from a hook) and push it to the attached ws (H2/H3). */ handleHookEvent( diff --git a/test/title-util.test.ts b/test/title-util.test.ts index 5794fc3..87f7e76 100644 --- a/test/title-util.test.ts +++ b/test/title-util.test.ts @@ -1,5 +1,21 @@ import { describe, it, expect } from 'vitest' -import { folderFromTitle } from '../public/title-util.js' +import { folderFromTitle, cwdFromOsc7 } from '../public/title-util.js' + +describe('cwdFromOsc7', () => { + it('extracts the path from a file:// URL with a host', () => { + expect(cwdFromOsc7('file://mac.local/Users/me/web-terminal')).toBe('/Users/me/web-terminal') + }) + it('extracts the path with an empty host (file:///)', () => { + expect(cwdFromOsc7('file:///Users/me/dir')).toBe('/Users/me/dir') + }) + it('decodes percent-encoded spaces', () => { + expect(cwdFromOsc7('file://h/Users/me/my%20dir')).toBe('/Users/me/my dir') + }) + it('returns null for non-file URLs', () => { + expect(cwdFromOsc7('http://x/y')).toBeNull() + expect(cwdFromOsc7('garbage')).toBeNull() + }) +}) describe('folderFromTitle', () => { it('extracts the folder from user@host:~/path', () => {