diff --git a/src/providers/toolbar.provider.ts b/src/providers/toolbar.provider.ts index 022af9a..bc5833c 100644 --- a/src/providers/toolbar.provider.ts +++ b/src/providers/toolbar.provider.ts @@ -168,14 +168,21 @@ export class WorkspaceToolbarProvider extends ToolbarButtonProvider { if (!workspace) return - // Register startup commands BEFORE opening the workspace - // Commands will be sent via sendInput() when terminals open + // Register startup commands BEFORE opening the workspace so the service can + // match panes as they appear. Commands are sent via sendInput() once each + // pane's PTY session is connected and its shell prompt has rendered. const commands = this.workspaceService.collectStartupCommands(workspace) - if (commands.length > 0) { - this.startupService.registerCommands(commands) - } + const startupDone = commands.length > 0 + ? this.startupService.registerCommands(commands) + : Promise.resolve() const profile = await this.workspaceService.generateTabbyProfile(workspace) this.profilesService.openNewTabForProfile(profile) + + // Serialize launches: wait until this workspace's panes are connected and + // their commands sent before returning. The next startup workspace then + // opens in the foreground, so its PTY sessions actually initialize instead + // of being left disconnected in the background (issue #14, root cause #3). + await startupDone } } diff --git a/src/services/startupCommand.service.ts b/src/services/startupCommand.service.ts index 119b012..cd7cf73 100644 --- a/src/services/startupCommand.service.ts +++ b/src/services/startupCommand.service.ts @@ -1,140 +1,294 @@ -import { Injectable } from '@angular/core' -import { AppService, BaseTabComponent, SplitTabComponent } from 'tabby-core' -import { BaseTerminalTabComponent } from 'tabby-terminal' -import { first, timeout, of } from 'rxjs' -import { catchError } from 'rxjs/operators' - -export interface PendingCommand { - paneId: string - command?: string - originalTitle: string -} - -/** - * Handles startup commands for workspace panes. - * - * This service listens to tab open events and sends startup commands - * to terminals that match registered pane IDs. - * - * NOTE: This is a module-level singleton that lives for the app lifetime. - * The tabOpened$ subscription intentionally runs forever - no cleanup needed. - */ -@Injectable() -export class StartupCommandService { - private pendingCommands: Map = new Map() - - constructor(private app: AppService) { - this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab)) - } - - registerCommands(commands: PendingCommand[]): void { - console.log('[TabbySpaces] Registering commands:', commands) - for (const cmd of commands) { - this.pendingCommands.set(cmd.paneId, cmd) - } - } - - private onTabOpened(tab: BaseTabComponent): void { - console.log('[TabbySpaces] Tab opened:', { - type: tab.constructor.name, - title: tab.title, - }) - - // Handle SplitTabComponent - get all child terminal tabs - if (tab instanceof SplitTabComponent) { - console.log('[TabbySpaces] SplitTabComponent detected, waiting for children...') - // Wait for split tab to fully initialize its children - setTimeout(() => this.processChildTabs(tab), 300) - return - } - - // Handle individual terminal tab (shouldn't happen for split-layout, but just in case) - if (tab instanceof BaseTerminalTabComponent) { - this.processTerminalTab(tab) - } - } - - private processChildTabs(splitTab: SplitTabComponent): void { - // Get all nested tabs from the split container - const allTabs = splitTab.getAllTabs() - console.log('[TabbySpaces] Found child tabs:', allTabs.length) - - for (const tab of allTabs) { - if (tab instanceof BaseTerminalTabComponent) { - this.processTerminalTab(tab) - } - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private processTerminalTab(terminalTab: BaseTerminalTabComponent): void { - const paneId = terminalTab.customTitle || terminalTab.title - console.log('[TabbySpaces] Processing terminal tab:', { - title: terminalTab.title, - customTitle: terminalTab.customTitle, - paneId, - pendingKeys: [...this.pendingCommands.keys()], - }) - - const pending = this.pendingCommands.get(paneId) - if (!pending) { - console.log('[TabbySpaces] No matching command for paneId:', paneId) - return - } - - this.pendingCommands.delete(paneId) - - // Build startup command (cd + command) - const fullCommand = this.buildFullCommand(pending) - if (!fullCommand) { - console.log('[TabbySpaces] No command to send (no cwd or startup command)') - return - } - - console.log('[TabbySpaces] Command matched, waiting for shell output...:', fullCommand) - - // Unified command sender - reduces duplication - const sendCommand = () => { - console.log('[TabbySpaces] Shell ready, sending command:', fullCommand) - terminalTab.sendInput(fullCommand + '\r') - this.clearProfileArgs(terminalTab) - this.setTabTitle(terminalTab, pending.originalTitle) - } - - // Wait for shell to emit first output (prompt), then send command - if (terminalTab.session?.output$) { - terminalTab.session.output$.pipe( - first(), - timeout(2000), // Prevent infinite wait if shell doesn't emit - catchError(() => of(null)) // Fallback on timeout/error - ).subscribe(() => { - // Small delay after prompt renders - setTimeout(sendCommand, 100) - }) - } else { - console.log('[TabbySpaces] No session.output$, falling back to timeout') - setTimeout(sendCommand, 500) - } - } - - private buildFullCommand(pending: PendingCommand): string | null { - return pending.command || null - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private clearProfileArgs(terminalTab: BaseTerminalTabComponent): void { - // Clear args from profile to prevent native splits from re-running startup command - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const profile = (terminalTab as any).profile - if (profile?.options?.args) { - console.log('[TabbySpaces] Clearing profile args to prevent re-run on split') - profile.options.args = [] - } - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private setTabTitle(terminalTab: BaseTerminalTabComponent, title: string): void { - terminalTab.setTitle(title) - terminalTab.customTitle = title - } -} +import { Injectable } from '@angular/core' +import { AppService, BaseTabComponent, SplitTabComponent } from 'tabby-core' +import { BaseTerminalTabComponent } from 'tabby-terminal' + +export interface PendingCommand { + paneId: string + command?: string + originalTitle: string + /** Internal: resolves when this command has been sent (or given up on). Set by registerCommands(). */ + resolve?: () => void +} + +/** + * Handles startup commands for workspace panes. + * + * This service listens to tab open events and sends startup commands + * to terminals that match registered pane IDs. + * + * Reliability notes (see GitHub issue #14): + * - Pane discovery polls instead of firing a single delayed scan, so panes that + * are constructed late are still picked up. + * - Commands are only sent once the PTY session exists AND the shell has rendered + * a prompt, so input is no longer dropped during shell initialization. + * + * NOTE: This is a module-level singleton that lives for the app lifetime. + * The tabOpened$ subscription intentionally runs forever - no cleanup needed. + */ +@Injectable() +export class StartupCommandService { + private pendingCommands: Map = new Map() + + // Pane discovery: re-scan a split until all of its commands are handled. + private readonly DISCOVERY_INTERVAL_MS = 200 + private readonly DISCOVERY_MAX_ATTEMPTS = 50 // ~10s + + // Session readiness: poll until terminalTab.session.output$ exists. + private readonly SESSION_WAIT_INTERVAL_MS = 100 + private readonly SESSION_WAIT_MAX_ATTEMPTS = 100 // ~10s + + // Prompt readiness: wait for the shell to render a prompt before typing. + private readonly PROMPT_TIMEOUT_MS = 8000 // hard cap if no prompt is ever detected + private readonly PROMPT_SETTLE_MS = 800 // fallback once output stops flowing + + // A whole startup batch is abandoned after this long so one stuck pane can't + // block the serialized launch of the remaining workspaces. + private readonly BATCH_TIMEOUT_MS = 20000 + + // Prompt characters across the supported shells: cmd/clink/PowerShell (>), + // bash/sh (#, $), zsh (%), and common custom prompts (❯, ➜). + private static readonly PROMPT_CHARS = ['>', '$', '#', '%', '❯', '➜'] + + // ANSI strippers, built with String.fromCharCode so the source stays free of + // literal control bytes. 27 = ESC, 7 = BEL. + // CSI: ESC [ ... final-byte (colors, cursor movement) + // OSC: ESC ] ... BEL|ESC \ (e.g. window-title sets) + private static readonly CSI_PATTERN = new RegExp(String.fromCharCode(27) + '\\[[0-9;?]*[ -/]*[@-~]', 'g') + private static readonly OSC_PATTERN = new RegExp( + String.fromCharCode(27) + '\\][^' + String.fromCharCode(7) + ']*(?:' + String.fromCharCode(7) + '|' + String.fromCharCode(27) + '\\\\)', + 'g' + ) + + constructor(private app: AppService) { + this.app.tabOpened$.subscribe((tab) => this.onTabOpened(tab)) + } + + /** + * Registers startup commands and returns a promise that resolves once every + * command in this batch has been sent (or the batch safety-timeout elapses). + * Callers can await this to serialize workspace launches. + */ + registerCommands(commands: PendingCommand[]): Promise { + console.log('[TabbySpaces] Registering commands:', commands) + + const batch = [...commands] + const promises = batch.map((cmd) => new Promise((resolve) => { cmd.resolve = resolve })) + for (const cmd of batch) { + this.pendingCommands.set(cmd.paneId, cmd) + } + + return new Promise((resolveBatch) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + clearTimeout(timer) + for (const cmd of batch) { + // Drop any straggler that was never matched so it can't poison the next batch. + if (this.pendingCommands.get(cmd.paneId) === cmd) { + this.pendingCommands.delete(cmd.paneId) + } + cmd.resolve?.() + } + resolveBatch() + } + const timer = setTimeout(() => { + console.warn('[TabbySpaces] Startup batch timed out; continuing with remaining workspaces') + finish() + }, this.BATCH_TIMEOUT_MS) + Promise.all(promises).then(finish) + }) + } + + private onTabOpened(tab: BaseTabComponent): void { + console.log('[TabbySpaces] Tab opened:', { + type: tab.constructor.name, + title: tab.title, + }) + + // Handle SplitTabComponent - poll for child terminal tabs as they appear. + if (tab instanceof SplitTabComponent) { + console.log('[TabbySpaces] SplitTabComponent detected, polling for children...') + void this.processChildTabs(tab) + return + } + + // Handle individual terminal tab (shouldn't happen for split-layout, but just in case) + if (tab instanceof BaseTerminalTabComponent) { + void this.processTerminalTab(tab) + } + } + + /** + * Re-scans a split for terminal panes until every pending command it owns has + * been handled (or the attempt cap is hit). Replaces the old single 300ms shot + * that silently skipped every command if the panes weren't built yet. + */ + private async processChildTabs(splitTab: SplitTabComponent): Promise { + const handled = new Set() + const sends: Promise[] = [] + + for (let attempt = 0; attempt < this.DISCOVERY_MAX_ATTEMPTS; attempt++) { + for (const tab of splitTab.getAllTabs()) { + if (!(tab instanceof BaseTerminalTabComponent)) continue + const paneId = tab.customTitle || tab.title + if (handled.has(paneId)) continue + if (this.pendingCommands.has(paneId)) { + handled.add(paneId) + sends.push(this.processTerminalTab(tab)) + } + } + + // All commands matched (this is per-workspace because launches are serialized). + if (this.pendingCommands.size === 0) break + await this.delay(this.DISCOVERY_INTERVAL_MS) + } + + if (handled.size === 0) { + console.log('[TabbySpaces] No matching panes found for this split') + } + + await Promise.all(sends) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async processTerminalTab(terminalTab: BaseTerminalTabComponent): Promise { + const paneId = terminalTab.customTitle || terminalTab.title + const pending = this.pendingCommands.get(paneId) + if (!pending) { + return + } + this.pendingCommands.delete(paneId) + + try { + const fullCommand = this.buildFullCommand(pending) + if (!fullCommand) { + console.log('[TabbySpaces] No command to send for pane:', paneId) + return + } + + console.log('[TabbySpaces] Command matched, waiting for session + prompt:', fullCommand) + + const session = await this.waitForSession(terminalTab) + if (session) { + await this.waitForPrompt(session) + } else { + // Last resort: session never connected. Send blindly after a short delay. + console.warn('[TabbySpaces] Session never connected for pane', paneId, '- sending blindly') + await this.delay(500) + } + + console.log('[TabbySpaces] Shell ready, sending command:', fullCommand) + terminalTab.sendInput(fullCommand + '\r') + this.clearProfileArgs(terminalTab) + this.setTabTitle(terminalTab, pending.originalTitle) + } finally { + pending.resolve?.() + } + } + + /** Polls until the terminal's PTY session and its output stream exist. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private waitForSession(terminalTab: BaseTerminalTabComponent): Promise { + return new Promise((resolve) => { + let attempts = 0 + const check = () => { + const session = terminalTab.session + if (session?.output$) { + resolve(session) + return + } + if (++attempts >= this.SESSION_WAIT_MAX_ATTEMPTS) { + resolve(null) + return + } + setTimeout(check, this.SESSION_WAIT_INTERVAL_MS) + } + check() + }) + } + + /** + * Resolves once the shell has rendered a prompt, so typed input is not dropped + * during shell initialization (clink/conpty in particular drop early input). + * Falls back to an output-settle timeout, then a hard cap. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private waitForPrompt(session: any): Promise { + return new Promise((resolve) => { + let buffer = '' + let done = false + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let settleTimer: any + const finish = () => { + if (done) return + done = true + clearTimeout(settleTimer) + clearTimeout(overallTimer) + try { sub.unsubscribe() } catch { /* ignore */ } + // Small delay so the prompt is fully rendered before we type. + setTimeout(resolve, 100) + } + + const overallTimer = setTimeout(finish, this.PROMPT_TIMEOUT_MS) + // If the prompt was already painted before we subscribed (or no further + // output arrives), settle after a short quiet period instead of waiting + // for the hard cap. + settleTimer = setTimeout(finish, this.PROMPT_SETTLE_MS) + + const sub = session.output$.subscribe((data: string) => { + buffer += data + clearTimeout(settleTimer) + if (this.endsWithPrompt(buffer)) { + finish() + } else { + settleTimer = setTimeout(finish, this.PROMPT_SETTLE_MS) + } + }) + }) + } + + /** True if the (ANSI-stripped) terminal output currently ends in a shell prompt. */ + private endsWithPrompt(raw: string): boolean { + const clean = raw + .replace(StartupCommandService.CSI_PATTERN, '') + .replace(StartupCommandService.OSC_PATTERN, '') + // Walk backwards past trailing whitespace and control chars to the last visible char. + let i = clean.length - 1 + while (i >= 0) { + const code = clean.charCodeAt(i) + if (code <= 0x20 || code === 0x7f) { + i-- + continue + } + break + } + if (i < 0) return false + return StartupCommandService.PROMPT_CHARS.includes(clean[i]) + } + + private buildFullCommand(pending: PendingCommand): string | null { + return pending.command || null + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private clearProfileArgs(terminalTab: BaseTerminalTabComponent): void { + // Clear args from profile to prevent native splits from re-running startup command + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const profile = (terminalTab as any).profile + if (profile?.options?.args) { + console.log('[TabbySpaces] Clearing profile args to prevent re-run on split') + profile.options.args = [] + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private setTabTitle(terminalTab: BaseTerminalTabComponent, title: string): void { + terminalTab.setTitle(title) + terminalTab.customTitle = title + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +}