feat(v0.3): M6 — new tab opens in the active tab's directory

- 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.
This commit is contained in:
Yaojia Wang
2026-06-18 07:14:52 +02:00
parent f79f14b89d
commit 87e11734d8
9 changed files with 97 additions and 13 deletions

View File

@@ -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)

View File

@@ -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<string>) => {

View File

@@ -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
}
}

View File

@@ -133,8 +133,21 @@ function validateAttach(obj: Record<string, unknown>): 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<string, unknown>): ParseResult {
}
}
return { ok: true, message: { type: 'attach', sessionId } }
return {
ok: true,
message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId },
}
}
// ─── serialize ────────────────────────────────────────────────────────────────

View File

@@ -229,7 +229,7 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
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 =

View File

@@ -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);

View File

@@ -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,

View File

@@ -39,9 +39,10 @@ export type EnvLike = Readonly<Record<string, string | undefined>>;
/* ─────────────────────── 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(

View File

@@ -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', () => {