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:
@@ -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)
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user