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
4 changes: 2 additions & 2 deletions packages/app/src/web/api-auth-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const AuthProviderSnapshotFields = {
claudeAuthPath: Schema.String,
geminiAuthEntries: Schema.Number,
geminiAuthPath: Schema.String,
grokAuthEntries: Schema.Number,
grokAuthPath: Schema.String,
grokAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }),
grokAuthPath: Schema.optionalWith(Schema.String, { default: () => "" }),
Comment on lines +31 to +32
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Добавьте функциональные комментарии для документирования изменения схемы.

Согласно guidelines, все изменения должны быть документированы функциональными комментариями с метками CHANGE (что изменилось), WHY (почему это необходимо для backward compatibility), QUOTE(ТЗ) или REF (ссылка на требование/issue), и INVARIANT (гарантия дефолтных значений при отсутствии полей).

Кроме того, это изменение не упомянуто в описании PR #322, которое посвящено terminal copy interaction. В соответствии с принципом spec-driven development, каждое изменение поведения должно быть явно задокументировано в спецификации PR или связанном issue.

📝 Предлагаемые функциональные комментарии
+  // CHANGE: grokAuthEntries и grokAuthPath стали опциональными с дефолтами
+  // WHY: Backward compatibility — контроллеры без Grok-провайдера не отправляют эти поля
+  // QUOTE(ТЗ): n/a (уточните в описании PR или issue `#317`)
+  // REF: PR `#322` commit d66d8f2
+  // INVARIANT: ∀ snapshot without grok fields: grokAuthEntries = 0 ∧ grokAuthPath = ""
+  // PURITY: CORE
   grokAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }),
   grokAuthPath: Schema.optionalWith(Schema.String, { default: () => "" }),

Согласно coding guidelines: "Use functional comment markers for code clarity: CHANGE (brief description), WHY (mathematical/architectural justification), QUOTE(ТЗ) (requirement citation), REF (RTM or message ID)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/web/api-auth-schema.ts` around lines 31 - 32, Добавьте
функциональные комментарии рядом с определениями grokAuthEntries и grokAuthPath
(где используется Schema.optionalWith) используя маркеры CHANGE, WHY, QUOTE/REF
и INVARIANT: в CHANGE кратко опишите внесённое поле и значение по умолчанию; в
WHY объясните почему дефолты (0 и "") сохраняют backward compatibility; в QUOTE
или REF укажите связанное требование/issue/PR; в INVARIANT зафиксируйте гарантию
«при отсутствии поля применяется default» и типы (Number/String). Обновите
описание PR `#322` или добавьте ссылку на соответствующее issue в QUOTE/REF, чтобы
изменение схемы было задокументировано в спецификации.

githubTokenEntries: Schema.Number,
gitTokenEntries: Schema.Number
}
Expand Down
136 changes: 136 additions & 0 deletions packages/app/src/web/terminal-copy-interaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10"

type TerminalSelectionTarget = {
readonly getSelection: () => string
readonly hasSelection: () => boolean
}

export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
readonly modes: {
readonly mouseTrackingMode: TerminalMouseTrackingMode
}
}

type TerminalMouseButtonEvent = {
readonly button: number
}

type TerminalSelectionModifierEvent = {
readonly altKey: boolean
readonly shiftKey: boolean
}

type TerminalCopyClipboardData = {
readonly setData: (format: string, data: string) => void
}

type TerminalCopyClipboardEvent = {
readonly clipboardData: TerminalCopyClipboardData | null
readonly preventDefault: () => void
readonly stopPropagation: () => void
}

type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent

