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
15 changes: 15 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,21 @@ jobs:
- name: Build (api)
run: bun run --cwd packages/api build

dist-deps-prune:
name: Dist deps prune
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/setup
with:
node-version: 24.14.0
- name: Build package
run: bun run --cwd packages/app build
- name: Dist deps prune (lint)
run: bun run check:dist-deps-prune

types:
name: Types
runs-on: ubuntu-latest
Expand Down
205 changes: 205 additions & 0 deletions packages/app/src/docker-git/frontend-lib/shell/terminal-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/* jscpd:ignore-start */
import * as Command from "@effect/platform/Command"
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
import * as FileSystem from "@effect/platform/FileSystem"
import { Effect, Option, pipe } from "effect"

const terminalSaneEscape = "\u001B[0m" + // reset rendition
"\u001B[?25h" + // show cursor
"\u001B[?1l" + // normal cursor keys mode
"\u001B>" + // normal keypad mode
"\u001B[?1000l" + // disable mouse click tracking
"\u001B[?1002l" + // disable mouse drag tracking
"\u001B[?1003l" + // disable any-event mouse tracking
"\u001B[?1005l" + // disable UTF-8 mouse mode
"\u001B[?1006l" + // disable SGR mouse mode
"\u001B[?1015l" + // disable urxvt mouse mode
"\u001B[?1007l" + // disable alternate scroll mode
"\u001B[?1004l" + // disable focus reporting
"\u001B[?2004l" + // disable bracketed paste
"\u001B[>4;0m" + // disable xterm modifyOtherKeys
"\u001B[>4m" + // reset xterm modifyOtherKeys
"\u001B[<u" // disable kitty keyboard protocol

const controllingTtyPath = "/dev/tty"
const shellPath = "/bin/sh"
const sttyPath = "/usr/bin/stty"
const snapshotPattern = /^[0-9a-fA-F:]+$/u

export type TerminalCursorRuntime = CommandExecutor.CommandExecutor | FileSystem.FileSystem
export type TerminalResetFallbackWrite = (chunk: string) => void

const optionOrElse = <A>(option: Option.Option<A>, fallback: A): A => pipe(option, Option.getOrElse(() => fallback))

const succeeds = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<boolean, never, R> =>
pipe(
effect,
Effect.as(true),
Effect.option,
Effect.map((result) => optionOrElse(result, false))
)

const hasInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY

const disableRawMode = (): Effect.Effect<void> => {
if (typeof process.stdin.setRawMode !== "function") {
return Effect.void
}

return pipe(
Effect.try(() => {
process.stdin.setRawMode(false)
}),
Effect.ignore
)
}

const ttyShellCommand = (script: string): Command.Command =>
pipe(
Command.make(shellPath, "-c", script),
Command.stdin("inherit"),
Command.stdout("pipe"),
Command.stderr("pipe")
)

const runTtyShell = (script: string): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
pipe(
ttyShellCommand(script),
Command.exitCode,
Effect.map((exitCode) => Number(exitCode) === 0),
Effect.option,
Effect.map((result) => optionOrElse(result, false))
)

const runTtyShellString = (script: string): Effect.Effect<string, never, CommandExecutor.CommandExecutor> =>
pipe(
ttyShellCommand(script),
Command.string,
Effect.map((output) => output.trim()),
Effect.option,
Effect.map((result) => optionOrElse(result, ""))
)

