From 77f27a60b56cd9ffa1d1e4b127093a0fc248c431 Mon Sep 17 00:00:00 2001 From: lyx-tec Date: Sat, 6 Jun 2026 11:28:20 +0800 Subject: [PATCH 1/3] feat: term block command config dialog with Save & Restart and error state display - Add CommandConfigModal dialog (Command, Run on startup, Clear on start, Env vars) - Replace 'Run On Startup' submenu with 'Configure Command...' menu item - Inject command as keystrokes into interactive shell (not -c mode) - Write cmd:lasterror meta key on non-zero exit, clear on exit 0 - Show red error icon in block header when cmd:lasterror is set - Add MetaKey_CmdLastError to MetaTSType / metaconsts.go --- frontend/app/modals/modalregistry.tsx | 2 + .../app/view/term/CommandConfigModal.scss | 2 + frontend/app/view/term/CommandConfigModal.tsx | 170 ++++++++++++++++++ frontend/app/view/term/term-model.ts | 41 ++--- frontend/types/gotypes.d.ts | 1 + .../term-block-command-config/.openspec.yaml | 2 + .../term-block-command-config/design.md | 59 ++++++ .../term-block-command-config/proposal.md | 28 +++ .../specs/command-config-modal/spec.md | 72 ++++++++ .../specs/command-error-state/spec.md | 53 ++++++ .../specs/save-and-restart/spec.md | 39 ++++ .../term-block-command-config/tasks.md | 40 +++++ pkg/blockcontroller/shellcontroller.go | 29 +++ pkg/waveobj/metaconsts.go | 1 + pkg/waveobj/wtypemeta.go | 1 + 15 files changed, 514 insertions(+), 26 deletions(-) create mode 100644 frontend/app/view/term/CommandConfigModal.scss create mode 100644 frontend/app/view/term/CommandConfigModal.tsx create mode 100644 openspec/changes/term-block-command-config/.openspec.yaml create mode 100644 openspec/changes/term-block-command-config/design.md create mode 100644 openspec/changes/term-block-command-config/proposal.md create mode 100644 openspec/changes/term-block-command-config/specs/command-config-modal/spec.md create mode 100644 openspec/changes/term-block-command-config/specs/command-error-state/spec.md create mode 100644 openspec/changes/term-block-command-config/specs/save-and-restart/spec.md create mode 100644 openspec/changes/term-block-command-config/tasks.md diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index 88d19e732c..da0bb7b22e 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -1,6 +1,7 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { CommandConfigModal } from "@/app/view/term/CommandConfigModal"; import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; @@ -15,6 +16,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [UpgradeOnboardingModal.displayName || "UpgradeOnboardingModal"]: UpgradeOnboardingModal, [UpgradeOnboardingPatch.displayName || "UpgradeOnboardingPatch"]: UpgradeOnboardingPatch, [UserInputModal.displayName || "UserInputModal"]: UserInputModal, + [CommandConfigModal.displayName || "CommandConfigModal"]: CommandConfigModal, [AboutModal.displayName || "AboutModal"]: AboutModal, [MessageModal.displayName || "MessageModal"]: MessageModal, [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, diff --git a/frontend/app/view/term/CommandConfigModal.scss b/frontend/app/view/term/CommandConfigModal.scss new file mode 100644 index 0000000000..70808bbcc9 --- /dev/null +++ b/frontend/app/view/term/CommandConfigModal.scss @@ -0,0 +1,2 @@ +// Configure Command Modal styles +// Uses Tailwind classes for most styling; this file for any overrides if needed diff --git a/frontend/app/view/term/CommandConfigModal.tsx b/frontend/app/view/term/CommandConfigModal.tsx new file mode 100644 index 0000000000..77614337cd --- /dev/null +++ b/frontend/app/view/term/CommandConfigModal.tsx @@ -0,0 +1,170 @@ +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { WOS, atoms, globalStore } from "@/store/global"; +import * as keyutil from "@/util/keyutil"; +import { fireAndForget } from "@/util/util"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import "./CommandConfigModal.scss"; + +interface CommandConfigModalProps { + blockId: string; +} + +function parseEnvText(text: string): { valid: boolean; map: Record; error?: string } { + const lines = text.split("\n"); + const map: Record = {}; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line === "" || line.startsWith("#")) { + continue; + } + const eqIdx = line.indexOf("="); + if (eqIdx <= 0) { + return { valid: false, map, error: `Invalid format on line ${i + 1}: use KEY=VALUE` }; + } + const key = line.substring(0, eqIdx).trim(); + const value = line.substring(eqIdx + 1).trim(); + map[key] = value; + } + return { valid: true, map }; +} + +function envMapToText(envMap: Record | undefined | null): string { + if (!envMap) return ""; + return Object.entries(envMap) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); +} + +const CommandConfigModal = (props: CommandConfigModalProps) => { + const { blockId } = props; + const blockAtom = useMemo(() => WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)), [blockId]); + const blockData = globalStore.get(blockAtom); + + const [command, setCommand] = useState(blockData?.meta?.["cmd"] ?? ""); + const [runOnStart, setRunOnStart] = useState(blockData?.meta?.["cmd:runonstart"] ?? true); + const [clearOnStart, setClearOnStart] = useState(blockData?.meta?.["cmd:clearonstart"] ?? false); + const [envText, setEnvText] = useState(envMapToText(blockData?.meta?.["cmd:env"])); + const [saveDisabled, setSaveDisabled] = useState(false); + const [validationError, setValidationError] = useState(null); + + const handleSaveAndRestart = useCallback(() => { + const parsed = parseEnvText(envText); + if (!parsed.valid) { + setValidationError(parsed.error ?? "Invalid environment variables"); + return; + } + setValidationError(null); + setSaveDisabled(true); + const meta: Record = { + "cmd": command || null, + "cmd:runonstart": !!runOnStart, + "cmd:clearonstart": !!clearOnStart, + "cmd:env": parsed.map, + "cmd:lasterror": null, + }; + fireAndForget(async () => { + try { + await RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("block", blockId), + meta, + }); + await RpcApi.ControllerDestroyCommand(TabRpcClient, blockId); + await RpcApi.ControllerResyncCommand(TabRpcClient, { + tabid: globalStore.get(atoms.staticTabId), + blockid: blockId, + forcerestart: true, + }); + } catch (e) { + console.error("Save & Restart failed:", e); + } + modalsModel.popModal(); + }); + }, [blockId, command, runOnStart, clearOnStart, envText]); + + const handleCancel = useCallback(() => { + modalsModel.popModal(); + }, []); + + const handleKeyDown = useCallback( + (waveEvent: WaveKeyboardEvent): boolean => { + if (keyutil.checkKeyPressed(waveEvent, "Escape")) { + handleCancel(); + return true; + } + return false; + }, + [handleCancel] + ); + + return ( + +
Configure Command
+
+
+ +