type TerminalCopyInteractionHost = {
readonly addEventListener: {
(type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void
(type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void
}
readonly removeEventListener: {
(type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void
(type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void
}
}

type TerminalCopyInteractionArgs = {
readonly host: TerminalCopyInteractionHost
readonly terminal: TerminalCopyInteractionTerminal
}

const primaryMouseButton = 0
const secondaryMouseButton = 2

const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"])

const currentNavigatorPlatform = (): string => {
if (typeof navigator === "undefined") {
return ""
}
return navigator.platform
}

const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton

const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton

const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean =>
terminal.modes.mouseTrackingMode !== "none"

export const shouldForceBrowserTerminalSelection = (
event: TerminalMouseButtonEvent,
terminal: TerminalCopyInteractionTerminal
): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal)

export const shouldForceTerminalSelectionContext = (
event: TerminalMouseButtonEvent,
terminal: TerminalCopyInteractionTerminal
): boolean => isSecondaryMouseButton(event) && terminal.hasSelection()

Comment on lines +75 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Ограничьте форсирование context-selection только активным mouse tracking.

На Line 78 сейчас условие не учитывает mouseTrackingMode, из-за чего модификатор форсируется даже при "none" и меняет поведение вне целевого кейса.

Предлагаемое изменение
 export const shouldForceTerminalSelectionContext = (
   event: TerminalMouseButtonEvent,
   terminal: TerminalCopyInteractionTerminal
-): boolean => isSecondaryMouseButton(event) && terminal.hasSelection()
+): boolean =>
+  isSecondaryMouseButton(event) &&
+  hasActiveMouseTracking(terminal) &&
+  terminal.hasSelection()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/web/terminal-copy-interaction.ts` around lines 75 - 79, The
current shouldForceTerminalSelectionContext function incorrectly forces the
context-selection without checking mouse tracking; update the boolean condition
in shouldForceTerminalSelectionContext (which takes TerminalMouseButtonEvent and
TerminalCopyInteractionTerminal) to also require active mouse tracking by
checking the terminal's mouse tracking state (e.g., terminal.mouseTrackingMode
=== 'active' or terminal.isMouseTrackingActive()) so it returns true only when
isSecondaryMouseButton(event) && terminal.hasSelection() && mouse tracking is
active.

const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent =>
macPlatformNames.has(platform) ? "altKey" : "shiftKey"

export const forceTerminalSelectionModifier = (
event: TerminalSelectionModifierEvent,
platform: string = currentNavigatorPlatform()
): boolean =>
Reflect.defineProperty(event, terminalSelectionModifier(platform), {
configurable: true,
value: true
})

export const writeTerminalSelectionToClipboardData = (
terminal: TerminalSelectionTarget,
clipboardData: TerminalCopyClipboardData | null
): boolean => {
if (clipboardData === null || !terminal.hasSelection()) {
return false
}
const selection = terminal.getSelection()
if (selection.length === 0) {
return false
}
clipboardData.setData("text/plain", selection)
return true
}

export const attachTerminalCopyInteraction = (
args: TerminalCopyInteractionArgs
): { readonly dispose: () => void } => {
const onMouseDown = (event: TerminalCopyMouseEvent): void => {
if (
!shouldForceBrowserTerminalSelection(event, args.terminal) &&
!shouldForceTerminalSelectionContext(event, args.terminal)
) {
return
}
forceTerminalSelectionModifier(event)
}
const onCopy = (event: TerminalCopyClipboardEvent): void => {
if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) {
return
}
event.preventDefault()
event.stopPropagation()
}

args.host.addEventListener("mousedown", onMouseDown, true)
args.host.addEventListener("copy", onCopy, true)

return {
dispose: () => {
args.host.removeEventListener("mousedown", onMouseDown, true)
args.host.removeEventListener("copy", onCopy, true)
}
}
}
60 changes: 60 additions & 0 deletions packages/app/src/web/terminal-panel-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { TerminalPasteGuard } from "./terminal-panel-runtime-types.js"

export type TerminalClientMessage =
| { readonly data: string; readonly type: "input" }
| { readonly cols: number; readonly rows: number; readonly type: "resize" }

type TerminalClientSocket = {
readonly readyState: number
readonly send: (data: string) => void
}

export type TerminalClientSocketRef = { readonly current: TerminalClientSocket | null }

type TerminalInputTarget = {
readonly onData: (handler: (data: string) => void) => { readonly dispose: () => void }
readonly scrollToBottom: () => void
}

const csiPrefix = "\u001B["
const x10MouseReportPrefix = `${csiPrefix}M`
const x10MouseReportLength = 6
const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u
const urxvtMouseReportBodyPattern = /^\d+;\d+;\d+M$/u

export const isTerminalMouseReportInput = (data: string): boolean => {
if (data.startsWith(x10MouseReportPrefix)) {
return data.length === x10MouseReportLength
}
if (!data.startsWith(csiPrefix)) {
return false
}
const body = data.slice(csiPrefix.length)
return sgrMouseReportBodyPattern.test(body) || urxvtMouseReportBodyPattern.test(body)
}

export const sendTerminalClientMessage = (
socketRef: TerminalClientSocketRef,
message: TerminalClientMessage
): void => {
const socket = socketRef.current
if (socket === null || socket.readyState !== WebSocket.OPEN) {
return
}
socket.send(JSON.stringify(message))
}

export const attachTerminalInput = (
terminal: TerminalInputTarget,
socketRef: TerminalClientSocketRef,
pasteGuard: TerminalPasteGuard
) =>
terminal.onData((data) => {
if (pasteGuard.shouldSuppressTerminalInput(data)) {
return
}
if (!isTerminalMouseReportInput(data)) {
terminal.scrollToBottom()
}
sendTerminalClientMessage(socketRef, { data, type: "input" })
})
Comment on lines +36 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Проверка, что в модуле есть прямые IO-вызовы без Effect-композиции
rg -n "socket\.send\(|terminal\.onData\(|scrollToBottom\(|Effect\." packages/app/src/web/terminal-panel-input.ts

Repository: ProverCoderAI/docker-git

Length of output: 178


🏁 Script executed:

# Проверка полного содержимого файла
cat -n packages/app/src/web/terminal-panel-input.ts

Repository: ProverCoderAI/docker-git

Length of output: 2352


🏁 Script executed:

# Поиск использования Effect и Layer в веб-части приложения
rg -l "Effect\.|Context\.Tag|Layer\." packages/app/src/web/ | head -10

Repository: ProverCoderAI/docker-git

Length of output: 539


🏁 Script executed:

# Поиск паттернов Effect для WebSocket/сокетов в кодовой базе
rg -B2 -A5 "Context\.Tag|Layer\.effect" packages/app/src/web/ | head -50

Repository: ProverCoderAI/docker-git

Length of output: 50


🏁 Script executed:

# Поиск использования sendTerminalClientMessage и attachTerminalInput
rg -B3 -A3 "sendTerminalClientMessage|attachTerminalInput" packages/app/src/web/ --type ts

Repository: ProverCoderAI/docker-git

Length of output: 4947


🏁 Script executed:

# Проверка terminal-panel-runtime-core.ts на предмет Effect паттернов
cat packages/app/src/web/terminal-panel-runtime-core.ts | head -100

Repository: ProverCoderAI/docker-git

Length of output: 3232


🏁 Script executed:

# Поиск Context.Tag или Layer определений в веб приложении
rg "extends Context\.Tag|Layer\." packages/app/src/web/ -A3

Repository: ProverCoderAI/docker-git

Length of output: 50


🏁 Script executed:

# Посмотреть как используется attachTerminalInput в terminal-panel-runtime.ts
rg -B5 -A10 "inputDisposable.*attachTerminalInput" packages/app/src/web/terminal-panel-runtime.ts

Repository: ProverCoderAI/docker-git

Length of output: 489


🏁 Script executed:

# Полный контекст использования в terminal-panel-runtime.ts
cat packages/app/src/web/terminal-panel-runtime.ts | head -150

Repository: ProverCoderAI/docker-git

Length of output: 4793


🏁 Script executed:

# Проверить структуру проекта и наличие Effect-based сервисов
find packages/app/src -name "*.ts" -type f | xargs rg "extends Context\.Tag" 2>/dev/null | head -20

Repository: ProverCoderAI/docker-git

Length of output: 50


🏁 Script executed:

# Проверить git статус файла - новый или существующий
git -C packages/app/src/web log --oneline terminal-panel-input.ts 2>/dev/null | head -5

Repository: ProverCoderAI/docker-git

Length of output: 119


🏁 Script executed:

# Проверить если есть PR description или сообщение о коммите
git -C packages/app log --oneline -20 --all 2>/dev/null | head -20

Repository: ProverCoderAI/docker-git

Length of output: 1037


Рефакторинг под Effect + Layer необходимо выполнить в отдельной архитектурной задаче, но текущий fix содержит нарушения.

Функции sendTerminalClientMessage и attachTerminalInput выполняют IO побочные эффекты напрямую (строки 44, 52, 57):

  • socket.send() — прямой вызов WebSocket API
  • terminal.onData() — прямая подписка на событие
  • terminal.scrollToBottom() — прямая DOM-мутация

Согласно кодовым гайдлайнам, все внешние зависимости должны быть обёрнуты через Effect-TS Layer с типизированными Context.Tag. Текущая реализация нарушает контракт FCIS-паттерна: IO-логика должна находиться в SHELL слое через Effect-композицию, оставляя здесь только чистые функции и монадическую композицию.

Этот рефакторинг требует создания типизированных сервисов (WebSocketService, TerminalIOService) через Context.Tag и их инъекции через Layer.effect. Рекомендуется выполнить как отдельную архитектурную задачу для всего модуля, а не в текущем fix-коммите.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/app/src/web/terminal-panel-input.ts` around lines 36 - 60,
sendTerminalClientMessage and attachTerminalInput perform direct IO
(socket.send, terminal.onData, terminal.scrollToBottom) which violates the FCIS
pattern; refactor by extracting IO behind typed Effect-TS services and inject
them via Layer.Context.Tags: create a WebSocketService (wrap socket.send) and a
TerminalIOService (wrap terminal.onData and scrollToBottom and pasteGuard
checks) and replace direct calls in sendTerminalClientMessage and
attachTerminalInput with calls to those service methods (using
TerminalClientSocketRef/TerminalInputTarget only as data carriers), then provide
implementations via Layer.effect in the SHELL layer so this module contains
pure, testable orchestration calling the services instead of performing raw
DOM/WebSocket IO.

31 changes: 4 additions & 27 deletions packages/app/src/web/terminal-panel-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import {
unavailableTerminalInlineImageEntry
} from "./terminal-inline-images.js"
import type { TerminalInlineImageEntry } from "./terminal-inline-images.js"
import { sendTerminalClientMessage } from "./terminal-panel-input.js"
import type {
TerminalCleanupArgs,
TerminalExitInfo,
TerminalInputController,
TerminalLifecycleState,
TerminalMessageHandlers,
TerminalPasteGuard,
TerminalRuntime,
TerminalSocketConnectArgs,
TerminalSocketListenerArgs,
Expand All @@ -34,9 +34,7 @@ import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions }
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"

type TerminalClientMessage =
| { readonly data: string; readonly type: "input" }
| { readonly cols: number; readonly rows: number; readonly type: "resize" }
export { attachTerminalInput, isTerminalMouseReportInput } from "./terminal-panel-input.js"

type TerminalRuntimeOptions = {
readonly querySuppression?: TerminalQuerySuppressionOptions
Expand Down Expand Up @@ -90,7 +88,9 @@ export const createTerminalRuntime = (
cursorBlink: true,
fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace",
fontSize: 14,
macOptionClickForcesSelection: true,
scrollback: 50_000,
scrollOnUserInput: false,
theme: { background: "#080a0d", foreground: "#f4f7fb" }
})
installTerminalQuerySuppression(terminal, options.querySuppression)
Expand Down Expand Up @@ -122,17 +122,6 @@ const createTerminalSocket = (
terminal: Terminal
): WebSocket => new WebSocket(resolveTerminalWebSocketUrl(session.websocketPath, terminal.cols, terminal.rows))

const sendTerminalClientMessage = (
socketRef: TerminalSocketRef,
message: TerminalClientMessage
): void => {
const socket = socketRef.current
if (socket === null || socket.readyState !== WebSocket.OPEN) {
return
}
socket.send(JSON.stringify(message))
}

export const sendTerminalResize = (
fitAddon: FitAddon,
socketRef: TerminalSocketRef,
Expand Down Expand Up @@ -164,18 +153,6 @@ export const observeTerminalResize = (
return resizeObserver
}

export const attachTerminalInput = (
terminal: Terminal,
socketRef: TerminalSocketRef,
pasteGuard: TerminalPasteGuard
) =>
terminal.onData((data) => {
if (pasteGuard.shouldSuppressTerminalInput(data)) {
return
}
sendTerminalClientMessage(socketRef, { data, type: "input" })
})

const notifyTerminalReady = (
handlers: TerminalMessageHandlers
): void => {
Expand Down
Loading
Loading