diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 91fe9dd2..0c347785 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -22,12 +22,24 @@ export type CreateFlowContext = { readonly projectsRoot?: string | undefined } -export type CreateFlowView = { - readonly step: number +type BaseCreateFlowView = { readonly buffer: string + readonly inputError: string | null readonly values: Partial } +export type CreateModeFlowView = BaseCreateFlowView & { + readonly mode: "create" + readonly step: number +} + +export type DisplayModeFlowView = BaseCreateFlowView & { + readonly mode: "display" + readonly step: number +} + +export type CreateFlowView = CreateModeFlowView | DisplayModeFlowView + type AdvanceCreateFlowResult = | { readonly _tag: "Continue"; readonly view: CreateFlowView } | { readonly _tag: "Error"; readonly error: ParseError } @@ -43,6 +55,80 @@ type AdvanceCreateFlowOptions = { readonly quickCreate?: boolean } +/** + * Direction over the finite ordered set of unresolved Create settings rows. + * + * @pure true + * @effect none + * @invariant value ∈ {"up", "down"} + * @precondition n/a + * @postcondition navigation direction is total for settings rows + * @complexity O(1) + */ +export type CreateSettingsNavigationDirection = "up" | "down" + +/** + * Horizontal choice direction over finite Create settings with discrete values. + * + * @pure true + * @effect none + * @invariant value ∈ {"left", "right"} + * @precondition n/a + * @postcondition direction maps only to an input-buffer token, never to applied Create values + * @complexity O(1) + */ +export type CreateSettingsChoiceDirection = "left" | "right" + +/** + * User-facing key guide shown only after Create leaves the repo URL step. + * + * @pure true + * @effect none + * @invariant hint contains the complete settings-mode key contract + * @precondition CreateFlowView.step > 0 + * @postcondition no repo-step quick-create guidance is rendered from this value + * @complexity O(1) + */ +export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply" + +const firstCreateSettingsStepIndex = 1 + +/** + * Narrows a Create flow snapshot to the unresolved step scale. + * + * @pure true + * @effect none + * @invariant true iff view.mode = "create" + * @precondition view is a CreateFlowView snapshot + * @postcondition result=true narrows the view to the unresolved Create step scale + * @complexity O(1) + */ +export const isCreateModeFlowView = (view: CreateFlowView): view is CreateModeFlowView => view.mode === "create" + +/** + * Narrows a Create flow snapshot to the browser display-settings step scale. + * + * @pure true + * @effect none + * @invariant true iff view.mode = "display" + * @precondition view is a CreateFlowView snapshot + * @postcondition result=true narrows the view to the display-settings step scale + * @complexity O(1) + */ +export const isDisplayModeFlowView = (view: CreateFlowView): view is DisplayModeFlowView => view.mode === "display" + +/** + * Detects the web Create repo URL entry state. + * + * @pure true + * @effect none + * @invariant result=true -> view.mode = "create" ∧ view.step = 0 + * @precondition view is a CreateFlowView snapshot + * @postcondition display settings rows are never reported as repo-step submissions + * @complexity O(1) + */ +export const isCreateFlowRepoStep = (view: CreateFlowView): boolean => isCreateModeFlowView(view) && view.step === 0 + const trimLeftSlash = (value: string): string => { let start = 0 while (start < value.length && value[start] === "/") { @@ -71,6 +157,8 @@ const joinPath = (...parts: ReadonlyArray): string => { return cleaned.join("/") } +const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N" + export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): string => Match.value(step).pipe( Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"), @@ -79,18 +167,96 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): Match.when("cpuLimit", () => `CPU limit [${defaults.cpuLimit || "30%"}]`), Match.when("ramLimit", () => `RAM limit [${defaults.ramLimit || "30%"}]`), Match.when("gpu", () => `GPU access [${defaults.gpu}]`), - Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), + Match.when("runUp", () => `Run docker compose up now? [${renderExplicitBooleanChoice(defaults.runUp)}]`), Match.when( "mcpPlaywright", - () => `Enable Playwright MCP (nested Chromium browser)? [${defaults.enableMcpPlaywright ? "y" : "N"}]` + () => + `Enable Playwright MCP (nested Chromium browser)? [${ + renderExplicitBooleanChoice(defaults.enableMcpPlaywright) + }]` ), Match.when( "force", - () => `Force recreate (overwrite files + wipe volumes)? [${defaults.force ? "y" : "N"}]` + () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` ), Match.exhaustive ) +const parseBooleanChoice = (input: string): boolean | null => { + const normalized = input.trim().toLowerCase() + if (normalized === "y" || normalized === "yes") { + return true + } + if (normalized === "n" || normalized === "no") { + return false + } + return null +} + +const parseExplicitBooleanChoice = parseBooleanChoice + +const parseExplicitGpuChoice = ( + input: string +): GpuMode | null => { + const normalized = input.trim().toLowerCase() + if (normalized === "y" || normalized === "yes") { + return "all" + } + if (normalized === "n" || normalized === "no") { + return "none" + } + if (isGpuMode(normalized)) { + return normalized + } + return null +} + +/** + * Renders the active Create settings label with an unapplied input-buffer preview. + * + * @pure true + * @effect none + * @invariant invalid or empty preview buffers preserve the committed/default label + * @precondition defaults are resolved Create inputs + * @postcondition Create values are not mutated or applied by rendering + * @complexity O(1) + */ +export const renderCreateStepLabelWithBufferPreview = ( + step: CreateStep, + defaults: CreateInputs, + buffer: string +): string => + Match.value(step).pipe( + Match.when("repoUrl", () => renderCreateStepLabel(step, defaults)), + Match.when("repoRef", () => renderCreateStepLabel(step, defaults)), + Match.when("outDir", () => renderCreateStepLabel(step, defaults)), + Match.when("cpuLimit", () => renderCreateStepLabel(step, defaults)), + Match.when("ramLimit", () => renderCreateStepLabel(step, defaults)), + Match.when("gpu", () => { + const gpu = parseExplicitGpuChoice(buffer) + return gpu === null ? renderCreateStepLabel(step, defaults) : `GPU access [${gpu}]` + }), + Match.when("runUp", () => { + const runUp = parseExplicitBooleanChoice(buffer) + return runUp === null + ? renderCreateStepLabel(step, defaults) + : `Run docker compose up now? [${renderExplicitBooleanChoice(runUp)}]` + }), + Match.when("mcpPlaywright", () => { + const enableMcpPlaywright = parseExplicitBooleanChoice(buffer) + return enableMcpPlaywright === null + ? renderCreateStepLabel(step, defaults) + : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` + }), + Match.when("force", () => { + const force = parseExplicitBooleanChoice(buffer) + return force === null + ? renderCreateStepLabel(step, defaults) + : `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(force)}]` + }), + Match.exhaustive + ) + const normalizeCreateFlowContext = ( context: string | CreateFlowContext ): CreateFlowContext => @@ -153,16 +319,7 @@ const parseGpuInput = ( return Either.left(createParseError("gpu must be one of: none, all, yes, no")) } -const parseYesDefault = (input: string, fallback: boolean): boolean => { - const normalized = input.trim().toLowerCase() - if (normalized === "y" || normalized === "yes") { - return true - } - if (normalized === "n" || normalized === "no") { - return false - } - return fallback -} +const parseYesDefault = (input: string, fallback: boolean): boolean => parseBooleanChoice(input) ?? fallback const createParseError = (reason: string): ParseError => ({ _tag: "InvalidOption", @@ -379,6 +536,20 @@ export const resolveCreateFlowSteps = ( .filter((step) => !isCreateStepSatisfied(step, values)) ] +/** + * Resolves the stable Create display rows used by browser Settings mode. + * + * @pure true + * @effect none + * @invariant result = createSteps and is independent of applied values + * @precondition n/a + * @postcondition applied settings rows remain present in the result + * @complexity O(1) + */ +export const resolveCreateDisplaySteps = ( + _values: Partial = {} +): ReadonlyArray => createSteps + const applyCreateStep = (input: { readonly step: CreateStep readonly buffer: string @@ -437,12 +608,60 @@ const applyCreateStep = (input: { Match.exhaustive ) -export const createInitialFlowView = (buffer = ""): CreateFlowView => ({ +const applyCreateBufferToValues = ( + context: CreateFlowContext, + view: CreateFlowView, + step: CreateStep +): Either.Either>, ParseError> => { + const buffer = view.buffer.trim() + const currentDefaults = resolveCreateInputs(context, view.values) + const nextValues: Partial> = { ...view.values } + const updated = applyCreateStep({ + step, + buffer, + currentDefaults, + nextValues, + context + }) + return Either.isLeft(updated) ? Either.left(updated.left) : Either.right(nextValues) +} + +export const createInitialFlowView = (buffer = ""): CreateModeFlowView => ({ + mode: "create", step: 0, buffer, + inputError: null, values: {} }) +const resolveDisplayFlowStep = (view: CreateFlowView): number => { + const displaySteps = resolveCreateDisplaySteps() + if (isDisplayModeFlowView(view)) { + return clampCreateSettingsStep(view.step, displaySteps.length - 1) + } + const flowStep = resolveCreateFlowSteps(view.values)[view.step] + const displayStep = flowStep === undefined ? -1 : displaySteps.indexOf(flowStep) + return clampCreateSettingsStep(displayStep === -1 ? view.step : displayStep, displaySteps.length - 1) +} + +/** + * Converts a parsed repo Create snapshot into browser display-settings mode. + * + * @pure true + * @effect none + * @invariant result.mode = "display" + * @precondition view contains already-applied repo values + * @postcondition result.step is clamped to a valid display settings row + * @complexity O(1) + */ +export const createDisplayFlowView = (view: CreateFlowView): DisplayModeFlowView => ({ + mode: "display", + step: resolveDisplayFlowStep(view), + buffer: view.buffer, + inputError: null, + values: view.values +}) + const shouldQuickCreate = ( step: CreateStep, options: AdvanceCreateFlowOptions @@ -456,15 +675,271 @@ const continueCreateFlow = ( ): AdvanceCreateFlowResult => ({ _tag: "Continue", view: { + mode: "create", step: nextStep, buffer: "", + inputError: null, + values: nextValues + } +}) + +const continueCreateDisplayFlow = ( + view: DisplayModeFlowView, + nextValues: Partial> +): AdvanceCreateFlowResult => ({ + _tag: "Continue", + view: { + ...view, + buffer: "", + inputError: null, values: nextValues } }) +const clampCreateSettingsStep = ( + step: number, + lastStep: number +): number => Math.min(Math.max(step, firstCreateSettingsStepIndex), lastStep) + +const nextCreateSettingsStep = ( + step: number, + lastStep: number, + direction: CreateSettingsNavigationDirection +): number => + Match.value(direction).pipe( + Match.when("up", () => step === firstCreateSettingsStepIndex ? lastStep : step - 1), + Match.when("down", () => step === lastStep ? firstCreateSettingsStepIndex : step + 1), + Match.exhaustive + ) + +function moveCreateSettingsWithin( + view: CreateModeFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): CreateModeFlowView | null +function moveCreateSettingsWithin( + view: DisplayModeFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): DisplayModeFlowView | null +function moveCreateSettingsWithin( + view: CreateFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): CreateFlowView | null { + if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { + return null + } + + const currentStep = clampCreateSettingsStep(view.step, lastStep) + const step = nextCreateSettingsStep(currentStep, lastStep, direction) + return step === view.step + ? view + : { + ...view, + step, + buffer: "", + inputError: null + } +} + +const booleanChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => + Match.value(direction).pipe( + Match.when("left", () => "n"), + Match.when("right", () => "y"), + Match.exhaustive + ) + +const gpuChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => + Match.value(direction).pipe( + Match.when("left", () => "none"), + Match.when("right", () => "all"), + Match.exhaustive + ) + +/** + * Resolves a horizontal settings choice to the Create input buffer without applying it. + * + * @pure true + * @effect none + * @invariant result = null for free-text Create rows + * @invariant result != null -> view.values are unchanged by caller-visible semantics + * @precondition view is a CreateFlowView snapshot + * @postcondition result ∈ {"none", "all", "n", "y"} ∪ {null} + * @complexity O(1) + */ +export const resolveCreateSettingsChoiceBuffer = ( + view: DisplayModeFlowView, + direction: CreateSettingsChoiceDirection +): string | null => { + const step = resolveCreateDisplaySteps()[view.step] + if (step === undefined) { + return null + } + + return Match.value(step).pipe( + Match.when("repoUrl", () => null), + Match.when("repoRef", () => null), + Match.when("outDir", () => null), + Match.when("cpuLimit", () => null), + Match.when("ramLimit", () => null), + Match.when("gpu", () => gpuChoiceBuffer(direction)), + Match.when("runUp", () => booleanChoiceBuffer(direction)), + Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), + Match.when("force", () => booleanChoiceBuffer(direction)), + Match.exhaustive + ) +} + +/** + * Moves the selected Create settings row without applying the current buffer. + * + * @pure true + * @effect none + * @invariant view.step = 0 -> result = null + * @invariant result != null -> 1 <= result.step < |resolveCreateFlowSteps(result.values)| + * @invariant result != null && result.step != view.step -> result.buffer = "" + * @precondition view is a CreateFlowView snapshot + * @postcondition result values are identical to the input values + * @complexity O(n) where n is the number of unresolved Create steps + */ +export const moveCreateSettingsStep = ( + view: CreateModeFlowView, + direction: CreateSettingsNavigationDirection +): CreateModeFlowView | null => + moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) + +/** + * Moves the selected browser Create settings row over the full display list. + * + * @pure true + * @effect none + * @invariant applied rows do not affect navigation order + * @invariant view.step = 0 -> result = null + * @invariant result != null -> 1 <= result.step < |resolveCreateDisplaySteps()| + * @precondition view is a CreateFlowView snapshot + * @postcondition result values are identical to input values + * @complexity O(1) + */ +export const moveCreateDisplaySettingsStep = ( + view: DisplayModeFlowView, + direction: CreateSettingsNavigationDirection +): DisplayModeFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) + +const resolveActiveCreateDisplayStep = (view: DisplayModeFlowView): CreateStep | null => { + const step = resolveCreateDisplaySteps()[view.step] + return view.step < firstCreateSettingsStepIndex || step === undefined ? null : step +} + +type ActiveCreateDisplayContext = { + readonly context: CreateFlowContext + readonly step: CreateStep +} + +const resolveActiveCreateDisplayContext = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): ActiveCreateDisplayContext | null => { + const step = resolveActiveCreateDisplayStep(view) + return step === null + ? null + : { + context: normalizeCreateFlowContext(contextOrCwd), + step + } +} + +const completeCreateFlow = ( + context: CreateFlowContext, + values: Partial +): AdvanceCreateFlowResult => ({ + _tag: "Complete", + inputs: resolveCreateInputs(context, values) +}) + +const foldAppliedCreateValues = ( + appliedValues: Either.Either>, ParseError>, + onSuccess: (nextValues: Partial>) => AdvanceCreateFlowResult +): AdvanceCreateFlowResult => + Either.isLeft(appliedValues) + ? { + _tag: "Error", + error: appliedValues.left + } + : onSuccess(appliedValues.right) + +const withActiveCreateDisplayContext = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView, + onActive: (active: ActiveCreateDisplayContext) => AdvanceCreateFlowResult | null +): AdvanceCreateFlowResult | null => { + const active = resolveActiveCreateDisplayContext(contextOrCwd, view) + return active === null ? null : onActive(active) +} + +/** + * Applies one browser Create settings display row without advancing or submitting. + * + * @pure true + * @effect none + * @invariant result._tag = "Continue" -> result.view.step = view.step + * @invariant result._tag = "Continue" -> result.view.buffer = "" + * @precondition view.step points at a settings display row + * @postcondition successful result stores the parsed setting in result.view.values + * @complexity O(1) + */ +export const applyCreateDisplaySettingsStep = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => + foldAppliedCreateValues( + applyCreateBufferToValues(active.context, view, active.step), + (nextValues) => continueCreateDisplayFlow(view, nextValues) + )) + +/** + * Completes browser Create settings by applying a non-empty active buffer first. + * + * @pure true + * @effect none + * @invariant non-empty invalid buffer -> result._tag = "Error" + * @invariant successful result._tag = "Complete" + * @precondition view.step points at a settings display row + * @postcondition submitted inputs include all committed values and defaults + * @complexity O(1) + */ +export const completeCreateDisplaySettingsFlow = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => { + if (view.buffer.trim().length === 0) { + return completeCreateFlow(active.context, view.values) + } + + const applied = applyCreateDisplaySettingsStep(active.context, view) + if (applied === null || applied._tag === "Error") { + return applied + } + if (applied._tag === "Continue") { + return completeCreateFlow(active.context, applied.view.values) + } + return applied + }) + +const resolveNextCreateFlowStep = ( + currentStep: CreateStep, + currentStepIndex: number, + nextSteps: ReadonlyArray +): number => + currentStep === "repoUrl" + ? firstCreateSettingsStepIndex + : clampCreateSettingsStep(currentStepIndex, nextSteps.length - 1) + export const advanceCreateFlow = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView, + view: CreateModeFlowView, options: AdvanceCreateFlowOptions = {} ): AdvanceCreateFlowResult | null => { const context = normalizeCreateFlowContext(contextOrCwd) @@ -474,40 +949,20 @@ export const advanceCreateFlow = ( return null } - const buffer = view.buffer.trim() - const currentDefaults = resolveCreateInputs(context, view.values) - const nextValues: Partial> = { ...view.values } - const updated = applyCreateStep({ - step, - buffer, - currentDefaults, - nextValues, - context - }) - if (Either.isLeft(updated)) { - return { - _tag: "Error", - error: updated.left - } - } + return foldAppliedCreateValues( + applyCreateBufferToValues(context, view, step), + (nextValues) => { + if (shouldQuickCreate(step, options)) { + return completeCreateFlow(context, nextValues) + } - if (shouldQuickCreate(step, options)) { - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, nextValues) + const nextSteps = resolveCreateFlowSteps(nextValues) + const nextStep = resolveNextCreateFlowStep(step, view.step, nextSteps) + return nextSteps.length > firstCreateSettingsStepIndex && nextStep < nextSteps.length + ? continueCreateFlow(nextStep, nextValues) + : completeCreateFlow(context, nextValues) } - } - - const nextSteps = resolveCreateFlowSteps(nextValues) - const nextStep = step === "repoUrl" ? 1 : view.step - if (nextStep < nextSteps.length) { - return continueCreateFlow(nextStep, nextValues) - } - - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, nextValues) - } + ) } export const handleAdvanceCreateFlowResult = ( diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 4525f49f..8de64caf 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -11,6 +11,7 @@ import { advanceCreateFlow, createInitialFlowView, handleAdvanceCreateFlowResult, + moveCreateSettingsStep, resolveCreateInputs } from "./menu-create-shared.js" import { resetToMenu } from "./menu-shared.js" @@ -156,7 +157,9 @@ const handleCreateReturn = ( }) }, onContinue: (view) => { - context.setView({ _tag: "Create", ...view }) + if (view.mode === "create") { + context.setView({ _tag: "Create", ...view }) + } context.setMessage(null) }, onError: (error) => { @@ -178,6 +181,8 @@ export const handleCreateInput = ( input: string, key: { readonly escape?: boolean + readonly upArrow?: boolean + readonly downArrow?: boolean readonly return?: boolean readonly shift?: boolean readonly backspace?: boolean @@ -190,6 +195,14 @@ export const handleCreateInput = ( resetToMenu(context) return } + if (key.upArrow || key.downArrow) { + const nextView = moveCreateSettingsStep(view, key.upArrow ? "up" : "down") + if (nextView !== null) { + context.setView({ _tag: "Create", ...nextView }) + context.setMessage(null) + } + return + } if (key.return) { handleCreateReturn({ ...context, view }, key.shift === true) return diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index de3c1d0a..03155b20 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -1,7 +1,7 @@ import React from "react" import { Box, Text } from "../ui/primitives.js" -import { renderCreateStepLabel } from "./menu-create-shared.js" +import { createSettingsHint, renderCreateStepLabel } from "./menu-create-shared.js" import { renderLayout } from "./menu-render-layout.js" import { buildSelectLabels, @@ -120,9 +120,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => { export const renderCreate = (input: CreateRenderInput): React.ReactElement => { const { buffer, defaults, label, message, stepIndex, steps } = input const el = React.createElement - const hint = stepIndex === 0 - ? "Enter = next, Shift+Enter = quick create, Esc = cancel." - : "Enter = next, Esc = cancel." + const hint = stepIndex > 0 ? createSettingsHint : null const stepViews = steps.map((step, index) => el( Text, @@ -132,7 +130,7 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => { ) return renderLayout( "docker-git / Create", - [ + compactElements([ el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews), el( Box, @@ -140,8 +138,10 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => { el(Text, null, `${label}: `), el(Text, { fg: "green" }, buffer) ), - el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) - ], + hint === null + ? null + : el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) + ]), message ) } diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 44df2630..7936cd15 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -142,7 +142,14 @@ export interface ProjectAuthSnapshot { export type ViewState = | { readonly _tag: "Menu" } - | { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial } + | { + readonly _tag: "Create" + readonly mode: "create" + readonly step: number + readonly buffer: string + readonly inputError: string | null + readonly values: Partial + } | { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot } | { readonly _tag: "AuthPrompt" diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index da1e0084..1892bcf1 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -20,11 +20,12 @@ 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, ControllerBootstrapError } from "./host-errors.js" +import type { ApiRequestError, CliError } from "./host-errors.js" import { terminalAuthTitle } from "./menu-auth-shared.js" -import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js" +import { attachTerminalSession } from "./terminal-session-client.js" type OperationalCommand = Exclude +type RoutedAuthEffect = Effect.Effect export type RoutedAuthCommand = Extract< OperationalCommand, @@ -46,10 +47,9 @@ export type RoutedAuthCommand = Extract< } > -const withControllerReady = ( - effect: Effect.Effect -): Effect.Effect => - pipe(ensureControllerReady(), Effect.zipRight(effect)) +const withControllerReady = ( + effect: Effect.Effect +): Effect.Effect => pipe(ensureControllerReady(), Effect.zipRight(effect)) const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) @@ -60,17 +60,6 @@ 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, @@ -120,12 +109,34 @@ const handleCodexLoginCommand = ( command: Extract ) => withControllerReady(codexLogin(command)) +/** + * Attaches the Grok OAuth terminal session created by the controller. + * + * @pure false + * @effect terminal websocket attachment through `attachTerminalSession` + * @invariant null controller sessions fail with a typed ApiRequestError + * @precondition controller response has already been decoded as ApiTerminalSession | null + * @postcondition non-null sessions are attached through the auth terminal websocket path + * @complexity O(1) before terminal IO + * @throws Never; errors are represented in the Effect error channel as CliError + */ +const attachGrokAuthTerminalSession = ( + 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 handleGrokLoginCommand = ( command: Extract ) => withControllerReady( createAuthTerminalSession("GrokOauth", command.label).pipe( - Effect.flatMap((session) => attachGrokTerminalSession(session)) + Effect.flatMap((session) => attachGrokAuthTerminalSession(session)) ) ) @@ -157,7 +168,7 @@ const handleCodexLogoutCommand = ( export const dispatchRoutedAuthCommand = ( command: RoutedAuthCommand -): Effect.Effect => +): RoutedAuthEffect => Match.value(command).pipe( Match.when({ _tag: "AuthGithubLogin" }, handleGithubLoginCommand), Match.when({ _tag: "AuthGithubStatus" }, handleGithubStatusCommand), diff --git a/packages/app/src/ui/primitives-web.tsx b/packages/app/src/ui/primitives-web.tsx index 7f558344..dd520453 100644 --- a/packages/app/src/ui/primitives-web.tsx +++ b/packages/app/src/ui/primitives-web.tsx @@ -1,4 +1,4 @@ -import { createElement, type CSSProperties, type JSX } from "react" +import { createElement, type CSSProperties, type JSX, type KeyboardEvent } from "react" import type { UiBoxProps, UiButtonProps, UiTextInputProps, UiTextProps } from "./primitives.js" @@ -113,8 +113,67 @@ export const webPrimitives = { props.multiline === true ? : } as const +const horizontalArrowAction = ( + key: string, + onArrowLeft: (() => void) | undefined, + onArrowRight: (() => void) | undefined +): (() => void) | null => { + if (key === "ArrowLeft") { + return onArrowLeft ?? null + } + if (key === "ArrowRight") { + return onArrowRight ?? null + } + return null +} + +type TextInputKeyboardHandlers = { + readonly onArrowLeft: (() => void) | undefined + readonly onArrowRight: (() => void) | undefined + readonly onEnter: ((shift: boolean) => void) | undefined + readonly onEscape: (() => void) | undefined +} + +const stopTextInputKey = ( + event: KeyboardEvent +): void => { + event.preventDefault() + event.stopPropagation() +} + +const handleMultilineTextInputKeyDown = + ({ onArrowLeft, onArrowRight, onEnter, onEscape }: TextInputKeyboardHandlers) => + (event: KeyboardEvent): void => { + const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight) + if (onArrow !== null) { + stopTextInputKey(event) + onArrow() + return + } + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + stopTextInputKey(event) + onEnter?.(event.shiftKey) + return + } + if (event.key === "Escape") { + stopTextInputKey(event) + onEscape?.() + } + } + const MultilineTextInput = ( - { ariaLabel, autoFocus, minRows, onChange, onEnter, onEscape, placeholder, value }: UiTextInputProps + { + ariaLabel, + autoFocus, + minRows, + onArrowLeft, + onArrowRight, + onChange, + onEnter, + onEscape, + placeholder, + value + }: UiTextInputProps ): JSX.Element => { const rows = minRows ?? 6 return ( @@ -124,19 +183,7 @@ const MultilineTextInput = ( onChange={(event) => { onChange(event.currentTarget.value) }} - onKeyDown={(event) => { - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - event.stopPropagation() - onEnter?.(event.shiftKey) - return - } - if (event.key === "Escape") { - event.preventDefault() - event.stopPropagation() - onEscape?.() - } - }} + onKeyDown={handleMultilineTextInputKeyDown({ onArrowLeft, onArrowRight, onEnter, onEscape })} placeholder={placeholder} rows={rows} style={{ @@ -152,7 +199,18 @@ const MultilineTextInput = ( } const SingleLineTextInput = ( - { ariaLabel, autoFocus, onChange, onEnter, onEscape, placeholder, secret, value }: UiTextInputProps + { + ariaLabel, + autoFocus, + onArrowLeft, + onArrowRight, + onChange, + onEnter, + onEscape, + placeholder, + secret, + value + }: UiTextInputProps ): JSX.Element => ( { + const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight) + if (onArrow !== null) { + event.preventDefault() + event.stopPropagation() + onArrow() + return + } if (event.key === "Enter") { event.preventDefault() event.stopPropagation() diff --git a/packages/app/src/ui/primitives.tsx b/packages/app/src/ui/primitives.tsx index 63380c4e..c7e48118 100644 --- a/packages/app/src/ui/primitives.tsx +++ b/packages/app/src/ui/primitives.tsx @@ -56,6 +56,8 @@ export type UiTextInputProps = { readonly minRows?: number readonly multiline?: boolean readonly onChange: (value: string) => void + readonly onArrowLeft?: () => void + readonly onArrowRight?: () => void readonly onEnter?: (shift: boolean) => void readonly onEscape?: () => void readonly placeholder?: string diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 59251bd2..b8de9301 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -448,7 +448,7 @@ export const runProjectMenuAction = ( context: BrowserActionContext ) => { if (currentMenu === "Create") { - context.setMessage("Create mode is active. Paste URL or URL + flags, Enter = next, Shift+Enter = quick create.") + context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.") return } if (currentMenu === "Select") { diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 34fa08ef..cbca0ae3 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -19,7 +19,13 @@ import { resolveCurrentMenu, runAuthActionByIndex, runProjectAuthActionByIndex } import { useProjectBrowserReset, useTerminalBrowserAutoload } from "./app-ready-browser-hook.js" import { useBrowserShortcuts } from "./app-ready-browser-shortcuts-hook.js" import { createReadyActionContext } from "./app-ready-controller-context.js" -import { cancelCreate, setCreateBuffer, submitCreateView, useCreateMenuReset } from "./app-ready-create.js" +import { + cancelCreate, + type CreateSubmitMode, + setCreateBuffer, + submitCreateView, + useCreateMenuReset +} from "./app-ready-create.js" import { bindDatabaseActions } from "./app-ready-database-actions.js" import { useProjectDatabasesReset } from "./app-ready-databases-hook.js" import { useGithubAuthGate } from "./app-ready-github-auth-gate-hook.js" @@ -198,13 +204,13 @@ const bindCreateActions = ( onCreateCancel: () => { cancelCreate(actionContext, state.setCreateView) }, - onCreateSubmit: (quickCreate = false) => { + onCreateSubmit: (mode: CreateSubmitMode) => { submitCreateView({ context: actionContext, controllerCwd: dashboard.health.cwd, projectsRoot: dashboard.health.projectsRoot, createView: state.createView, - quickCreate, + mode, setCreateView: state.setCreateView }) } diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index 7068100e..a57bb8fd 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -4,9 +4,17 @@ import { formatParseError } from "../docker-git/cli/usage.js" import { nextBufferValue } from "../docker-git/menu-buffer-input.js" import { advanceCreateFlow, + applyCreateDisplaySettingsStep, + completeCreateDisplaySettingsFlow, + createDisplayFlowView, type CreateFlowView, createInitialFlowView, - handleAdvanceCreateFlowResult + type DisplayModeFlowView, + handleAdvanceCreateFlowResult, + isCreateFlowRepoStep, + isDisplayModeFlowView, + moveCreateDisplaySettingsStep, + resolveCreateSettingsChoiceBuffer } from "../docker-git/menu-create-shared.js" import { submitCreateInputs } from "./actions-projects.js" import { requireGithubAuthConfigured } from "./actions-shared.js" @@ -16,6 +24,10 @@ import { menuScreen } from "./screen.js" type Setter = Dispatch> +const emptyRepoUrlInputError = "Insert URL first" + +export type CreateSubmitMode = "advance" | "quick-create" | "complete-settings" + type CreateKeyArgs = { readonly context: BrowserActionContext readonly controllerCwd: string @@ -25,7 +37,7 @@ type CreateKeyArgs = { } type CreateSubmitArgs = CreateKeyArgs & { - readonly quickCreate?: boolean + readonly mode: CreateSubmitMode } type CreateKeyboardEvent = { @@ -53,7 +65,21 @@ export const setCreateBuffer = ( setCreateView: Setter, buffer: string ) => { - setCreateView({ ...createView, buffer }) + setCreateView({ ...createView, buffer, inputError: null }) +} + +const resolveCreateSubmitResult = ( + createContext: { readonly cwd: string; readonly projectsRoot: string }, + createView: CreateFlowView, + mode: CreateSubmitMode +): ReturnType => { + if (isDisplayModeFlowView(createView)) { + return mode === "advance" + ? applyCreateDisplaySettingsStep(createContext, createView) + : completeCreateDisplaySettingsFlow(createContext, createView) + } + const next = advanceCreateFlow(createContext, createView, { quickCreate: mode === "quick-create" }) + return next?._tag === "Continue" ? { ...next, view: createDisplayFlowView(next.view) } : next } export const submitCreateView = ( @@ -61,19 +87,22 @@ export const submitCreateView = ( context, controllerCwd, createView, + mode, projectsRoot, - quickCreate, setCreateView }: CreateSubmitArgs ): void => { + if (isCreateFlowRepoStep(createView) && createView.buffer.trim().length === 0) { + setCreateView({ ...createView, inputError: emptyRepoUrlInputError }) + return + } + if (!requireGithubAuthConfigured(context)) { return } const createContext = { cwd: controllerCwd, projectsRoot } - const next = quickCreate === undefined - ? advanceCreateFlow(createContext, createView) - : advanceCreateFlow(createContext, createView, { quickCreate }) + const next = resolveCreateSubmitResult(createContext, createView, mode) handleAdvanceCreateFlowResult(next, { onError: (error) => { context.setMessage(formatParseError(error)) @@ -100,28 +129,80 @@ export const useCreateMenuReset = ( }, [currentMenu, setCreateView]) } -export const handleCreateKey = ( +const handleCreateVerticalArrow = ( event: CreateKeyboardEvent, - { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs + createView: DisplayModeFlowView, + setCreateView: Setter, + context: BrowserActionContext ): boolean => { - if (event.key === "Escape") { - event.preventDefault() - cancelCreate(context, setCreateView) - return true + const nextView = moveCreateDisplaySettingsStep(createView, event.key === "ArrowUp" ? "up" : "down") + if (nextView === null) { + return false } - if (event.key === "Enter") { - event.preventDefault() - submitCreateView({ - context, - controllerCwd, - projectsRoot, - createView, - quickCreate: event.shiftKey, - setCreateView - }) - return true + event.preventDefault() + setCreateView(nextView) + context.setMessage(null) + return true +} + +const handleCreateHorizontalArrow = ( + event: CreateKeyboardEvent, + createView: DisplayModeFlowView, + setCreateView: Setter, + context: BrowserActionContext +): boolean => { + const nextBuffer = resolveCreateSettingsChoiceBuffer( + createView, + event.key === "ArrowLeft" ? "left" : "right" + ) + if (nextBuffer === null) { + return false } + event.preventDefault() + setCreateBuffer(createView, setCreateView, nextBuffer) + context.setMessage(null) + return true +} +const submitCreateFromKeyboard = ( + event: CreateKeyboardEvent, + { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs +): void => { + event.preventDefault() + submitCreateView({ + context, + controllerCwd, + projectsRoot, + createView, + mode: event.shiftKey && isCreateFlowRepoStep(createView) ? "quick-create" : "advance", + setCreateView + }) +} + +const handleCreateArrowKey = ( + event: CreateKeyboardEvent, + createView: CreateFlowView, + setCreateView: Setter, + context: BrowserActionContext +): boolean | null => { + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + return isDisplayModeFlowView(createView) + ? handleCreateVerticalArrow(event, createView, setCreateView, context) + : false + } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + return isDisplayModeFlowView(createView) + ? handleCreateHorizontalArrow(event, createView, setCreateView, context) + : false + } + return null +} + +const handleCreateTextKey = ( + event: CreateKeyboardEvent, + createView: CreateFlowView, + setCreateView: Setter +): boolean => { const nextBuffer = nextBufferValue( createCharacterInput(event), { backspace: event.key === "Backspace", delete: event.key === "Delete" }, @@ -134,3 +215,23 @@ export const handleCreateKey = ( setCreateBuffer(createView, setCreateView, nextBuffer) return true } + +export const handleCreateKey = ( + event: CreateKeyboardEvent, + { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs +): boolean => { + if (event.key === "Escape") { + event.preventDefault() + cancelCreate(context, setCreateView) + return true + } + const arrowHandled = handleCreateArrowKey(event, createView, setCreateView, context) + if (arrowHandled !== null) { + return arrowHandled + } + if (event.key === "Enter") { + submitCreateFromKeyboard(event, { context, controllerCwd, createView, projectsRoot, setCreateView }) + return true + } + return handleCreateTextKey(event, createView, setCreateView) +} diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 37af1afe..4611bc86 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -20,6 +20,7 @@ import type { ProjectSkillScope, ProjectSkillsSnapshot } from "./api.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { MainPanels } from "./app-ready-main-panels.js" import { Box, Text } from "./elements.js" import type { BrowserMenuTag } from "./menu.js" @@ -63,7 +64,7 @@ export type ReadyLayoutProps = { readonly onBackScreen: () => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onCloseProjectPortForward: (targetPort: number) => void readonly onDatabaseConnectionInputChange: (value: string) => void readonly onDatabaseLabelInputChange: (value: string) => void diff --git a/packages/app/src/web/app-ready.tsx b/packages/app/src/web/app-ready.tsx index e5a9f301..14697262 100644 --- a/packages/app/src/web/app-ready.tsx +++ b/packages/app/src/web/app-ready.tsx @@ -2,6 +2,7 @@ import { type JSX } from "react" import type { DashboardData } from "./api.js" import { useReadyController } from "./app-ready-controller.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { ReadyLayout } from "./app-ready-layout.js" import type { ViewportLayout } from "./viewport-layout.js" @@ -29,7 +30,7 @@ type ReadyLayoutRenderArgs = { readonly onBackScreen: () => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onDatabaseConnectionInputChange: (value: string) => void readonly onDatabaseLabelInputChange: (value: string) => void readonly onCloseDatabaseForward: ReturnType["onCloseDatabaseForward"] diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index 52d27b8c..0d0ddebd 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -4,6 +4,7 @@ import type { JSX } from "react" import type { CreateFlowView } from "../docker-git/menu-create-shared.js" import type { ActionPromptState } from "./action-prompt.js" import type { AuthSnapshot, GithubAuthStatus, ProjectAuthSnapshot, ProjectDetails, ProjectSummary } from "./api.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { Box, Text } from "./elements.js" import type { BrowserMenuTag } from "./menu.js" import { AuthPanel } from "./panel-auth.js" @@ -32,7 +33,7 @@ type ContentPanelProps = { ) => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onKillProjectTerminalSession: (projectId: string, projectKey: string, sessionId: string) => void readonly onOpenProjectTerminalById: (projectId: string, projectKey?: string) => void readonly onRunAuthAction: (index: number) => void diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 45600e3f..4a01a293 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -3,38 +3,44 @@ import type { JSX } from "react" import { type CreateFlowContext, type CreateFlowView, + type CreateSettingsChoiceDirection, + createSettingsHint, + isCreateFlowRepoStep, + isDisplayModeFlowView, renderCreateStepLabel, - resolveCreateFlowSteps, - resolveCreateInputs + renderCreateStepLabelWithBufferPreview, + resolveCreateDisplaySteps, + resolveCreateInputs, + resolveCreateSettingsChoiceBuffer } from "../docker-git/menu-create-shared.js" import type { CreateStep } from "../docker-git/menu-types.js" import { Box, Button, Text, TextInput } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" +import type { CreateSubmitMode } from "./app-ready-create.js" const renderStepColor = (active: boolean): string => active ? "#56f39a" : "#8fa6c4" +const webCreateSettingsChoiceHint = "←/→ - choose yes/no or GPU" + const createPrompt = ( createContext: CreateFlowContext, createView: CreateFlowView ): { readonly label: string; readonly defaults: ReturnType } => { const defaults = resolveCreateInputs(createContext, createView.values) - const steps = resolveCreateFlowSteps(createView.values) + const steps = resolveCreateDisplaySteps() const step = steps[createView.step] ?? steps[0] ?? "repoUrl" return { - label: renderCreateStepLabel(step, defaults), + label: renderCreateStepLabelWithBufferPreview(step, defaults, createView.buffer), defaults } } -const createHint = (isRepoStep: boolean): string => - isRepoStep - ? "Enter = next, Shift+Enter = quick create, Esc = cancel." - : "Enter = next, Esc = cancel." - const CreatePromptInput = ( { createView, isRepoStep, + onArrowLeft, + onArrowRight, onBufferChange, onCancel, onSubmit, @@ -42,25 +48,34 @@ const CreatePromptInput = ( }: { readonly createView: CreateFlowView readonly isRepoStep: boolean + readonly onArrowLeft?: () => void + readonly onArrowRight?: () => void readonly onBufferChange: (buffer: string) => void readonly onCancel: () => void - readonly onSubmit: (quickCreate?: boolean) => void + readonly onSubmit: (mode: CreateSubmitMode) => void readonly promptLabel: string } ): JSX.Element => ( - { - onBufferChange(value) - }} - onEnter={(shift) => { - onSubmit(shift) - }} - onEscape={onCancel} - placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} - value={createView.buffer} - /> + <> + { + onBufferChange(value) + }} + {...(onArrowLeft === undefined ? {} : { onArrowLeft })} + {...(onArrowRight === undefined ? {} : { onArrowRight })} + onEnter={(shift) => { + onSubmit(isRepoStep && shift ? "quick-create" : "advance") + }} + onEscape={onCancel} + placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} + value={createView.buffer} + /> + {createView.inputError === null || !isRepoStep + ? null + : {createView.inputError}} + ) export const CreatePanel = ( @@ -79,20 +94,35 @@ export const CreatePanel = ( readonly projectsRoot: string readonly onBufferChange: (buffer: string) => void readonly onCancel: () => void - readonly onSubmit: (quickCreate?: boolean) => void + readonly onSubmit: (mode: CreateSubmitMode) => void } ): JSX.Element => { const prompt = createPrompt({ cwd: controllerCwd, projectsRoot }, createView) - const steps = resolveCreateFlowSteps(createView.values) - const visibleSteps = compact ? [steps[createView.step] ?? "repoUrl"] : steps - const isRepoStep = (steps[createView.step] ?? "repoUrl") === "repoUrl" + const steps = resolveCreateDisplaySteps() + const activeStep = isDisplayModeFlowView(createView) ? steps[createView.step] ?? "repoUrl" : "repoUrl" + const isRepoStep = isCreateFlowRepoStep(createView) + const visibleSteps = compact && isRepoStep ? [activeStep] : steps + const leftChoiceBuffer = isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "left") + : null + const rightChoiceBuffer = isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "right") + : null + const chooseSettingsBuffer = (direction: CreateSettingsChoiceDirection): void => { + if (isDisplayModeFlowView(createView)) { + const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) + if (nextBuffer !== null) { + onBufferChange(nextBuffer) + } + } + } return ( docker-git / Create @@ -101,6 +131,20 @@ export const CreatePanel = ( { + chooseSettingsBuffer("left") + } + })} + {...(rightChoiceBuffer === null + ? {} + : { + onArrowRight: () => { + chooseSettingsBuffer("right") + } + })} onBufferChange={onBufferChange} onCancel={onCancel} onSubmit={onSubmit} @@ -111,14 +155,29 @@ export const CreatePanel = ( ? ( ") + expect(html).not.toContain("Quick Create") + expect(html).not.toContain("Settings") + }) + + it("marks only the current row active in compact settings mode", () => { + const createView = createSettingsView() + const html = renderCreatePanel(createView, { compact: true }) + const activeLabel = renderStepLabels(createView)[createView.step] ?? "Repo URL (optional for empty workspace)" + + expect(countActiveStepMarkers(html)).toBe(1) + expect(html).toContain(`${activeStepMarker}${activeLabel}`) + }) + + it("previews side-arrow choices in the active settings row brackets without applying values", () => { + const createView = createSettingsViewAtStep("mcpPlaywright", "y") + const html = renderCreatePanel(createView, { compact: true }) + + expect(html).toContain(`${activeStepMarker}Enable Playwright MCP (nested Chromium browser)? [Y]`) + expect(html).toContain("Enable Playwright MCP (nested Chromium browser)? [Y]:") + expect(html).toContain("Force recreate (overwrite files + wipe volumes)? [N]") + expect(html).not.toContain(`${activeStepMarker}Enable Playwright MCP (nested Chromium browser)? [N]`) + }) + + it("drops unapplied bracket previews after settings navigation clears the buffer", () => { + const createView = createSettingsViewAtStep("force", "") + const html = renderCreatePanel(createView, { compact: true }) + + expect(html).toContain(`${activeStepMarker}Force recreate (overwrite files + wipe volumes)? [N]`) + expect(html).not.toContain("Force recreate (overwrite files + wipe volumes)? [Y]") + }) + + it("renders the settings navigation hint only after leaving the repo URL step", () => { + expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain(createSettingsHint) + expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain(webCreateSettingsChoiceHint) + expect(renderCreatePanel(createSettingsView())).toContain(createSettingsHint) + expect(renderCreatePanel(createSettingsView())).toContain(webCreateSettingsChoiceHint) + }) + + it("renders terminal Create hints with the same repo/settings split", () => { + const repoHtml = renderTerminalCreate(createInitialFlowView(featureCreateRepoUrl)) + const settingsHtml = renderTerminalCreate(createTerminalSettingsView()) + + expect(repoHtml).not.toContain("Enter = next, Esc = cancel.") + expect(repoHtml).not.toContain("Shift+Enter") + expect(settingsHtml).toContain(createSettingsHint) + expect(settingsHtml).not.toContain(webCreateSettingsChoiceHint) + }) + + it("preserves hint visibility invariants for every Create step", () => { + const settingsView = createSettingsView() + const lastDisplayStep = resolveCreateDisplaySteps(settingsView.values).length - 1 + + fc.assert( + fc.property(fc.integer({ min: 0, max: lastDisplayStep }), (step) => { + const view = step === 0 ? createInitialFlowView(featureCreateRepoUrl) : { ...settingsView, step } + const isSettings = step > 0 + const panelHtml = renderCreatePanel(view) + const compactPanelHtml = renderCreatePanel(view, { compact: true }) + + expect(panelHtml.includes(createSettingsHint)).toBe(isSettings) + expect(compactPanelHtml.includes(createSettingsHint)).toBe(isSettings) + expect(panelHtml.includes(webCreateSettingsChoiceHint)).toBe(isSettings) + expect(compactPanelHtml.includes(webCreateSettingsChoiceHint)).toBe(isSettings) + expect(panelHtml).not.toContain("Enter = next, Esc = cancel.") + expect(compactPanelHtml).not.toContain("Enter = next, Esc = cancel.") + expect(panelHtml).not.toContain("Shift+Enter") + expect(compactPanelHtml).not.toContain("Shift+Enter") + }) + ) + + const terminalSettingsView = createTerminalSettingsView() + const lastTerminalStep = resolveCreateFlowSteps(terminalSettingsView.values).length - 1 + + fc.assert( + fc.property(fc.integer({ min: 0, max: lastTerminalStep }), (step) => { + const view = step === 0 ? createInitialFlowView(featureCreateRepoUrl) : { ...terminalSettingsView, step } + const terminalHtml = renderTerminalCreate(view) + + expect(terminalHtml.includes(createSettingsHint)).toBe(step > 0) + expect(terminalHtml).not.toContain(webCreateSettingsChoiceHint) + expect(terminalHtml).not.toContain("Enter = next, Esc = cancel.") + expect(terminalHtml).not.toContain("Shift+Enter") + }) + ) + }) +}) diff --git a/packages/app/tests/docker-git/create-flow-test-helpers.ts b/packages/app/tests/docker-git/create-flow-test-helpers.ts new file mode 100644 index 00000000..b1a41941 --- /dev/null +++ b/packages/app/tests/docker-git/create-flow-test-helpers.ts @@ -0,0 +1,135 @@ +import * as fc from "fast-check" +import { expect } from "vitest" + +import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" +import { + advanceCreateFlow, + createDisplayFlowView, + type CreateFlowView, + createInitialFlowView, + type CreateModeFlowView, + type DisplayModeFlowView, + resolveCreateDisplaySteps, + resolveCreateFlowSteps +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" + +type CreateFlowAdvanceResult = NonNullable> + +export const featureCreateRepoUrl = "https://github.com/org/repo/tree/feature-x" + +const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" +const githubNameCharArbitrary = fc + .integer({ min: 0, max: githubNameChars.length - 1 }) + .map((index) => githubNameChars[index] ?? "a") + +const githubSegmentArbitrary = fc + .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) + .map((chars) => chars.join("")) + .filter((value) => !value.startsWith("-") && !value.endsWith("-")) + +export const repositoryCreateInputArbitrary = fc.record({ + branch: fc.option(githubSegmentArbitrary, { nil: null }), + owner: githubSegmentArbitrary, + repo: githubSegmentArbitrary +}).map(({ branch, owner, repo }) => ({ + expectedRepoRef: branch ?? "main", + repoUrl: branch === null + ? `https://github.com/${owner}/${repo}` + : `https://github.com/${owner}/${repo}/tree/${branch}` +})) + +export const expectedOutDirForRepoUrl = (repoUrl: string, projectsRoot: string): string => + `${projectsRoot}/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` + +export const expectCreateContinueView = ( + next: ReturnType +): Extract["view"] => { + expect(next?._tag).toBe("Continue") + if (next === null || next._tag !== "Continue") { + throw new TypeError("expected continue create flow result") + } + return next.view +} + +export const expectCreateCompleteInputs = ( + next: ReturnType +): Extract["inputs"] => { + expect(next?._tag).toBe("Complete") + if (next === null || next._tag !== "Complete") { + throw new TypeError("expected complete create flow result") + } + return next.inputs +} + +export const resolveRequiredCreateStepIndex = ( + stepName: CreateStep, + steps: ReadonlyArray +): number => { + const step = steps.indexOf(stepName) + if (step === -1) { + throw new TypeError(`expected Create step: ${stepName}`) + } + return step +} + +export const expectedWrappedCreateNavigationStep = ( + step: number, + direction: "up" | "down", + lastStep: number +): number => { + if (direction === "up") { + return step === 1 ? lastStep : step - 1 + } + return step === lastStep ? 1 : step + 1 +} + +export const expectCreateNavigationResult = ( + next: CreateFlowView | null, + expectedStep: number, + expectedValues: CreateFlowView["values"] +): void => { + expect(next).not.toBeNull() + expect(next?.step).toBe(expectedStep) + expect(next?.buffer).toBe("") + expect(next?.values).toEqual(expectedValues) +} + +export const createFeatureRepoSettingsView = ( + contextOrCwd: Parameters[0] +): CreateModeFlowView => { + const view = expectCreateContinueView(advanceCreateFlow(contextOrCwd, createInitialFlowView(featureCreateRepoUrl))) + if (view.mode !== "create") { + throw new TypeError("expected create mode flow view") + } + return view +} + +export const createFeatureRepoDisplaySettingsView = ( + contextOrCwd: Parameters[0] +): DisplayModeFlowView => createDisplayFlowView(createFeatureRepoSettingsView(contextOrCwd)) + +export function createFlowViewAtStep( + view: CreateModeFlowView, + stepName: CreateStep, + buffer?: string +): CreateModeFlowView +export function createFlowViewAtStep( + view: DisplayModeFlowView, + stepName: CreateStep, + buffer?: string +): DisplayModeFlowView +export function createFlowViewAtStep( + view: CreateFlowView, + stepName: CreateStep, + buffer = "draft" +): CreateFlowView { + const steps = view.mode === "display" + ? resolveCreateDisplaySteps() + : resolveCreateFlowSteps(view.values) + return { + ...view, + buffer, + step: resolveRequiredCreateStepIndex(stepName, steps) + } +} diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts new file mode 100644 index 00000000..09f43b7c --- /dev/null +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -0,0 +1,228 @@ +import * as fc from "fast-check" +import { describe, expect, it } from "vitest" + +import { + applyCreateDisplaySettingsStep, + completeCreateDisplaySettingsFlow, + type DisplayModeFlowView, + moveCreateDisplaySettingsStep, + renderCreateStepLabelWithBufferPreview, + resolveCreateDisplaySteps, + resolveCreateInputs, + resolveCreateSettingsChoiceBuffer +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" +import { + createFeatureRepoDisplaySettingsView, + createFlowViewAtStep, + expectCreateCompleteInputs, + expectCreateContinueView, + expectCreateNavigationResult, + expectedWrappedCreateNavigationStep +} from "./create-flow-test-helpers.js" + +const expectDisplayModeView = (view: ReturnType): DisplayModeFlowView => { + expect(view.mode).toBe("display") + if (view.mode !== "display") { + throw new TypeError("expected display mode CreateFlowView") + } + return view +} + +describe("menu-create-shared display settings", () => { + const cwd = process.cwd() + const isDisplaySettingStep = (step: CreateStep): step is Exclude => step !== "repoUrl" + const displaySettingSteps = resolveCreateDisplaySteps().filter(isDisplaySettingStep) + const discreteDisplaySteps: ReadonlyArray<"gpu" | "runUp" | "mcpPlaywright" | "force"> = [ + "gpu", + "runUp", + "mcpPlaywright", + "force" + ] + const validBufferByStep: Record = { + cpuLimit: "45%", + force: "y", + gpu: "all", + mcpPlaywright: "y", + outDir: "/home/dev/.docker-git/org/repo-preview", + ramLimit: "8g", + repoRef: "main", + repoUrl: "https://github.com/org/repo", + runUp: "n" + } + + it("keeps every browser display row after settings are applied", () => { + const appliedValues = { + cpuLimit: "40%", + enableMcpPlaywright: true, + force: true, + gpu: "all", + ramLimit: "8g", + runUp: false + } satisfies Partial> + + expect(resolveCreateDisplaySteps(appliedValues)).toEqual([ + "repoUrl", + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright", + "force" + ]) + }) + + it("applies a browser display setting in place", () => { + const mcpView = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright") + const next = expectCreateContinueView(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) + + expect(next.step).toBe(mcpView.step) + expect(next.buffer).toBe("") + expect(next.values.enableMcpPlaywright).toBe(true) + expect(resolveCreateDisplaySteps()[next.step]).toBe("mcpPlaywright") + }) + + it("navigates browser display settings without skipping applied rows", () => { + const view = createFeatureRepoDisplaySettingsView(cwd) + const applied = expectDisplayModeView(expectCreateContinueView(applyCreateDisplaySettingsStep( + cwd, + { ...createFlowViewAtStep(view, "mcpPlaywright"), buffer: "y" } + ))) + const down = moveCreateDisplaySettingsStep(applied, "down") + const up = moveCreateDisplaySettingsStep(applied, "up") + + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) + expect(down?.buffer).toBe("") + expect(up?.values.enableMcpPlaywright).toBe(true) + }) + + it("resolves horizontal choices against applied browser display rows", () => { + const applied = expectDisplayModeView(expectCreateContinueView(applyCreateDisplaySettingsStep( + cwd, + { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright"), buffer: "y" } + ))) + + expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(applied, "right")).toBe("y") + }) + + it("completes browser display settings with a valid active buffer", () => { + const complete = expectCreateCompleteInputs(completeCreateDisplaySettingsFlow( + cwd, + { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "mcpPlaywright"), buffer: "y" } + )) + + expect(complete.enableMcpPlaywright).toBe(true) + }) + + it("returns a typed error when Done has an invalid active display buffer", () => { + const result = completeCreateDisplaySettingsFlow( + cwd, + { ...createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), "gpu"), buffer: "bogus" } + ) + + expect(result?._tag).toBe("Error") + if (result === null || result._tag !== "Error") { + throw new TypeError("expected display settings completion error") + } + expect(result.error).toEqual({ + _tag: "InvalidOption", + option: "create", + reason: "gpu must be one of: none, all, yes, no" + }) + }) + + it("preserves display apply invariants for every settings row", () => { + fc.assert( + fc.property(fc.constantFrom(...displaySettingSteps), (stepName) => { + const view = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), stepName) + const beforeValues = { ...view.values } + const next = expectDisplayModeView(expectCreateContinueView(applyCreateDisplaySettingsStep( + cwd, + { ...view, buffer: validBufferByStep[stepName] } + ))) + + expect(next.step).toBe(view.step) + expect(next.buffer).toBe("") + expect(resolveCreateDisplaySteps()[next.step]).toBe(stepName) + expect(resolveCreateDisplaySteps(next.values)).toEqual(resolveCreateDisplaySteps(beforeValues)) + expect(next.values).toEqual({ + ...beforeValues, + ...next.values + }) + }) + ) + }) + + it("preserves display navigation wraparound and buffer clearing invariants", () => { + const lastStep = resolveCreateDisplaySteps().length - 1 + + fc.assert( + fc.property( + fc.integer({ min: 1, max: lastStep }), + fc.constantFrom("up", "down"), + (step, direction) => { + const view = { ...createFeatureRepoDisplaySettingsView(cwd), step, buffer: "preview" } + const next = moveCreateDisplaySettingsStep(view, direction) + + expectCreateNavigationResult( + next, + expectedWrappedCreateNavigationStep(step, direction, lastStep), + view.values + ) + } + ) + ) + }) + + it("keeps preview rendering and side-arrow choices isolated from committed display values", () => { + fc.assert( + fc.property( + fc.constantFrom(...discreteDisplaySteps), + fc.constantFrom("left", "right"), + (stepName, direction) => { + const view = createFlowViewAtStep(createFeatureRepoDisplaySettingsView(cwd), stepName, "typed") + const beforeValues = { ...view.values } + const nextBuffer = resolveCreateSettingsChoiceBuffer(view, direction) + const defaults = resolveCreateInputs(cwd, view.values) + const label = renderCreateStepLabelWithBufferPreview(stepName, defaults, nextBuffer ?? "") + + expect(nextBuffer).not.toBeNull() + expect(label.length).toBeGreaterThan(0) + expect(view.values).toEqual(beforeValues) + } + ) + ) + }) + + it("renders unapplied buffer previews for discrete settings labels", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "all")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "none")).toBe("GPU access [none]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "y")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("runUp", defaults, "n")).toBe( + "Run docker compose up now? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "y")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [Y]" + ) + expect(renderCreateStepLabelWithBufferPreview("force", defaults, "y")).toBe( + "Force recreate (overwrite files + wipe volumes)? [Y]" + ) + }) + + it("preserves committed/default labels for empty, invalid, and free-text preview buffers", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "maybe")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("cpuLimit", defaults, "80%")).toBe("CPU limit [30%]") + expect(renderCreateStepLabelWithBufferPreview("ramLimit", defaults, "8g")).toBe("RAM limit [30%]") + }) +}) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 875cb62c..9821edb2 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -1,30 +1,25 @@ +import * as fc from "fast-check" import { describe, expect, it } from "vitest" import { advanceCreateFlow, + createDisplayFlowView, createInitialFlowView, - resolveCreateFlowSteps + moveCreateSettingsStep, + resolveCreateDisplaySteps, + resolveCreateFlowSteps, + resolveCreateSettingsChoiceBuffer } from "../../src/docker-git/menu-create-shared.js" - -const expectContinueResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Continue") - if (next === null || next._tag !== "Continue") { - throw new TypeError("expected continue create flow result") - } - return next.view -} - -const expectCompleteResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Complete") - if (next === null || next._tag !== "Complete") { - throw new TypeError("expected complete create flow result") - } - return next.inputs -} +import { + createFeatureRepoDisplaySettingsView, + createFeatureRepoSettingsView, + createFlowViewAtStep, + expectCreateCompleteInputs, + expectCreateContinueView, + expectCreateNavigationResult, + expectedWrappedCreateNavigationStep, + featureCreateRepoUrl +} from "./create-flow-test-helpers.js" const expectFeatureRepoDefaults = ( value: { @@ -34,7 +29,7 @@ const expectFeatureRepoDefaults = ( }, defaultRoot: string ) => { - expect(value.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") + expect(value.repoUrl).toBe(featureCreateRepoUrl) expect(value.repoRef).toBe("feature-x") expect(value.outDir).toBe(defaultRoot) } @@ -42,11 +37,12 @@ const expectFeatureRepoDefaults = ( describe("menu-create-shared", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` + const settingsDirectionArbitrary: fc.Arbitrary<"up" | "down"> = fc.constantFrom("up", "down") it("advances from repo URL into the wizard by default", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") + createInitialFlowView(featureCreateRepoUrl) )) expect(view.step).toBe(1) @@ -64,9 +60,9 @@ describe("menu-create-shared", () => { }) it("quick-creates from repo URL only when requested explicitly", () => { - const inputs = expectCompleteResult(advanceCreateFlow( + const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x"), + createInitialFlowView(featureCreateRepoUrl), { quickCreate: true } )) @@ -75,9 +71,9 @@ describe("menu-create-shared", () => { }) it("prefills create values from inline CLI flags on the repo step", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x --force --mcp-playwright --no-up") + createInitialFlowView(`${featureCreateRepoUrl} --force --mcp-playwright --no-up`) )) expectFeatureRepoDefaults(view.values, defaultRoot) @@ -93,10 +89,10 @@ describe("menu-create-shared", () => { }) it("completes immediately when every remaining prompt was passed inline", () => { - const inputs = expectCompleteResult(advanceCreateFlow( + const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, createInitialFlowView( - "https://github.com/org/repo/tree/feature-x --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force" + `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force` ) )) @@ -127,14 +123,133 @@ describe("menu-create-shared", () => { }) it("uses server-provided projectsRoot in browser mode", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( { cwd: "/repo/packages/api", projectsRoot: "/home/dev/.docker-git" }, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") + createInitialFlowView(featureCreateRepoUrl) )) expect(view.values.outDir).toBe("/home/dev/.docker-git/org/repo") }) + + it("moves between remaining settings rows and clears the input buffer", () => { + const view = createFeatureRepoSettingsView(cwd) + const editingView = { ...view, buffer: "stale" } + const lastStep = resolveCreateFlowSteps(view.values).length - 1 + + const downView = moveCreateSettingsStep(editingView, "down") + + expect(downView).toEqual({ + ...view, + step: 2, + buffer: "" + }) + + const wrappedView = moveCreateSettingsStep(view, "up") + + expect(wrappedView).toEqual({ + ...view, + step: lastStep, + buffer: "" + }) + }) + + it("preserves settings navigation wraparound and buffer invariants", () => { + const view = createFeatureRepoSettingsView(cwd) + const lastStep = resolveCreateFlowSteps(view.values).length - 1 + + fc.assert( + fc.property(fc.integer({ min: 1, max: lastStep }), settingsDirectionArbitrary, (step, direction) => { + const next = moveCreateSettingsStep({ ...view, step, buffer: "draft" }, direction) + + expectCreateNavigationResult(next, expectedWrappedCreateNavigationStep(step, direction, lastStep), view.values) + }) + ) + }) + + it("maps create-mode steps to the matching display row when opening browser Settings", () => { + const createView = { + ...createFeatureRepoSettingsView(cwd), + step: 1, + values: { + ...createFeatureRepoSettingsView(cwd).values, + cpuLimit: "40%" + } + } + const displayView = createDisplayFlowView(createView) + + expect(resolveCreateFlowSteps(createView.values)[createView.step]).toBe("ramLimit") + expect(resolveCreateDisplaySteps()[displayView.step]).toBe("ramLimit") + expect(displayView.buffer).toBe(createView.buffer) + expect(displayView.values).toEqual(createView.values) + }) + + it("does not navigate settings from the repo URL step", () => { + expect(moveCreateSettingsStep(createInitialFlowView("https://github.com/org/repo"), "down")).toBeNull() + }) + + it("rejects settings navigation for every repo URL step buffer", () => { + fc.assert( + fc.property(fc.string(), (buffer) => { + expect(moveCreateSettingsStep(createInitialFlowView(buffer), "down")).toBeNull() + expect(moveCreateSettingsStep(createInitialFlowView(buffer), "up")).toBeNull() + }) + ) + }) + + it("resolves horizontal choices to buffer tokens for discrete settings rows", () => { + const view = createFeatureRepoDisplaySettingsView(cwd) + + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "gpu"), "left")).toBe("none") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "gpu"), "right")).toBe("all") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "runUp"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "runUp"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "mcpPlaywright"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "mcpPlaywright"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "force"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "force"), "right")).toBe("y") + }) + + it("does not resolve horizontal choices for free-text rows or unknown steps", () => { + const view = createFeatureRepoDisplaySettingsView(cwd) + const unknownStepView = { + ...view, + step: resolveCreateDisplaySteps().length + 1, + buffer: "draft" + } + + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "cpuLimit"), "left")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "ramLimit"), "right")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(unknownStepView, "left")).toBeNull() + }) + + it("continues after applying a navigated setting while earlier settings remain unresolved", () => { + const view = createFeatureRepoSettingsView(cwd) + const forceView = moveCreateSettingsStep(view, "up") + + if (forceView === null) { + throw new TypeError("expected settings navigation result") + } + + const next = expectCreateContinueView(advanceCreateFlow( + cwd, + { + ...forceView, + buffer: "y" + } + )) + + expect(next.values.force).toBe(true) + expect(next.step).toBe(resolveCreateFlowSteps(next.values).length - 1) + expect(resolveCreateFlowSteps(next.values)).toEqual([ + "repoUrl", + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright" + ]) + }) }) diff --git a/packages/app/tests/docker-git/menu-create.test.ts b/packages/app/tests/docker-git/menu-create.test.ts new file mode 100644 index 00000000..088eae95 --- /dev/null +++ b/packages/app/tests/docker-git/menu-create.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" +import { vi } from "vitest" + +import { resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import { handleCreateInput } from "../../src/docker-git/menu-create.js" +import type { ViewState } from "../../src/docker-git/menu-types.js" + +type CreateView = Extract + +const settingsValues = { + outDir: "/home/dev/.docker-git/org/repo", + repoRef: "feature-x", + repoUrl: "https://github.com/org/repo/tree/feature-x" +} + +const createSettingsView = (): CreateView => ({ + _tag: "Create", + mode: "create", + step: 1, + buffer: "30%", + inputError: null, + values: settingsValues +}) + +const wrapSettingsStep = (step: number, offset: -1 | 1, settingsStepCount: number): number => + ((step - 1 + offset + settingsStepCount) % settingsStepCount) + 1 + +const expectedSettingsStep = ( + step: number, + direction: "up" | "down", + settingsStepCount: number +): number => wrapSettingsStep(step, direction === "up" ? -1 : 1, settingsStepCount) + +const createArrowKey = ( + direction: "up" | "down" +): Parameters[1] => direction === "up" ? { upArrow: true } : { downArrow: true } + +const createContext = (): Parameters[3] & { + readonly setViewMock: ReturnType + readonly setMessageMock: ReturnType +} => { + const setViewMock = vi.fn() + const setMessageMock = vi.fn() + + return { + state: { cwd: "/workspace", activeDir: null }, + setView: setViewMock, + setMessage: setMessageMock, + runner: { runEffect: vi.fn() }, + setActiveDir: vi.fn(), + setViewMock, + setMessageMock + } +} + +describe("menu-create", () => { + it.effect("moves TUI settings selection with arrows and clears the input buffer", () => + Effect.sync(() => { + const context = createContext() + const view = createSettingsView() + + handleCreateInput("", { downArrow: true }, view, context) + + expect(context.setViewMock).toHaveBeenCalledWith({ + ...view, + step: 2, + buffer: "" + }) + expect(context.setMessageMock).toHaveBeenCalledWith(null) + })) + + it.effect("ignores TUI settings arrows before the Settings flow starts", () => + Effect.sync(() => { + const context = createContext() + + handleCreateInput( + "", + { downArrow: true }, + { _tag: "Create", mode: "create", step: 0, buffer: "", inputError: null, values: {} }, + context + ) + + expect(context.setViewMock).not.toHaveBeenCalled() + })) + + it.effect("preserves TUI settings arrow invariants for every settings row", () => + Effect.sync(() => { + const baseView = createSettingsView() + const settingsStepCount = resolveCreateFlowSteps(baseView.values).length - 1 + + fc.assert( + fc.property( + fc.integer({ min: 1, max: settingsStepCount }), + fc.constantFrom<"up" | "down">("up", "down"), + (step, direction) => { + const context = createContext() + const view = { ...baseView, step, buffer: "draft" } + + handleCreateInput("", createArrowKey(direction), view, context) + + expect(context.setViewMock).toHaveBeenCalledWith({ + ...view, + step: expectedSettingsStep(step, direction, settingsStepCount), + buffer: "" + }) + expect(context.setMessageMock).toHaveBeenCalledWith(null) + } + ) + ) + })) +}) diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index a95037f7..56bc0ab5 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -6,7 +6,7 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -// CHANGE: run the Grok CLI device-auth flow inside the auth container +// CHANGE: run the official 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 diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index c8da9828..2db92771 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -763,6 +763,7 @@ describe("renderDockerCompose", () => { expect(runtime).toBeDefined() expect(runtime?.mode).toBe(0o755) expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') + expect(runtime?.contents).not.toContain('\\${MCP_PLAYWRIGHT_ENABLE:-0}') expect(runtime?.contents).toContain("docker_git_wait_for_playwright_cdp()") expect(runtime?.contents).toContain("MCP_PLAYWRIGHT_ENABLE=0") })