diff --git a/docs/pr-screenshots/issue-317/terminal-latest-output.png b/docs/pr-screenshots/issue-317/terminal-latest-output.png new file mode 100644 index 00000000..c8646b72 Binary files /dev/null and b/docs/pr-screenshots/issue-317/terminal-latest-output.png differ diff --git a/docs/pr-screenshots/issue-317/terminal-scrolled-history.png b/docs/pr-screenshots/issue-317/terminal-scrolled-history.png new file mode 100644 index 00000000..21d6c0c4 Binary files /dev/null and b/docs/pr-screenshots/issue-317/terminal-scrolled-history.png differ diff --git a/docs/pr-screenshots/issue-317/web-terminal-pr-checks.png b/docs/pr-screenshots/issue-317/web-terminal-pr-checks.png new file mode 100644 index 00000000..52f8c35c Binary files /dev/null and b/docs/pr-screenshots/issue-317/web-terminal-pr-checks.png differ diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 9247f56b..7898edaa 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -976,10 +976,12 @@ export const renderTmuxAttachCommand = ( `if ! command -v tmux >/dev/null 2>&1; then printf '%s\\n' ${ shellQuote(args.missingMessage ?? tmuxMissingMessage) } >&2; exit 127; fi`, - `tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux new-session -d -s ${ + `tmux has-session -t ${shellQuote(args.tmuxName)} 2>/dev/null || tmux start-server \\; set-option -g history-limit 50000 \\; new-session -d -s ${ shellQuote(args.tmuxName) } -c ${shellQuote(args.targetDir)}`, `tmux set-option -t ${shellQuote(args.tmuxName)} status off >/dev/null 2>&1 || true`, + `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`, `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 e91077d0..4f507b75 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -247,12 +247,38 @@ describe("terminal sessions service", () => { expect(command).toContain("command -v tmux") expect(command).toContain("bash --noprofile --norc -lc") expect(command).toContain("tmux missing") - expect(command).toContain("tmux new-session -d -s") + expect(command).toContain("tmux start-server") + expect(command).toContain("set-option -g history-limit 50000") + expect(command).toContain("new-session -d -s") expect(command).toContain("tmux set-option") expect(command).toContain("status off") + expect(command).toContain("history-limit 50000") + expect(command).toContain("mouse on") expect(command).toContain("tmux attach-session -t") expect(command).toContain("docker-git-session-1") expect(command).toContain("/home/dev/project with spaces") + + const startServerIndex = command.indexOf("tmux start-server") + const globalHistoryLimitIndex = command.indexOf("set-option -g history-limit 50000") + const newSessionIndex = command.indexOf("new-session -d -s") + const statusOffIndex = command.indexOf("status off") + const sessionHistoryLimitIndex = command.lastIndexOf("history-limit 50000") + const mouseOnIndex = command.indexOf("mouse on") + const attachSessionIndex = command.indexOf("tmux attach-session -t") + + expect(startServerIndex).toBeGreaterThanOrEqual(0) + expect(globalHistoryLimitIndex).toBeGreaterThanOrEqual(0) + expect(newSessionIndex).toBeGreaterThanOrEqual(0) + expect(statusOffIndex).toBeGreaterThanOrEqual(0) + expect(sessionHistoryLimitIndex).toBeGreaterThanOrEqual(0) + expect(mouseOnIndex).toBeGreaterThanOrEqual(0) + 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(attachSessionIndex) }) it("fails before creating a durable session when tmux is unavailable", async () => { diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index df31dc1f..da1e0084 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -1,6 +1,7 @@ import { Effect, Match, pipe } from "effect" import { + type ApiTerminalSession, codexImport, codexLogin, codexLogout, @@ -19,7 +20,7 @@ import { } from "./api-client.js" import { type ControllerRuntime, ensureControllerReady } from "./controller.js" import type { Command } from "./frontend-lib/core/domain.js" -import type { ApiRequestError, CliError } from "./host-errors.js" +import type { ApiRequestError, CliError, ControllerBootstrapError } from "./host-errors.js" import { terminalAuthTitle } from "./menu-auth-shared.js" import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js" @@ -45,7 +46,9 @@ export type RoutedAuthCommand = Extract< } > -const withControllerReady = (effect: Effect.Effect) => +const withControllerReady = ( + effect: Effect.Effect +): Effect.Effect => pipe(ensureControllerReady(), Effect.zipRight(effect)) const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) @@ -57,6 +60,17 @@ const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError message: `Controller did not create a terminal session for ${provider}.` }) +const attachGrokTerminalSession = ( + session: ApiTerminalSession | null +): Effect.Effect => + session === null + ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) + : attachTerminalSession({ + header: terminalAuthTitle("GrokOauth"), + session, + websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` + }) + const routedAuthTags: Readonly> = { AuthCodexImport: true, AuthCodexLogin: true, @@ -111,15 +125,7 @@ const handleGrokLoginCommand = ( ) => withControllerReady( createAuthTerminalSession("GrokOauth", command.label).pipe( - Effect.flatMap((session): Effect.Effect => - session === null - ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) - : attachTerminalSession({ - header: terminalAuthTitle("GrokOauth"), - session, - websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` - }) - ) + Effect.flatMap((session) => attachGrokTerminalSession(session)) ) ) diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index 0572bd37..345a7346 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -30,7 +30,7 @@ import type { TerminalSocketListenerArgs, TerminalSocketRef } from "./terminal-panel-runtime-types.js" -import { installTerminalQuerySuppression } from "./terminal-query-suppression.js" +import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } from "./terminal-query-suppression.js" import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js" import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js" @@ -38,6 +38,10 @@ type TerminalClientMessage = | { readonly data: string; readonly type: "input" } | { readonly cols: number; readonly rows: number; readonly type: "resize" } +type TerminalRuntimeOptions = { + readonly querySuppression?: TerminalQuerySuppressionOptions +} + type TerminalInlineImageFetchError = { readonly _tag: "TerminalInlineImageFetchError" readonly message: string @@ -76,16 +80,20 @@ const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => { } } -export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime => { +export const createTerminalRuntime = ( + host: HTMLDivElement, + options: TerminalRuntimeOptions = {} +): TerminalRuntime => { const terminal = new Terminal({ allowProposedApi: true, convertEol: false, cursorBlink: true, fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace", fontSize: 14, + scrollback: 50_000, theme: { background: "#080a0d", foreground: "#f4f7fb" } }) - installTerminalQuerySuppression(terminal) + installTerminalQuerySuppression(terminal, options.querySuppression) const fitAddon = new FitAddon() terminal.loadAddon(fitAddon) terminal.open(host) diff --git a/packages/app/src/web/terminal-panel-runtime.ts b/packages/app/src/web/terminal-panel-runtime.ts index 00397c87..09c46828 100644 --- a/packages/app/src/web/terminal-panel-runtime.ts +++ b/packages/app/src/web/terminal-panel-runtime.ts @@ -168,6 +168,9 @@ 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) { @@ -177,7 +180,11 @@ const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undef args.connectionRef.current = { closing: false, opened: false } const lifecycle = createLifecycleState() const socketRef: TerminalSocketRef = { current: null } - const { fitAddon, terminal } = createTerminalRuntime(host) + const { fitAddon, terminal } = createTerminalRuntime(host, { + querySuppression: { + allowMouseTracking: shouldAllowTerminalMouseTracking(args.session) + } + }) const terminalInputController = createTerminalInputController(terminal, socketRef) const pasteGuard = createTerminalPasteGuard() const sendResize = (): void => { diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts index 92566ff8..44d411c3 100644 --- a/packages/app/src/web/terminal-query-suppression.ts +++ b/packages/app/src/web/terminal-query-suppression.ts @@ -1,5 +1,9 @@ export type TerminalQuerySuppression = { readonly dispose: () => void } +export type TerminalQuerySuppressionOptions = { + readonly allowMouseTracking?: boolean +} + type Disposable = { readonly dispose: () => void } type FunctionIdentifier = { @@ -29,12 +33,20 @@ export type TerminalQuerySuppressionTarget = { } } -// DEC private modes whose `h`/`l` setter causes xterm.js to start emitting -// unsolicited reply bytes back through `onData` on later DOM events: -// 1000/1002/1003/1006/1015/1016 — mouse tracking (mouse events -> bytes) -// 1004 — focus reporting (focus/blur -> CSI I/O) -// Suppressing the SET (`h`) leaves xterm.js in the default state (no event -// emission); suppressing the RESET (`l`) is harmless and kept for symmetry. +// DEC private modes whose `h`/`l` setter can cause xterm.js to emit event bytes +// back through `onData` on later DOM events. +const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet = new Set([ + 1000, + 1002, + 1003, + 1006, + 1015, + 1016 +]) +const FOCUS_REPORTING_PRIVATE_MODE = 1004 + +// Suppressing SET leaves xterm.js in the default state (no event emission); +// suppressing RESET is harmless and kept for symmetry. // Modes intentionally left to fall through to xterm's built-in handlers: // 25 — cursor visibility // 1049 — alternate screen buffer @@ -42,13 +54,8 @@ export type TerminalQuerySuppressionTarget = { // 2026 — synchronized output (Ink uses BSU/ESU around every frame) // 1007 — alternate scroll (only changes wheel semantics, no leak) const SUPPRESSED_PRIVATE_MODES: ReadonlySet = new Set([ - 1000, - 1002, - 1003, - 1004, - 1006, - 1015, - 1016 + ...MOUSE_TRACKING_PRIVATE_MODES, + FOCUS_REPORTING_PRIVATE_MODE ]) const isColorQuery = (data: string): boolean => { @@ -68,10 +75,20 @@ const extractParam = (param: CsiParam): number | null => { return typeof head === "number" ? head : null } -const containsSuppressedPrivateMode = (params: CsiParams): boolean => { +const shouldSuppressPrivateMode = ( + mode: number, + options: TerminalQuerySuppressionOptions +): boolean => + mode === FOCUS_REPORTING_PRIVATE_MODE || + (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) + +const containsSuppressedPrivateMode = ( + params: CsiParams, + options: TerminalQuerySuppressionOptions +): boolean => { for (const param of params) { const value = extractParam(param) - if (value !== null && SUPPRESSED_PRIVATE_MODES.has(value)) { + if (value !== null && shouldSuppressPrivateMode(value, options)) { return true } } @@ -95,15 +112,17 @@ const registerDcsSuppressor = ( const registerSelectivePrivateModeSuppressor = ( terminal: TerminalQuerySuppressionTarget, - final: "h" | "l" + final: "h" | "l", + options: TerminalQuerySuppressionOptions ): Disposable => terminal.parser.registerCsiHandler( { final, prefix: "?" }, - (params) => containsSuppressedPrivateMode(params) + (params) => containsSuppressedPrivateMode(params, options) ) export const installTerminalQuerySuppression = ( - terminal: TerminalQuerySuppressionTarget + terminal: TerminalQuerySuppressionTarget, + options: TerminalQuerySuppressionOptions = {} ): TerminalQuerySuppression => { const disposables: ReadonlyArray = [ // OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through. @@ -132,11 +151,11 @@ export const installTerminalQuerySuppression = ( // CSI Pm t — window manipulation. Gated by `windowOptions` (off by default); // suppressed so an accidental future enable does not leak size reports. registerCsiSuppressor(terminal, { final: "t" }), - // CSI ? h / CSI ? l — block xterm from enabling focus reporting and mouse - // tracking modes that would later pump unsolicited bytes back through onData. + // CSI ? h / CSI ? l — block xterm from enabling focus reporting and, + // unless explicitly allowed for tmux project terminals, mouse tracking modes. // Other DEC private modes fall through to xterm's built-in setters. - registerSelectivePrivateModeSuppressor(terminal, "h"), - registerSelectivePrivateModeSuppressor(terminal, "l") + registerSelectivePrivateModeSuppressor(terminal, "h", options), + registerSelectivePrivateModeSuppressor(terminal, "l", options) ] return { dispose: () => { diff --git a/packages/app/tests/docker-git/terminal-query-suppression.test.ts b/packages/app/tests/docker-git/terminal-query-suppression.test.ts index 118d9949..90b800ca 100644 --- a/packages/app/tests/docker-git/terminal-query-suppression.test.ts +++ b/packages/app/tests/docker-git/terminal-query-suppression.test.ts @@ -123,7 +123,11 @@ const ADDED_CSI_IDENTIFIERS: ReadonlyArray = [ { final: "t" } ] -const SUPPRESSED_MODES: ReadonlyArray = [1000, 1002, 1003, 1004, 1006, 1015, 1016] +const MOUSE_TRACKING_MODES: ReadonlyArray = [1000, 1002, 1003, 1006, 1015, 1016] + +const FOCUS_REPORTING_MODE = 1004 + +const SUPPRESSED_MODES: ReadonlyArray = [...MOUSE_TRACKING_MODES, FOCUS_REPORTING_MODE] const PASS_THROUGH_MODES: ReadonlyArray = [25, 1007, 1049, 2004, 2026] @@ -218,6 +222,20 @@ describe("terminal query suppression", () => { } }) + it("allows DEC private mouse tracking when explicitly enabled for tmux project terminals", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal, { allowMouseTracking: true }) + const setHandler = findCsi(mock, { final: "h", prefix: "?" }) + const resetHandler = findCsi(mock, { final: "l", prefix: "?" }) + + for (const mode of MOUSE_TRACKING_MODES) { + expect(setHandler.callback([mode])).toBe(false) + expect(resetHandler.callback([mode])).toBe(false) + } + expect(setHandler.callback([FOCUS_REPORTING_MODE])).toBe(true) + expect(resetHandler.callback([FOCUS_REPORTING_MODE])).toBe(true) + }) + it("lets benign DEC private modes fall through to the built-in handler", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal) diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index 34d62984..a95037f7 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -6,12 +6,11 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -// CHANGE: add Grok CLI OAuth/browser authentication flow -// WHY: issue #304 expects `grok login` style URL handoff and callback paste support -// QUOTE(ТЗ): "Paste the URL here if it doesn't connect" +// CHANGE: run the Grok CLI device-auth flow inside the auth container +// WHY: `docker-git auth grok login` must work from terminal-only containers without callback URL handling // REF: issue-304 // SOURCE: https://x.ai/news/grok-build-cli -// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> grok_credentials_stored | error +// FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> device_code_authorized -> grok_credentials_stored | error // PURITY: SHELL // EFFECT: Effect // INVARIANT: Grok credentials are stored in ~/.grok within the selected account path @@ -77,13 +76,13 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray return [...base, spec.image, "grok", "login", "--device-auth"] } -const printOauthInstructions = (): Effect.Effect => +const printDeviceAuthInstructions = (): Effect.Effect => Effect.sync(() => { process.stderr.write("\n") - process.stderr.write("Grok CLI OAuth Authentication\n") - process.stderr.write("1. Open the Grok sign-in URL printed by the CLI.\n") - process.stderr.write("2. Complete browser authentication.\n") - process.stderr.write("3. If the callback cannot connect, paste the returned URL into the prompt.\n") + process.stderr.write("Grok CLI Device Authentication\n") + process.stderr.write("1. Copy the device code printed by the Grok CLI.\n") + process.stderr.write("2. Open the verification URL printed by the CLI in a browser.\n") + process.stderr.write("3. Complete approval; this terminal continues after the CLI writes credentials.\n") process.stderr.write("\n") }) @@ -120,18 +119,21 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st ) /** - * Runs the Grok OAuth device login inside the docker-git auth container. + * Runs the Grok CLI `--device-auth` login inside the docker-git auth container. + * + * The CLI prints a device code and verification URL; after the user completes + * approval externally, the command exits and credentials are normalized. * * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. * @param options Auth container image and in-container home path. - * @returns Effect that completes after Grok writes credentials and permissions are normalized. + * @returns Effect that completes after device authorization writes credentials and permissions are normalized. * @pure false * @effect CommandExecutor; invokes Docker and writes credentials under the selected account path. * @invariant successful completion leaves credentials scoped to accountPath and not to project source files. * @precondition Docker is available and options.image contains the official Grok CLI binary. * @postcondition accountPath ownership follows the mounted account root or a typed error is returned. - * @complexity O(n) local argument construction plus unbounded external OAuth interaction time. + * @complexity O(n) local argument construction plus unbounded external device authorization time. * @throws Never - failures are modeled as AuthError, CommandFailedError, or PlatformError in the Effect type. */ export const runGrokOauthLoginWithPrompt = ( @@ -143,7 +145,7 @@ export const runGrokOauthLoginWithPrompt = ( } ): Effect.Effect => Effect.gen(function*(_) { - yield* _(printOauthInstructions()) + yield* _(printDeviceAuthInstructions()) const hostPath = yield* _(resolveDockerVolumeHostPath(cwd, accountPath)) const spec = buildDockerGrokAuthSpec(cwd, hostPath, options.image, options.containerPath) yield* _(