diff --git a/src/components/terminal/TerminalView.test.tsx b/src/components/terminal/TerminalView.test.tsx
index 0783d09..35257f3 100644
--- a/src/components/terminal/TerminalView.test.tsx
+++ b/src/components/terminal/TerminalView.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { render, act } from "@testing-library/react";
+import { render, act, screen } from "@testing-library/react";
// Declared through vi.hoisted so the vi.mock factories below — which are
// hoisted to the top of the module — can reach them.
@@ -15,6 +15,7 @@ const { FakeTerminal, FakeWebSocket } = vi.hoisted(() => {
rows = 24;
written: string[] = [];
disposed = false;
+ focusCount = 0;
private queue: Array<{ data: string; cb?: () => void }> = [];
private dataHandler: ((d: string) => void) | undefined;
@@ -25,6 +26,9 @@ const { FakeTerminal, FakeWebSocket } = vi.hoisted(() => {
loadAddon() {}
open() {}
+ focus() {
+ this.focusCount++;
+ }
dispose() {
this.disposed = true;
}
@@ -298,6 +302,76 @@ describe("TerminalView replay gate", () => {
});
});
+ it("focuses the terminal on mount so selecting the tab lands the cursor", () => {
+ const { term } = setup();
+
+ expect(term.focusCount).toBe(1);
+ });
+
+ it("shows a loading indicator while the terminal is still blank", () => {
+ vi.useFakeTimers();
+ setup();
+
+ // Nothing yet — the indicator is delayed to avoid flickering on a fast
+ // attach, so it must not be up immediately.
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+
+ expect(screen.getByRole("status")).toHaveTextContent("Starting terminal");
+ });
+
+ it("does not flash the indicator when output arrives promptly", () => {
+ // Re-attaching to a running PTY paints from the replay buffer within a
+ // few milliseconds. The indicator must never appear at all in that
+ // window — showing and immediately hiding it reads as a glitch.
+ vi.useFakeTimers();
+ const { ws } = setup();
+
+ act(() => {
+ vi.advanceTimersByTime(20);
+ });
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+
+ deliver(ws, 0x02, "previous scrollback");
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+
+ it("hides the loading indicator once the first output arrives", () => {
+ vi.useFakeTimers();
+ const { ws } = setup();
+
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+ expect(screen.getByRole("status")).toBeInTheDocument();
+
+ deliver(ws, 0x00, "user@container:~$ ");
+
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+
+ it("keeps the indicator up when only the replay boundary has arrived", () => {
+ // `devpod ssh` spawns fast but connects slowly, so 0x03 lands on a blank
+ // screen. Treating it as ready would drop the spinner too early.
+ vi.useFakeTimers();
+ const { term, ws } = setup();
+
+ deliver(ws, 0x03);
+ act(() => term.flush());
+ act(() => {
+ vi.advanceTimersByTime(250);
+ });
+
+ expect(screen.getByRole("status")).toBeInTheDocument();
+ });
+
it("clears the fallback timer on unmount", () => {
vi.useFakeTimers();
const { unmount } = render();
diff --git a/src/components/terminal/TerminalView.tsx b/src/components/terminal/TerminalView.tsx
index 7d3d4c7..defa200 100644
--- a/src/components/terminal/TerminalView.tsx
+++ b/src/components/terminal/TerminalView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
@@ -6,10 +6,22 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { useSessionStore } from "@/stores/session-store";
+// How long the terminal may stay blank before we show a spinner. Attaching to
+// an existing PTY paints from the replay buffer almost immediately, and a
+// spinner that appears for 20ms reads as a flicker rather than as feedback.
+const SPINNER_DELAY_MS = 250;
+
export function TerminalView() {
const { activeSessionId, sessions } = useSessionStore();
const terminalRef = useRef(null);
+ // `devpod ssh` spawns immediately but takes seconds to establish the
+ // connection into the container, so a first attach sits on a black
+ // rectangle with no explanation. First output is the honest ready signal:
+ // the replay boundary arrives long before the shell has drawn anything.
+ const [hasOutput, setHasOutput] = useState(false);
+ const [spinnerVisible, setSpinnerVisible] = useState(false);
+
const session = sessions.find((s) => s.id === activeSessionId);
useEffect(() => {
@@ -18,6 +30,14 @@ export function TerminalView() {
let disposed = false;
let ws: WebSocket | null = null;
+ // Switching sessions re-runs this effect without remounting, so the
+ // previous session's ready state must not carry over.
+ setHasOutput(false);
+ setSpinnerVisible(false);
+ const spinnerTimer = setTimeout(() => {
+ if (!disposed) setSpinnerVisible(true);
+ }, SPINNER_DELAY_MS);
+
// The server replays recent PTY output to every newly-attached client so
// the terminal isn't blank on reconnect. That buffer can contain terminal
// *queries* the remote shell emitted earlier (OSC 11 background-colour,
@@ -69,6 +89,11 @@ export function TerminalView() {
term.loadAddon(webLinksAddon);
term.open(terminalRef.current);
fitAddon.fit();
+ // The tab was just selected, so the terminal is what the user came for —
+ // put the cursor in it rather than making them click first. This effect
+ // re-runs on every mount, and CenterPanel remounts TerminalView on tab
+ // switch, so selecting the tab focuses the terminal.
+ term.focus();
// Send keystrokes to WebSocket with 0x00 prefix
term.onData((data) => {
@@ -125,6 +150,12 @@ export function TerminalView() {
const data = new Uint8Array(event.data as ArrayBuffer);
if (data.length < 1) return;
+ // Any payload-bearing frame means the terminal has something to show.
+ if ((data[0] === 0x00 || data[0] === 0x02) && data.length > 1) {
+ clearTimeout(spinnerTimer);
+ setHasOutput(true);
+ }
+
switch (data[0]) {
case 0x02:
// Replayed output renders exactly like live output — the frame type
@@ -153,6 +184,7 @@ export function TerminalView() {
return () => {
disposed = true;
clearTimeout(unmuteTimer);
+ clearTimeout(spinnerTimer);
resizeObserver.disconnect();
if (ws) {
ws.onmessage = null;
@@ -198,10 +230,22 @@ export function TerminalView() {
);
}
+ // The terminal container must stay mounted while connecting — xterm has
+ // already attached to it — so the indicator overlays rather than replaces it.
return (
-
+