diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 4a268524b..5e8c3d8f1 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -29,6 +29,10 @@ import { TerminalStreamController, type TerminalStreamControllerStatus, } from "@/terminal/runtime/terminal-stream-controller"; +import { + summarizeTerminalText, + terminalDebugLog, +} from "@/terminal/runtime/terminal-debug"; import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { @@ -459,10 +463,32 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } ): boolean => { if (!client || activeStreamId === null) { + terminalDebugLog({ + scope: "terminal-pane", + event: "input:key:drop-no-stream", + details: { + key: input.key, + ctrl: input.ctrl, + shift: input.shift, + alt: input.alt, + activeStreamId, + }, + }); return false; } const normalizedKey = normalizeTerminalTransportKey(input.key); + terminalDebugLog({ + scope: "terminal-pane", + event: "input:key:send", + details: { + key: normalizedKey, + ctrl: input.ctrl, + shift: input.shift, + alt: input.alt, + activeStreamId, + }, + }); client.sendTerminalStreamKey(activeStreamId, { key: normalizedKey, ctrl: input.ctrl, @@ -480,6 +506,15 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { if (data.length === 0) { return; } + terminalDebugLog({ + scope: "terminal-pane", + event: "input:data:received", + details: { + length: data.length, + preview: summarizeTerminalText({ text: data, maxChars: 80 }), + activeStreamId, + }, + }); if (hasPendingTerminalModifiers(modifiers)) { const pendingResolution = resolvePendingModifierDataInput({ @@ -507,8 +542,25 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } if (!client || activeStreamId === null) { + terminalDebugLog({ + scope: "terminal-pane", + event: "input:data:drop-no-stream", + details: { + length: data.length, + preview: summarizeTerminalText({ text: data, maxChars: 80 }), + }, + }); return; } + terminalDebugLog({ + scope: "terminal-pane", + event: "input:data:send", + details: { + length: data.length, + preview: summarizeTerminalText({ text: data, maxChars: 80 }), + activeStreamId, + }, + }); client.sendTerminalStreamInput(activeStreamId, data); }, [ @@ -539,6 +591,15 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return; } lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; + terminalDebugLog({ + scope: "terminal-pane", + event: "display:resize:send", + details: { + terminalId: selectedTerminalId, + rows: normalizedRows, + cols: normalizedCols, + }, + }); client.sendTerminalInput(selectedTerminalId, { type: "resize", rows: normalizedRows, diff --git a/packages/app/src/terminal/runtime/terminal-debug.ts b/packages/app/src/terminal/runtime/terminal-debug.ts new file mode 100644 index 000000000..8a7db84ad --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-debug.ts @@ -0,0 +1,77 @@ +type TerminalDebugGlobal = { + __PASEO_TERMINAL_DEBUG?: boolean; +}; + +type TerminalDebugLogInput = { + scope: string; + event: string; + details?: Record; +}; + +function resolveGlobalDebugFlag(): boolean | null { + if (typeof globalThis === "undefined") { + return null; + } + + const value = (globalThis as TerminalDebugGlobal).__PASEO_TERMINAL_DEBUG; + if (typeof value === "boolean") { + return value; + } + return null; +} + +export function isTerminalDebugEnabled(): boolean { + const globalFlag = resolveGlobalDebugFlag(); + if (globalFlag !== null) { + return globalFlag; + } + return process.env.NODE_ENV === "development"; +} + +export function terminalDebugLog(input: TerminalDebugLogInput): void { + if (!isTerminalDebugEnabled()) { + return; + } + + const payload = input.details + ? { ...input.details, ts: Date.now() } + : { ts: Date.now() }; + console.log(`[terminal][${input.scope}] ${input.event}`, payload); +} + +function escapeControlBytes(input: { text: string }): string { + let output = ""; + for (const char of input.text) { + const code = char.charCodeAt(0); + if (code === 10) { + output += "\\n"; + continue; + } + if (code === 13) { + output += "\\r"; + continue; + } + if (code === 9) { + output += "\\t"; + continue; + } + if (code < 32 || code === 127) { + output += `\\x${code.toString(16).padStart(2, "0")}`; + continue; + } + output += char; + } + return output; +} + +export function summarizeTerminalText(input: { + text: string; + maxChars?: number; +}): string { + const maxChars = input.maxChars ?? 80; + const escaped = escapeControlBytes({ text: input.text }); + if (escaped.length <= maxChars) { + return escaped; + } + return `${escaped.slice(0, maxChars)}…`; +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 9b273beb9..8ca94ae55 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -8,6 +8,7 @@ import { normalizeTerminalTransportKey, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; +import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; export type TerminalEmulatorRuntimeTheme = { backgroundColor: string; @@ -82,6 +83,13 @@ export class TerminalEmulatorRuntime { } mount(input: TerminalEmulatorRuntimeMountInput): void { + terminalDebugLog({ + scope: "emulator-runtime", + event: "mount:start", + details: { + initialOutputLength: input.initialOutputText.length, + }, + }); this.unmount(); input.host.innerHTML = ""; @@ -138,6 +146,15 @@ export class TerminalEmulatorRuntime { } this.lastSize = { rows: nextRows, cols: nextCols }; + terminalDebugLog({ + scope: "emulator-runtime", + event: "resize:emit", + details: { + rows: nextRows, + cols: nextCols, + force, + }, + }); this.callbacks.onResize?.({ rows: nextRows, cols: nextCols, @@ -147,6 +164,14 @@ export class TerminalEmulatorRuntime { fitAndEmitResize(true); const inputDisposable = terminal.onData((data) => { + terminalDebugLog({ + scope: "emulator-runtime", + event: "input:onData", + details: { + length: data.length, + preview: summarizeTerminalText({ text: data, maxChars: 64 }), + }, + }); this.callbacks.onInput?.(data); }); @@ -182,6 +207,17 @@ export class TerminalEmulatorRuntime { key: normalizeTerminalTransportKey(normalizedKey), ...modifiers, }); + terminalDebugLog({ + scope: "emulator-runtime", + event: "key:intercepted", + details: { + key: normalizedKey, + ctrl: modifiers.ctrl, + shift: modifiers.shift, + alt: modifiers.alt, + meta: modifiers.meta, + }, + }); if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { this.callbacks.onPendingModifiersConsumed?.(); @@ -238,6 +274,17 @@ export class TerminalEmulatorRuntime { if (input.initialOutputText.length > 0) { terminal.write(input.initialOutputText); + terminalDebugLog({ + scope: "emulator-runtime", + event: "output:initial-write", + details: { + length: input.initialOutputText.length, + preview: summarizeTerminalText({ + text: input.initialOutputText, + maxChars: 96, + }), + }, + }); } const disposables: TerminalEmulatorRuntimeDisposables = { @@ -296,11 +343,24 @@ export class TerminalEmulatorRuntime { return; } this.pendingWriteText += input.text; + terminalDebugLog({ + scope: "emulator-runtime", + event: "output:enqueue", + details: { + chunkLength: input.text.length, + queueLength: this.pendingWriteText.length, + preview: summarizeTerminalText({ text: input.text, maxChars: 64 }), + }, + }); this.scheduleWriteFlush(); } clear(): void { this.pendingWriteText = ""; + terminalDebugLog({ + scope: "emulator-runtime", + event: "output:clear", + }); this.terminal?.reset(); } @@ -309,6 +369,13 @@ export class TerminalEmulatorRuntime { } unmount(): void { + terminalDebugLog({ + scope: "emulator-runtime", + event: "mount:unmount", + details: { + pendingWriteLength: this.pendingWriteText.length, + }, + }); this.pendingWriteText = ""; this.isWriteFlushQueued = false; @@ -340,6 +407,14 @@ export class TerminalEmulatorRuntime { } const text = this.pendingWriteText; this.pendingWriteText = ""; + terminalDebugLog({ + scope: "emulator-runtime", + event: "output:flush", + details: { + length: text.length, + preview: summarizeTerminalText({ text, maxChars: 96 }), + }, + }); this.terminal.write(text); } diff --git a/packages/app/src/terminal/runtime/terminal-output-pump.ts b/packages/app/src/terminal/runtime/terminal-output-pump.ts index 0fc22f6d4..40ccb54ac 100644 --- a/packages/app/src/terminal/runtime/terminal-output-pump.ts +++ b/packages/app/src/terminal/runtime/terminal-output-pump.ts @@ -4,6 +4,7 @@ import { readTerminalOutputBuffer, type TerminalOutputBuffer, } from "@/utils/terminal-output-buffer"; +import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; export type TerminalOutputChunk = { sequence: number; @@ -50,6 +51,14 @@ export class TerminalOutputPump { return; } + terminalDebugLog({ + scope: "output-pump", + event: "selected-terminal:set", + details: { + previousTerminalId: this.selectedTerminalId, + nextTerminalId: input.terminalId, + }, + }); this.clearSelectedChunkFlushTimer(); this.selectedChunkAccumulator = ""; this.selectedTerminalId = input.terminalId; @@ -77,6 +86,16 @@ export class TerminalOutputPump { } this.selectedChunkAccumulator += input.text; + terminalDebugLog({ + scope: "output-pump", + event: "selected-terminal:accumulate", + details: { + terminalId: input.terminalId, + appendedLength: input.text.length, + accumulatorLength: this.selectedChunkAccumulator.length, + preview: summarizeTerminalText({ text: input.text, maxChars: 80 }), + }, + }); this.scheduleSelectedChunkFlush(); } @@ -134,6 +153,15 @@ export class TerminalOutputPump { const text = this.selectedChunkAccumulator; this.selectedChunkAccumulator = ""; + terminalDebugLog({ + scope: "output-pump", + event: "selected-terminal:flush", + details: { + terminalId: this.selectedTerminalId, + textLength: text.length, + preview: summarizeTerminalText({ text, maxChars: 96 }), + }, + }); this.emitSelectedChunk({ text }); } diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index df5127d07..19dead0d2 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -6,6 +6,7 @@ import { waitForDuration, withPromiseTimeout, } from "@/utils/terminal-attach"; +import { summarizeTerminalText, terminalDebugLog } from "./terminal-debug"; export type TerminalStreamControllerAttachPayload = { streamId: number | null; @@ -100,6 +101,14 @@ export class TerminalStreamController { if (this.isDisposed) { return; } + terminalDebugLog({ + scope: "stream-controller", + event: "terminal:set", + details: { + previousTerminalId: this.selectedTerminalId, + nextTerminalId: input.terminalId, + }, + }); const nextTerminalId = input.terminalId; const previousTerminalId = this.selectedTerminalId; @@ -144,6 +153,14 @@ export class TerminalStreamController { if (this.isDisposed) { return; } + terminalDebugLog({ + scope: "stream-controller", + event: "stream:exit", + details: { + terminalId: input.terminalId, + streamId: input.streamId, + }, + }); const activeStream = this.activeStream; if (!activeStream) { @@ -217,6 +234,16 @@ export class TerminalStreamController { if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) { return; } + terminalDebugLog({ + scope: "stream-controller", + event: "attach:attempt", + details: { + terminalId: input.terminalId, + generation: input.generation, + attempt, + maxAttachAttempts, + }, + }); try { const preferredSize = this.options.getPreferredSize(); @@ -244,6 +271,15 @@ export class TerminalStreamController { if (attachPayload.error || typeof attachPayload.streamId !== "number") { lastErrorMessage = attachPayload.error ?? "Unable to attach terminal stream"; + terminalDebugLog({ + scope: "stream-controller", + event: "attach:response-error", + details: { + terminalId: input.terminalId, + attempt, + error: lastErrorMessage, + }, + }); const hasRemainingAttempts = attempt < maxAttachAttempts - 1; if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); @@ -287,6 +323,16 @@ export class TerminalStreamController { decoder, unsubscribe, }; + terminalDebugLog({ + scope: "stream-controller", + event: "attach:success", + details: { + terminalId: input.terminalId, + streamId, + currentOffset: attachPayload.currentOffset, + reset: attachPayload.reset, + }, + }); this.updateStatus({ terminalId: input.terminalId, streamId, @@ -297,6 +343,15 @@ export class TerminalStreamController { } catch (error) { lastErrorMessage = error instanceof Error ? error.message : "Unable to attach terminal stream"; + terminalDebugLog({ + scope: "stream-controller", + event: "attach:exception", + details: { + terminalId: input.terminalId, + attempt, + error: lastErrorMessage, + }, + }); const hasRemainingAttempts = attempt < maxAttachAttempts - 1; if (hasRemainingAttempts && isRetryableError({ message: lastErrorMessage })) { await waitForDelay({ durationMs: getRetryDelayMs({ attempt }) }); @@ -345,6 +400,18 @@ export class TerminalStreamController { if (text.length === 0) { return; } + terminalDebugLog({ + scope: "stream-controller", + event: "stream:chunk", + details: { + terminalId: input.terminalId, + streamId: input.streamId, + endOffset: input.chunk.endOffset, + byteLength: input.chunk.data.byteLength, + textLength: text.length, + preview: summarizeTerminalText({ text, maxChars: 96 }), + }, + }); this.options.onChunk({ terminalId: input.terminalId, @@ -357,6 +424,15 @@ export class TerminalStreamController { if (!activeStream) { return; } + terminalDebugLog({ + scope: "stream-controller", + event: "stream:detach", + details: { + terminalId: activeStream.terminalId, + streamId: activeStream.streamId, + shouldDetach: input.shouldDetach, + }, + }); this.activeStream = null; try { @@ -403,7 +479,16 @@ export class TerminalStreamController { private updateStatus(status: TerminalStreamControllerStatus): void { this.status = status; + terminalDebugLog({ + scope: "stream-controller", + event: "status:update", + details: { + terminalId: status.terminalId, + streamId: status.streamId, + isAttaching: status.isAttaching, + error: status.error, + }, + }); this.options.onStatusChange?.(status); } } -