const snapshotTerminalState = (): Effect.Effect<string | null, never, CommandExecutor.CommandExecutor> => {
if (!hasInteractiveTty()) {
return Effect.succeed(null)
}

return Effect.gen(function*(_) {
yield* _(disableRawMode())
const snapshot = yield* _(
runTtyShellString(
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} -g < ${controllingTtyPath} 2>/dev/null || true; fi`
)
)
return snapshotPattern.test(snapshot) ? snapshot : null
})
}

const writeTerminalReset = (
fallbackWrite?: TerminalResetFallbackWrite
): Effect.Effect<boolean, never, FileSystem.FileSystem> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const wroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape)))
if (wroteTty) {
return true
}

if (fallbackWrite !== undefined) {
return yield* _(
succeeds(
Effect.try(() => {
fallbackWrite(terminalSaneEscape)
})
)
)
}

return yield* _(
succeeds(
Effect.try(() => {
process.stdout.write(terminalSaneEscape)
})
)
)
})

const runSttySane = (): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
runTtyShell(
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} sane < ${controllingTtyPath} > ${controllingTtyPath} 2>/dev/null; else exit 1; fi`
)

const restoreSttySnapshot = (snapshot: string): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
snapshotPattern.test(snapshot)
? runTtyShell(
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} '${snapshot}' < ${controllingTtyPath} > ${controllingTtyPath} 2>/dev/null; else exit 1; fi`
)
: Effect.succeed(false)

// CHANGE: share the low-level tty repair across SSH launch and TUI suspend/resume
// WHY: both paths must reset the same controlling terminal before interactive output
// QUOTE(ТЗ): "при подключении по SSH контейнер забаганный. Кривокосо печатается текст"
// REF: user-request-2026-04-20-menu-select-ssh-terminal
// SOURCE: n/a
// FORMAT THEOREM: forall t: interactive(t) -> sane_tty(t)
// PURITY: SHELL
// EFFECT: Effect<void, never, TerminalCursorRuntime>
// INVARIANT: fallback writer is used only when /dev/tty repair is unavailable
// COMPLEXITY: O(1)
export const repairInteractiveTerminal = (
fallbackWrite?: TerminalResetFallbackWrite
): Effect.Effect<void, never, TerminalCursorRuntime> => {
if (!hasInteractiveTty()) {
return Effect.void
}

return Effect.gen(function*(_) {
yield* _(disableRawMode())
const sane = yield* _(runSttySane())
const wroteReset = sane ? yield* _(writeTerminalReset(fallbackWrite)) : false
if (!wroteReset) {
yield* _(writeTerminalReset(fallbackWrite))
}
})
}

const restoreTerminalState = (
snapshot: string | null
): Effect.Effect<void, never, TerminalCursorRuntime> => {
if (!hasInteractiveTty()) {
return Effect.void
}

return Effect.gen(function*(_) {
yield* _(disableRawMode())
const restored = snapshot === null ? false : yield* _(restoreSttySnapshot(snapshot))
if (!restored) {
yield* _(runSttySane())
}
yield* _(writeTerminalReset())
})
}

// CHANGE: ensure the terminal cursor is visible before handing control to interactive SSH
// WHY: Ink/TTY transitions can leave cursor hidden, which makes SSH shells look frozen
// QUOTE(ТЗ): "не виден курсор в SSH терминале"
// REF: issue-3
// SOURCE: n/a
// FORMAT THEOREM: forall t: interactive(t) -> cursor_visible(t)
// PURITY: SHELL
// EFFECT: Effect<void, never, TerminalCursorRuntime>
// INVARIANT: escape sequence is emitted only in interactive tty mode
// COMPLEXITY: O(1)
export const ensureTerminalCursorVisible = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
repairInteractiveTerminal()

export const withPreservedTerminalState = <A, E, R>(
use: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R | TerminalCursorRuntime> =>
Effect.gen(function*(_) {
const snapshot = yield* _(snapshotTerminalState())
yield* _(ensureTerminalCursorVisible())
return yield* _(use.pipe(Effect.ensuring(restoreTerminalState(snapshot))))
})
/* jscpd:ignore-end */
116 changes: 72 additions & 44 deletions packages/app/src/docker-git/menu-shared.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { MenuViewContext, ViewState } from "./menu-types.js"

import { Effect, pipe } from "effect"
import { repairInteractiveTerminal } from "../lib/usecases/terminal-cursor.js"
import { repairInteractiveTerminal, type TerminalCursorRuntime } from "./frontend-lib/shell/terminal-cursor.js"

// CHANGE: share menu escape handling across flows
// WHY: avoid duplicated logic in TUI handlers
Expand Down Expand Up @@ -146,19 +146,23 @@ export const withSuspendedTui = <A, E, R>(
readonly onError?: (error: E) => Effect.Effect<void>
readonly onResume?: () => void
}
): Effect.Effect<A, E, R> => {
): Effect.Effect<A, E, R | TerminalCursorRuntime> => {
const withError = options?.onError
? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void)))
: effect

return pipe(
Effect.sync(suspendTui),
suspendTui(),
Effect.zipRight(withError),
Effect.ensuring(
Effect.sync(() => {
resumeTui()
options?.onResume?.()
})
pipe(
resumeTui(),
Effect.zipRight(
Effect.sync(() => {
options?.onResume?.()
})
)
)
)
)
}
Expand Down Expand Up @@ -199,6 +203,36 @@ const setStdoutMuted = (muted: boolean): void => {
stdoutMuted = muted
}

const setStdoutMutedEffect = (muted: boolean): Effect.Effect<void> =>
Effect.sync(() => {
setStdoutMuted(muted)
})

const writeTerminalControlEffect = (text: string): Effect.Effect<void> =>
Effect.sync(() => {
writeTerminalControl(text)
})

const setRawModeEffect = (enabled: boolean): Effect.Effect<void> =>
process.stdin.isTTY && typeof process.stdin.setRawMode === "function"
? pipe(
Effect.try(() => {
process.stdin.setRawMode(enabled)
}),
Effect.ignore
)
: Effect.void

const whenStdoutTty = (effect: Effect.Effect<void, never, TerminalCursorRuntime>) =>
process.stdout.isTTY ? effect : Effect.void

const preparePrimaryScreen = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
Effect.gen(function*(_) {
yield* _(setStdoutMutedEffect(true))
yield* _(repairInteractiveTerminal(writeTerminalControl))
yield* _(writeTerminalControlEffect(primaryScreenEscape))
})

// CHANGE: temporarily suspend TUI rendering when running interactive commands
// WHY: avoid mixed output from docker/ssh and the Ink UI
// QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
Expand All @@ -209,16 +243,14 @@ const setStdoutMuted = (muted: boolean): void => {
// EFFECT: n/a
// INVARIANT: only toggles when TTY is available
// COMPLEXITY: O(1)
export const suspendTui = (): void => {
if (!process.stdout.isTTY) {
return
}
setStdoutMuted(true)
repairInteractiveTerminal(writeTerminalControl)
// Switch back to the primary screen so interactive commands (ssh/gh/codex)
// can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
writeTerminalControl(primaryScreenEscape)
}
export const suspendTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
whenStdoutTty(
preparePrimaryScreen().pipe(
// Switch back to the primary screen so interactive commands (ssh/gh/codex)
// can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
Effect.asVoid
)
)

// CHANGE: restore TUI rendering after interactive commands
// WHY: return to Ink UI without broken terminal state
Expand All @@ -230,34 +262,30 @@ export const suspendTui = (): void => {
// EFFECT: n/a
// INVARIANT: only toggles when TTY is available
// COMPLEXITY: O(1)
export const resumeTui = (): void => {
if (!process.stdout.isTTY) {
return
}
repairInteractiveTerminal(writeTerminalControl)
// Return to the alternate screen for Ink rendering.
writeTerminalControl("\u001B[?1049h\u001B[2J\u001B[H")
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
process.stdin.setRawMode(true)
}
disableTerminalInputModes()
setStdoutMuted(false)
}
export const resumeTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
whenStdoutTty(
Effect.gen(function*(_) {
yield* _(repairInteractiveTerminal(writeTerminalControl))
// Return to the alternate screen for Ink rendering.
yield* _(writeTerminalControlEffect("\u001B[?1049h\u001B[2J\u001B[H"))
yield* _(setRawModeEffect(true))
yield* _(Effect.sync(() => {
disableTerminalInputModes()
}))
yield* _(setStdoutMutedEffect(false))
})
)

export const leaveTui = (): void => {
if (!process.stdout.isTTY) {
return
}
// Ensure we don't leave the terminal in a broken "mouse reporting" mode.
setStdoutMuted(true)
repairInteractiveTerminal(writeTerminalControl)
// Restore the primary screen on exit without clearing it (keeps useful scrollback).
writeTerminalControl(primaryScreenEscape)
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
process.stdin.setRawMode(false)
}
setStdoutMuted(false)
}
export const leaveTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
whenStdoutTty(
Effect.gen(function*(_) {
// Ensure we don't leave the terminal in a broken "mouse reporting" mode.
yield* _(preparePrimaryScreen())
// Restore the primary screen on exit without clearing it (keeps useful scrollback).
yield* _(setRawModeEffect(false))
yield* _(setStdoutMutedEffect(false))
})
)

export const resetToMenu = (context: MenuResetContext): void => {
const view: ViewState = { _tag: "Menu" }
Expand Down
Loading