diff --git a/.changeset/terminal-scrollback-alternate-screen.md b/.changeset/terminal-scrollback-alternate-screen.md new file mode 100644 index 00000000..05c00b66 --- /dev/null +++ b/.changeset/terminal-scrollback-alternate-screen.md @@ -0,0 +1,11 @@ +--- +"@prover-coder-ai/docker-git-terminal": patch +--- + +Fix project terminals clearing all output and showing only one page (no scroll). + +Project terminals run inside tmux, which switches xterm into the alternate +screen buffer (DEC private modes 47/1047/1049). The alternate screen keeps no +scrollback, so output was wiped on every repaint and wheel scrolling had nothing +to reveal. Project terminals now suppress the alternate screen so tmux/TUI output +stays in xterm's normal buffer and accumulates in the 50k-line scrollback. diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index e3e26beb..e637731e 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -111,6 +111,8 @@ const terminalSessionStateRelativePath: ReadonlyArray = [".orch", "state const tmuxMissingMessage = "tmux is not available in this project container. Apply docker-git config or rebuild the project image so tmux is installed, then reopen this SSH terminal session." const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu +const tmuxInitialScrollbackLines = 10_000 +const tmuxInitialScrollbackMaxBytes = 1024 * 1024 const DurableTerminalSessionSchema = Schema.Struct({ id: Schema.String, @@ -901,6 +903,22 @@ const renderTmuxRightClickBindingCommands = (): ReadonlyArray => [ ...tmuxRightClickSuppressBindings.map(renderTmuxRightClickSuppressBinding) ] +// CHANGE: Build a bounded tmux pane-history preload before browser attach. +// WHY: Browser scrolling must use xterm's local scrollback instead of sending wheel input to tmux. +// QUOTE(ТЗ): "при открытии страницы он загружает сразу например историю из последних 10к символов" +// REF: user-message-2026-06-15-native-terminal-scrollback +// SOURCE: n/a +// FORMAT THEOREM: attach(tmux) -> preload(suffix(history(tmux)), <= tmuxInitialScrollbackMaxBytes) +// PURITY: CORE +// INVARIANT: emitted command is bounded and shell-quotes the tmux target. +// COMPLEXITY: O(1)/O(1) +const renderTmuxInitialScrollbackCommand = (tmuxName: string): string => + [ + `tmux capture-pane -p -J -S -${tmuxInitialScrollbackLines} -t ${shellQuote(tmuxName)} 2>/dev/null`, + `tail -c ${tmuxInitialScrollbackMaxBytes}`, + "sed 's/$/\\r/'" + ].join(" | ") + " || true" + const writeBufferToProjectContainer = ( containerName: string, containerPath: string, @@ -1119,6 +1137,7 @@ export const renderTmuxAttachCommand = ( `tmux set-option -t ${shellQuote(args.tmuxName)} history-limit 50000 >/dev/null 2>&1 || true`, `tmux set-option -t ${shellQuote(args.tmuxName)} mouse on >/dev/null 2>&1 || true`, ...renderTmuxRightClickBindingCommands(), + renderTmuxInitialScrollbackCommand(args.tmuxName), `exec tmux attach-session -t ${shellQuote(args.tmuxName)}` ].join("; ") return `bash --noprofile --norc -lc ${shellQuote(script)}` diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 76a6beae..017a92f1 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -276,6 +276,11 @@ describe("terminal sessions service", () => { expect(command).toContain("unbind-key -T root M-MouseDown3StatusRight") expect(command).toContain("unbind-key -T root M-MouseDown3Border") expect(command).not.toContain("display-menu") + expect(command).toContain("tmux capture-pane") + expect(command).toContain("-S -10000") + expect(command).toContain("tail -c 1048576") + expect(command).toContain("sed") + expect(command).toContain("s/$/\\r/") expect(command).toContain("tmux attach-session -t") expect(command).toContain("docker-git-session-1") expect(command).toContain("/home/dev/project with spaces") @@ -287,6 +292,7 @@ describe("terminal sessions service", () => { const sessionHistoryLimitIndex = command.lastIndexOf("history-limit 50000") const mouseOnIndex = command.indexOf("mouse on") const rightClickBindingIndex = command.indexOf("MouseDown3Pane") + const initialScrollbackIndex = command.indexOf("tmux capture-pane") const attachSessionIndex = command.indexOf("tmux attach-session -t") expect(startServerIndex).toBeGreaterThanOrEqual(0) @@ -296,6 +302,7 @@ describe("terminal sessions service", () => { expect(sessionHistoryLimitIndex).toBeGreaterThanOrEqual(0) expect(mouseOnIndex).toBeGreaterThanOrEqual(0) expect(rightClickBindingIndex).toBeGreaterThan(mouseOnIndex) + expect(initialScrollbackIndex).toBeGreaterThan(rightClickBindingIndex) expect(attachSessionIndex).toBeGreaterThanOrEqual(0) expect(startServerIndex).toBeLessThan(globalHistoryLimitIndex) expect(globalHistoryLimitIndex).toBeLessThan(newSessionIndex) @@ -303,7 +310,8 @@ describe("terminal sessions service", () => { expect(statusOffIndex).toBeLessThan(sessionHistoryLimitIndex) expect(sessionHistoryLimitIndex).toBeLessThan(mouseOnIndex) expect(mouseOnIndex).toBeLessThan(rightClickBindingIndex) - expect(rightClickBindingIndex).toBeLessThan(attachSessionIndex) + expect(rightClickBindingIndex).toBeLessThan(initialScrollbackIndex) + expect(initialScrollbackIndex).toBeLessThan(attachSessionIndex) }) it("fails before creating a durable session when tmux is unavailable", async () => { diff --git a/packages/app/CHANGELOG.md b/packages/app/CHANGELOG.md index ee8953b4..5007dc54 100644 --- a/packages/app/CHANGELOG.md +++ b/packages/app/CHANGELOG.md @@ -1,5 +1,14 @@ # @prover-coder-ai/docker-git +## 1.3.2 + +### Patch Changes + +- chore: automated version bump + +- Updated dependencies []: + - @prover-coder-ai/docker-git-session-sync@1.0.61 + ## 1.3.1 ### Patch Changes diff --git a/packages/app/package.json b/packages/app/package.json index 038b8a5e..2444966d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git", - "version": "1.3.1", + "version": "1.3.2", "description": "docker-git Bun and Gridland CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { diff --git a/packages/docker-git-session-sync/CHANGELOG.md b/packages/docker-git-session-sync/CHANGELOG.md index 4b5617a6..4d228119 100644 --- a/packages/docker-git-session-sync/CHANGELOG.md +++ b/packages/docker-git-session-sync/CHANGELOG.md @@ -1,5 +1,11 @@ # @prover-coder-ai/docker-git-session-sync +## 1.0.61 + +### Patch Changes + +- chore: automated version bump + ## 1.0.60 ### Patch Changes diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 3e287cb6..8ecf929d 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -1,6 +1,6 @@ { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.60", + "version": "1.0.61", "description": "Standalone docker-git AI agent session synchronization tool", "main": "dist/docker-git-session-sync.js", "bin": { diff --git a/packages/terminal/src/web/index.ts b/packages/terminal/src/web/index.ts index 1097c693..5825bd7f 100644 --- a/packages/terminal/src/web/index.ts +++ b/packages/terminal/src/web/index.ts @@ -57,6 +57,7 @@ export * from "./terminal-panel-runtime-types.js" export * from "./terminal-panel-runtime.js" export * from "./terminal-query-suppression.js" export * from "./terminal-reconnect.js" +export * from "./terminal-screen-policy.js" export * from "./terminal-state.js" export * from "./terminal-wheel-scroll.js" export * from "./terminal.js" diff --git a/packages/terminal/src/web/terminal-panel-runtime.ts b/packages/terminal/src/web/terminal-panel-runtime.ts index ac0bc0d9..d3cbda1c 100644 --- a/packages/terminal/src/web/terminal-panel-runtime.ts +++ b/packages/terminal/src/web/terminal-panel-runtime.ts @@ -21,6 +21,7 @@ import type { TerminalSocketConnectArgs, TerminalSocketRef } from "./terminal-panel-runtime-types.js" +import { shouldAllowTerminalMouseTracking, shouldSuppressTerminalAlternateScreen } from "./terminal-screen-policy.js" import { attachTerminalWheelScroll } from "./terminal-wheel-scroll.js" import { isPendingActiveTerminalSession } from "./terminal.js" @@ -190,9 +191,6 @@ const resolveMountHost = ( return hostRef.current } -const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean => - session.browserProjectId !== undefined - const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => { const host = resolveMountHost(args) if (host === null) { @@ -204,7 +202,8 @@ const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undef const socketRef: TerminalSocketRef = { current: null } const { fitAddon, terminal } = createTerminalRuntime(host, { querySuppression: { - allowMouseTracking: shouldAllowTerminalMouseTracking(args.session) + allowMouseTracking: shouldAllowTerminalMouseTracking(args.session), + suppressAlternateScreen: shouldSuppressTerminalAlternateScreen(args.session) } }) const terminalInputController = createTerminalInputController(terminal, socketRef) diff --git a/packages/terminal/src/web/terminal-screen-policy.ts b/packages/terminal/src/web/terminal-screen-policy.ts new file mode 100644 index 00000000..dcd5f046 --- /dev/null +++ b/packages/terminal/src/web/terminal-screen-policy.ts @@ -0,0 +1,34 @@ +import type { ActiveTerminalSession } from "./terminal.js" + +// CHANGE: Treat sessions carrying a browser project id as tmux-backed project terminals. +// WHY: Project terminals run inside tmux, which both drives mouse tracking and switches +// xterm into the alternate screen; auth/login terminals stay conservative. +// REF: issue-404 terminal cannot scroll. +// PURITY: CORE +// COMPLEXITY: O(1)/O(1) +export const isProjectTerminalSession = (session: ActiveTerminalSession): boolean => + session.browserProjectId !== undefined + +/** + * Whether xterm should allow DEC private mouse tracking modes for a session. + * + * @pure true + * @complexity O(1) + */ +export const shouldAllowTerminalMouseTracking = (session: ActiveTerminalSession): boolean => + isProjectTerminalSession(session) + +/** + * Whether xterm should suppress the alternate screen (DEC 47/1047/1049) for a session. + * + * Project terminals run inside tmux: with the alternate screen active xterm keeps no + * scrollback, so the terminal "constantly clears all text and only shows one page" and + * wheel scrolling has nothing to scroll. Suppressing it keeps tmux/TUI output in xterm's + * normal buffer where it accumulates in the 50k-line scrollback. + * + * @pure true + * @complexity O(1) + */ +// REF: issue-404 terminal cannot scroll. +export const shouldSuppressTerminalAlternateScreen = (session: ActiveTerminalSession): boolean => + isProjectTerminalSession(session) diff --git a/packages/terminal/tests/web/terminal-alternate-screen.test.ts b/packages/terminal/tests/web/terminal-alternate-screen.test.ts new file mode 100644 index 00000000..8dc8356f --- /dev/null +++ b/packages/terminal/tests/web/terminal-alternate-screen.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest" + +import { + isProjectTerminalSession, + shouldAllowTerminalMouseTracking, + shouldSuppressTerminalAlternateScreen +} from "../../src/web/terminal-screen-policy.js" +import type { ActiveTerminalSession } from "../../src/web/terminal.js" + +const baseSession: ActiveTerminalSession = { + closePath: "/projects/by-key/demo/terminal-sessions/abc", + exitMessage: "ended", + header: "SSH terminal: demo", + pendingDeleteMessage: "closed", + readyMessage: "ready", + session: { + createdAt: "2026-04-21T00:00:00.000Z", + id: "abc", + projectId: "project-demo", + sshCommand: "ssh dev@demo", + status: "ready" + }, + subtitle: "ssh dev@demo", + websocketPath: "/projects/by-key/demo/terminal-sessions/abc/ws" +} + +const projectSession: ActiveTerminalSession = { + ...baseSession, + browserProjectId: "project-demo", + browserProjectKey: "project-key-demo", + browserProjectName: "demo" +} + +describe("terminal alternate screen suppression gating", () => { + it("suppresses the alternate screen for tmux-backed project terminals", () => { + // Project terminals run inside tmux: keeping the alternate screen off lets output + // accumulate in xterm's scrollback so wheel scrolling can reveal earlier history. + expect(shouldSuppressTerminalAlternateScreen(projectSession)).toBe(true) + expect(shouldAllowTerminalMouseTracking(projectSession)).toBe(true) + }) + + it("keeps the alternate screen for non-project (auth) terminals", () => { + expect(shouldSuppressTerminalAlternateScreen(baseSession)).toBe(false) + expect(shouldAllowTerminalMouseTracking(baseSession)).toBe(false) + }) + + it("classifies project sessions by their browser project id", () => { + expect(isProjectTerminalSession(projectSession)).toBe(true) + expect(isProjectTerminalSession(baseSession)).toBe(false) + }) +})