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:
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user