diff --git a/src/features/terminal/Terminal.module.css b/src/features/terminal/Terminal.module.css index e9bc90e9..1fb240f5 100644 --- a/src/features/terminal/Terminal.module.css +++ b/src/features/terminal/Terminal.module.css @@ -57,6 +57,26 @@ padding: 6px 0 0 8px; overflow: hidden; outline: none; + position: relative; +} + +.paneActions { + position: absolute; + right: 14px; + bottom: 8px; + z-index: 10; + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.15s; + background: var(--bg-elev, var(--bg-app)); + border-radius: 4px; +} + +.pane:hover .paneActions, +.pane:focus-within .paneActions, +.paneActions:focus-within { + opacity: 1; } .exited :global(.xterm-cursor-layer), diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index b95c3bd3..f805b310 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -47,6 +47,7 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters[1]): function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus: boolean }) { const ref = useRef(null); const status = useTerminals((s) => s.sessions[sessionKey]?.status); + const follow = useTerminals((s) => s.sessions[sessionKey]?.follow ?? true); useLayoutEffect(() => { const el = ref.current; @@ -67,7 +68,17 @@ function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus if (e.target === e.currentTarget) getSession(sessionKey)?.focus(); }} onContextMenu={(e) => paneMenu(e, sessionKey)} - /> + > +
+ getSession(sessionKey)?.setFollow(!follow)} + /> +
+ ); } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index cdc92247..955f14af 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -52,6 +52,8 @@ export interface SessionSnapshot { commandRunning: boolean; /** Current directory (initial cwd, updated by OSC 7). */ cwd: string; + /** True while the viewport is pinned to the newest output. */ + follow: boolean; } export interface SessionOptions { @@ -192,6 +194,7 @@ export class TerminalSession { appTitle: null, commandRunning: false, cwd: opts.cwd, + follow: true, }; this.host = document.createElement("div"); this.host.className = "openidea-terminal-host"; @@ -266,6 +269,11 @@ export class TerminalSession { const modes = params.map((p) => (Array.isArray(p) ? p[0] : p) ?? 0); return modes.length > 0 && modes.every((m) => MOUSE_MODES.has(m)); }), + // Track scroll position: follow when the user scrolls back to the bottom. + this.term.onScroll(() => { + const atBottom = this.isAtBottom(); + if (atBottom !== this.snapshot.follow) this.update({ follow: atBottom }); + }), ); this.host.addEventListener("mousedown", this.onMiddleClick); } @@ -562,6 +570,16 @@ export class TerminalSession { this.term.clear(); } + isAtBottom(): boolean { + return this.term.buffer.active.viewportY >= this.term.buffer.active.baseY; + } + + /** Sets the follow flag; when turning it on also scrolls to the bottom. */ + setFollow(follow: boolean) { + if (follow) this.term.scrollToBottom(); + if (follow !== this.snapshot.follow) this.update({ follow }); + } + focus() { this.term.focus(); } diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts index 59f2297f..433749ce 100644 --- a/src/features/terminal/terminal.test.ts +++ b/src/features/terminal/terminal.test.ts @@ -365,7 +365,7 @@ describe("terminalFont", () => { describe("terminal tab titles", () => { const tab: TerminalTab = { id: "t1", name: "Local", customName: null, panes: ["s1"], layout: "s1", activePane: "s1" }; const snap = (appTitle: string | null, commandRunning = true): Record => ({ - s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p" }, + s1: { status: "running", exitCode: null, title: "zsh", appTitle, commandRunning, cwd: "/p", follow: true }, }); it("shows the default name at the prompt, whatever title the shell sets", () => {