feat(terminal): add a Scroll to End toggle to the terminal pane (#395)

Adds a Scroll to End button to the terminal pane (shown on hover/focus). It reflects whether the viewport follows new output, turns off when you scroll up and back on at the bottom, and clicking it jumps to the end.

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
arookieofc
2026-10-30 19:23:22 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent a1294598a5
commit 86cf348310
4 changed files with 51 additions and 2 deletions
+20
View File
@@ -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),
+12 -1
View File
@@ -47,6 +47,7 @@ function tabTooltip(tab: TerminalTab, sessions: Parameters<typeof tabTitle>[1]):
function TerminalPane({ sessionKey, autofocus }: { sessionKey: string; autofocus: boolean }) {
const ref = useRef<HTMLDivElement>(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)}
/>
>
<div className={styles.paneActions} role="toolbar" aria-label="Terminal actions">
<IconButton
icon="arrow-down"
iconSize={14}
title="Scroll to End"
pressed={follow}
onClick={() => getSession(sessionKey)?.setFollow(!follow)}
/>
</div>
</div>
);
}
+18
View File
@@ -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();
}
+1 -1
View File
@@ -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<string, SessionSnapshot> => ({
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", () => {