Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/terminal-scrollback-alternate-screen.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/api/src/services/terminal-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ const terminalSessionStateRelativePath: ReadonlyArray<string> = [".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,
Expand Down Expand Up @@ -901,6 +903,22 @@ const renderTmuxRightClickBindingCommands = (): ReadonlyArray<string> => [
...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,
Expand Down Expand Up @@ -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)}`
Expand Down
10 changes: 9 additions & 1 deletion packages/api/tests/terminal-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -296,14 +302,16 @@ 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)
expect(newSessionIndex).toBeLessThan(statusOffIndex)
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 () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/docker-git-session-sync/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/docker-git-session-sync/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/terminal/src/web/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 3 additions & 4 deletions packages/terminal/src/web/terminal-panel-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions packages/terminal/src/web/terminal-screen-policy.ts
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 51 additions & 0 deletions packages/terminal/tests/web/terminal-alternate-screen.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})