Skip to content

Commit 81be83a

Browse files
authored
fix(web): preserve project terminal history (#320)
* fix(web): preserve project terminal history * fix(ci): restore validation on issue branch * docs(pr): replace issue 317 proof screenshots * test(api): assert tmux history setup order
1 parent 0294332 commit 81be83a

11 files changed

Lines changed: 141 additions & 53 deletions

File tree

290 KB
Loading
311 KB
Loading
146 KB
Loading

packages/api/src/services/terminal-sessions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -976,10 +976,12 @@ export const renderTmuxAttachCommand = (
976976
`if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${
977977
shellQuote(args.missingMessage ?? tmuxMissingMessage)
978978
} >&2; exit 127; fi`,
979-
`tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux new-session -d -s ${
979+
`tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux start-server \\; set-option -g history-limit 50000 \\; new-session -d -s ${
980980
shellQuote(args.tmuxName)
981981
} -c ${shellQuote(args.targetDir)}`,
982982
`tmux set-option -t ${shellQuote(args.tmuxName)} status off >/dev/null 2>&1 || true`,
983+
`tmux set-option -t ${shellQuote(args.tmuxName)} history-limit 50000 >/dev/null 2>&1 || true`,
984+
`tmux set-option -t ${shellQuote(args.tmuxName)} mouse on >/dev/null 2>&1 || true`,
983985
`exec tmux attach-session -t ${shellQuote(args.tmuxName)}`
984986
].join("; ")
985987
return `bash --noprofile --norc -lc ${shellQuote(script)}`

packages/api/tests/terminal-sessions.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,38 @@ describe("terminal sessions service", () => {
247247
expect(command).toContain("command -v tmux")
248248
expect(command).toContain("bash --noprofile --norc -lc")
249249
expect(command).toContain("tmux missing")
250-
expect(command).toContain("tmux new-session -d -s")
250+
expect(command).toContain("tmux start-server")
251+
expect(command).toContain("set-option -g history-limit 50000")
252+
expect(command).toContain("new-session -d -s")
251253
expect(command).toContain("tmux set-option")
252254
expect(command).toContain("status off")
255+
expect(command).toContain("history-limit 50000")
256+
expect(command).toContain("mouse on")
253257
expect(command).toContain("tmux attach-session -t")
254258
expect(command).toContain("docker-git-session-1")
255259
expect(command).toContain("/home/dev/project with spaces")
260+
261+
const startServerIndex = command.indexOf("tmux start-server")
262+
const globalHistoryLimitIndex = command.indexOf("set-option -g history-limit 50000")
263+
const newSessionIndex = command.indexOf("new-session -d -s")
264+
const statusOffIndex = command.indexOf("status off")
265+
const sessionHistoryLimitIndex = command.lastIndexOf("history-limit 50000")
266+
const mouseOnIndex = command.indexOf("mouse on")
267+
const attachSessionIndex = command.indexOf("tmux attach-session -t")
268+
269+
expect(startServerIndex).toBeGreaterThanOrEqual(0)
270+
expect(globalHistoryLimitIndex).toBeGreaterThanOrEqual(0)
271+
expect(newSessionIndex).toBeGreaterThanOrEqual(0)
272+
expect(statusOffIndex).toBeGreaterThanOrEqual(0)
273+
expect(sessionHistoryLimitIndex).toBeGreaterThanOrEqual(0)
274+
expect(mouseOnIndex).toBeGreaterThanOrEqual(0)
275+
expect(attachSessionIndex).toBeGreaterThanOrEqual(0)
276+
expect(startServerIndex).toBeLessThan(globalHistoryLimitIndex)
277+
expect(globalHistoryLimitIndex).toBeLessThan(newSessionIndex)
278+
expect(newSessionIndex).toBeLessThan(statusOffIndex)
279+
expect(statusOffIndex).toBeLessThan(sessionHistoryLimitIndex)
280+
expect(sessionHistoryLimitIndex).toBeLessThan(mouseOnIndex)
281+
expect(mouseOnIndex).toBeLessThan(attachSessionIndex)
256282
})
257283

258284
it("fails before creating a durable session when tmux is unavailable", async () => {

packages/app/src/docker-git/program-auth.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Effect, Match, pipe } from "effect"
22

33
import {
4+
type ApiTerminalSession,
45
codexImport,
56
codexLogin,
67
codexLogout,
@@ -19,7 +20,7 @@ import {
1920
} from "./api-client.js"
2021
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
2122
import type { Command } from "./frontend-lib/core/domain.js"
22-
import type { ApiRequestError, CliError } from "./host-errors.js"
23+
import type { ApiRequestError, CliError, ControllerBootstrapError } from "./host-errors.js"
2324
import { terminalAuthTitle } from "./menu-auth-shared.js"
2425
import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js"
2526

@@ -45,7 +46,9 @@ export type RoutedAuthCommand = Extract<
4546
}
4647
>
4748

48-
const withControllerReady = <E, R>(effect: Effect.Effect<void, E, R>) =>
49+
const withControllerReady = <E extends CliError, R>(
50+
effect: Effect.Effect<void, E, R>
51+
): Effect.Effect<void, E | ControllerBootstrapError, R | ControllerRuntime> =>
4952
pipe(ensureControllerReady(), Effect.zipRight(effect))
5053

5154
const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload))
@@ -57,6 +60,17 @@ const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError
5760
message: `Controller did not create a terminal session for ${provider}.`
5861
})
5962

63+
const attachGrokTerminalSession = (
64+
session: ApiTerminalSession | null
65+
): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
66+
session === null
67+
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
68+
: attachTerminalSession({
69+
header: terminalAuthTitle("GrokOauth"),
70+
session,
71+
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
72+
})
73+
6074
const routedAuthTags: Readonly<Record<string, true>> = {
6175
AuthCodexImport: true,
6276
AuthCodexLogin: true,
@@ -111,15 +125,7 @@ const handleGrokLoginCommand = (
111125
) =>
112126
withControllerReady(
113127
createAuthTerminalSession("GrokOauth", command.label).pipe(
114-
Effect.flatMap((session): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
115-
session === null
116-
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
117-
: attachTerminalSession({
118-
header: terminalAuthTitle("GrokOauth"),
119-
session,
120-
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
121-
})
122-
)
128+
Effect.flatMap((session) => attachGrokTerminalSession(session))
123129
)
124130
)
125131

packages/app/src/web/terminal-panel-runtime-core.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,18 @@ import type {
3030
TerminalSocketListenerArgs,
3131
TerminalSocketRef
3232
} from "./terminal-panel-runtime-types.js"
33-
import { installTerminalQuerySuppression } from "./terminal-query-suppression.js"
33+
import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } from "./terminal-query-suppression.js"
3434
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
3535
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"
3636

3737
type TerminalClientMessage =
3838
| { readonly data: string; readonly type: "input" }
3939
| { readonly cols: number; readonly rows: number; readonly type: "resize" }
4040

41+
type TerminalRuntimeOptions = {
42+
readonly querySuppression?: TerminalQuerySuppressionOptions
43+
}
44+
4145
type TerminalInlineImageFetchError = {
4246
readonly _tag: "TerminalInlineImageFetchError"
4347
readonly message: string
@@ -76,16 +80,20 @@ const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => {
7680
}
7781
}
7882

79-
export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime => {
83+
export const createTerminalRuntime = (
84+
host: HTMLDivElement,
85+
options: TerminalRuntimeOptions = {}
86+
): TerminalRuntime => {
8087
const terminal = new Terminal({
8188
allowProposedApi: true,
8289
convertEol: false,
8390
cursorBlink: true,
8491
fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace",
8592
fontSize: 14,
93+
scrollback: 50_000,
8694
theme: { background: "#080a0d", foreground: "#f4f7fb" }
8795
})
88-
installTerminalQuerySuppression(terminal)
96+
installTerminalQuerySuppression(terminal, options.querySuppression)
8997
const fitAddon = new FitAddon()
9098
terminal.loadAddon(fitAddon)
9199
terminal.open(host)

packages/app/src/web/terminal-panel-runtime.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ const resolveMountHost = (
168168
return hostRef.current
169169
}
170170

171+
const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean =>
172+
session.browserProjectId !== undefined
173+
171174
const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => {
172175
const host = resolveMountHost(args)
173176
if (host === null) {
@@ -177,7 +180,11 @@ const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undef
177180
args.connectionRef.current = { closing: false, opened: false }
178181
const lifecycle = createLifecycleState()
179182
const socketRef: TerminalSocketRef = { current: null }
180-
const { fitAddon, terminal } = createTerminalRuntime(host)
183+
const { fitAddon, terminal } = createTerminalRuntime(host, {
184+
querySuppression: {
185+
allowMouseTracking: shouldAllowTerminalMouseTracking(args.session)
186+
}
187+
})
181188
const terminalInputController = createTerminalInputController(terminal, socketRef)
182189
const pasteGuard = createTerminalPasteGuard()
183190
const sendResize = (): void => {

packages/app/src/web/terminal-query-suppression.ts

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
export type TerminalQuerySuppression = { readonly dispose: () => void }
22

3+
export type TerminalQuerySuppressionOptions = {
4+
readonly allowMouseTracking?: boolean
5+
}
6+
37
type Disposable = { readonly dispose: () => void }
48

59
type FunctionIdentifier = {
@@ -29,26 +33,29 @@ export type TerminalQuerySuppressionTarget = {
2933
}
3034
}
3135

32-
// DEC private modes whose `h`/`l` setter causes xterm.js to start emitting
33-
// unsolicited reply bytes back through `onData` on later DOM events:
34-
// 1000/1002/1003/1006/1015/1016 — mouse tracking (mouse events -> bytes)
35-
// 1004 — focus reporting (focus/blur -> CSI I/O)
36-
// Suppressing the SET (`h`) leaves xterm.js in the default state (no event
37-
// emission); suppressing the RESET (`l`) is harmless and kept for symmetry.
36+
// DEC private modes whose `h`/`l` setter can cause xterm.js to emit event bytes
37+
// back through `onData` on later DOM events.
38+
const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet<number> = new Set([
39+
1000,
40+
1002,
41+
1003,
42+
1006,
43+
1015,
44+
1016
45+
])
46+
const FOCUS_REPORTING_PRIVATE_MODE = 1004
47+
48+
// Suppressing SET leaves xterm.js in the default state (no event emission);
49+
// suppressing RESET is harmless and kept for symmetry.
3850
// Modes intentionally left to fall through to xterm's built-in handlers:
3951
// 25 — cursor visibility
4052
// 1049 — alternate screen buffer
4153
// 2004 — bracketed paste
4254
// 2026 — synchronized output (Ink uses BSU/ESU around every frame)
4355
// 1007 — alternate scroll (only changes wheel semantics, no leak)
4456
const SUPPRESSED_PRIVATE_MODES: ReadonlySet<number> = new Set([
45-
1000,
46-
1002,
47-
1003,
48-
1004,
49-
1006,
50-
1015,
51-
1016
57+
...MOUSE_TRACKING_PRIVATE_MODES,
58+
FOCUS_REPORTING_PRIVATE_MODE
5259
])
5360

5461
const isColorQuery = (data: string): boolean => {
@@ -68,10 +75,20 @@ const extractParam = (param: CsiParam): number | null => {
6875
return typeof head === "number" ? head : null
6976
}
7077

71-
const containsSuppressedPrivateMode = (params: CsiParams): boolean => {
78+
const shouldSuppressPrivateMode = (
79+
mode: number,
80+
options: TerminalQuerySuppressionOptions
81+
): boolean =>
82+
mode === FOCUS_REPORTING_PRIVATE_MODE ||
83+
(options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode))
84+
85+
const containsSuppressedPrivateMode = (
86+
params: CsiParams,
87+
options: TerminalQuerySuppressionOptions
88+
): boolean => {
7289
for (const param of params) {
7390
const value = extractParam(param)
74-
if (value !== null && SUPPRESSED_PRIVATE_MODES.has(value)) {
91+
if (value !== null && shouldSuppressPrivateMode(value, options)) {
7592
return true
7693
}
7794
}
@@ -95,15 +112,17 @@ const registerDcsSuppressor = (
95112

96113
const registerSelectivePrivateModeSuppressor = (
97114
terminal: TerminalQuerySuppressionTarget,
98-
final: "h" | "l"
115+
final: "h" | "l",
116+
options: TerminalQuerySuppressionOptions
99117
): Disposable =>
100118
terminal.parser.registerCsiHandler(
101119
{ final, prefix: "?" },
102-
(params) => containsSuppressedPrivateMode(params)
120+
(params) => containsSuppressedPrivateMode(params, options)
103121
)
104122

105123
export const installTerminalQuerySuppression = (
106-
terminal: TerminalQuerySuppressionTarget
124+
terminal: TerminalQuerySuppressionTarget,
125+
options: TerminalQuerySuppressionOptions = {}
107126
): TerminalQuerySuppression => {
108127
const disposables: ReadonlyArray<Disposable> = [
109128
// OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through.
@@ -132,11 +151,11 @@ export const installTerminalQuerySuppression = (
132151
// CSI Pm t — window manipulation. Gated by `windowOptions` (off by default);
133152
// suppressed so an accidental future enable does not leak size reports.
134153
registerCsiSuppressor(terminal, { final: "t" }),
135-
// CSI ? h / CSI ? l — block xterm from enabling focus reporting and mouse
136-
// tracking modes that would later pump unsolicited bytes back through onData.
154+
// CSI ? h / CSI ? l — block xterm from enabling focus reporting and,
155+
// unless explicitly allowed for tmux project terminals, mouse tracking modes.
137156
// Other DEC private modes fall through to xterm's built-in setters.
138-
registerSelectivePrivateModeSuppressor(terminal, "h"),
139-
registerSelectivePrivateModeSuppressor(terminal, "l")
157+
registerSelectivePrivateModeSuppressor(terminal, "h", options),
158+
registerSelectivePrivateModeSuppressor(terminal, "l", options)
140159
]
141160
return {
142161
dispose: () => {

packages/app/tests/docker-git/terminal-query-suppression.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ const ADDED_CSI_IDENTIFIERS: ReadonlyArray<FunctionIdentifier> = [
123123
{ final: "t" }
124124
]
125125

126-
const SUPPRESSED_MODES: ReadonlyArray<number> = [1000, 1002, 1003, 1004, 1006, 1015, 1016]
126+
const MOUSE_TRACKING_MODES: ReadonlyArray<number> = [1000, 1002, 1003, 1006, 1015, 1016]
127+
128+
const FOCUS_REPORTING_MODE = 1004
129+
130+
const SUPPRESSED_MODES: ReadonlyArray<number> = [...MOUSE_TRACKING_MODES, FOCUS_REPORTING_MODE]
127131

128132
const PASS_THROUGH_MODES: ReadonlyArray<number> = [25, 1007, 1049, 2004, 2026]
129133

@@ -218,6 +222,20 @@ describe("terminal query suppression", () => {
218222
}
219223
})
220224

225+
it("allows DEC private mouse tracking when explicitly enabled for tmux project terminals", () => {
226+
const mock = createMockTerminal()
227+
installTerminalQuerySuppression(mock.terminal, { allowMouseTracking: true })
228+
const setHandler = findCsi(mock, { final: "h", prefix: "?" })
229+
const resetHandler = findCsi(mock, { final: "l", prefix: "?" })
230+
231+
for (const mode of MOUSE_TRACKING_MODES) {
232+
expect(setHandler.callback([mode])).toBe(false)
233+
expect(resetHandler.callback([mode])).toBe(false)
234+
}
235+
expect(setHandler.callback([FOCUS_REPORTING_MODE])).toBe(true)
236+
expect(resetHandler.callback([FOCUS_REPORTING_MODE])).toBe(true)
237+
})
238+
221239
it("lets benign DEC private modes fall through to the built-in handler", () => {
222240
const mock = createMockTerminal()
223241
installTerminalQuerySuppression(mock.terminal)

0 commit comments

Comments
 (0)