From c758b50ea478cb39fb62601b9e17d63a63015270 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 1/3] fix(tui): confirm textarea with Ctrl+S and show hint in command bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Enter inside wizard textareas (goal/task/agent descriptions) adds a newline — there was no Linux-friendly way to confirm. Ctrl+Enter is indistinguishable from Enter in most Linux terminals, so the only confirm path was unusable. Ctrl+S now confirms any textarea step in FormWizard. It is surfaced in the bottom CommandBar, which switches to step-specific hints while a wizard is open (bold amber Ctrl+S for textareas, step-appropriate hints for text/select/multiselect). Co-Authored-By: Claude Opus 4.7 --- src/tui/App.tsx | 3 ++ src/tui/components/CommandBar.tsx | 65 +++++++++++++++++++++++++++++- src/tui/components/FormWizard.tsx | 16 +++++--- test/unit/tui/form-wizard.test.tsx | 48 +++++++++++++++++++++- 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4788d27..22b5be3 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -419,6 +419,7 @@ export function App({ const inputHook = useTextInput(); const inputValue = inputHook.value; const [wizardConfig, setWizardConfig] = useState(null); + const [wizardStepType, setWizardStepType] = useState<'text' | 'select' | 'textarea' | 'multiselect' | null>(null); /** Temp file paths for images pasted via Ctrl+I during task wizard */ const [pendingAttachments, setPendingAttachments] = useState([]); @@ -2521,6 +2522,7 @@ export function App({ height={feedH} onPasteImage={isPasteCapable ? handlePasteImage : undefined} onSuggestionSelected={wizardConfig.kind === 'agent' ? handleSuggestionSelected : undefined} + onStepChange={(step) => setWizardStepType(step ? step.type : null)} footerExtra={ pendingAttachments.length > 0 && isPasteCapable ? `\uD83D\uDCCE${pendingAttachments.length}` : undefined @@ -2627,6 +2629,7 @@ export function App({ width={W} hasSuggestions={showSuggestions} onboardingCompleted={initialState.onboardingCompleted} + wizardStepType={inputMode === 'wizard' ? wizardStepType : null} /> ); diff --git a/src/tui/components/CommandBar.tsx b/src/tui/components/CommandBar.tsx index 37ac85b..428ddd8 100644 --- a/src/tui/components/CommandBar.tsx +++ b/src/tui/components/CommandBar.tsx @@ -38,11 +38,13 @@ export interface CommandBarProps { width: number; hasSuggestions?: boolean; onboardingCompleted?: boolean; + /** When a wizard is open, render a wizard-specific hint row instead of the app nav hints. */ + wizardStepType?: 'text' | 'select' | 'textarea' | 'multiselect' | null; } export const CommandBar = React.memo(function CommandBar({ mode, value, completion, activeView, canRun, canNew, canApprove, canReject, canCancel, canDelete, canUndo, canEdit, canForceStop, canToggleAuto, autoActive, canPause, isPaused, canToggleShowAll, showAllActive, hasDetail, - itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, + itemCount, itemLabel, width, hasSuggestions, onboardingCompleted, wizardStepType, }: CommandBarProps) { if (mode === 'command') { const hintText = hasSuggestions @@ -64,6 +66,67 @@ export const CommandBar = React.memo(function CommandBar({ ); } + // Wizard mode — show step-specific hints (overrides the app nav hints) + if (wizardStepType) { + const hint = + wizardStepType === 'textarea' ? ( + <> + Ctrl+S + save description + {' '} + Enter + newline + {' '} + {'\u2190\u2191\u2192\u2193'} + navigate + {' '} + Esc + back + + ) : wizardStepType === 'select' ? ( + <> + {'\u2191\u2193'} + select + {' '} + Enter + confirm + {' '} + Esc + back + + ) : wizardStepType === 'multiselect' ? ( + <> + {'\u2191\u2193'} + move + {' '} + Space + toggle + {' '} + Enter + confirm + {' '} + Esc + back + + ) : ( + <> + Enter + confirm + {' '} + {'\u2190\u2192'} + move + {' '} + Esc + back + + ); + return ( + + {hint} + + ); + } + // Navigate mode — hotkey hints return ( diff --git a/src/tui/components/FormWizard.tsx b/src/tui/components/FormWizard.tsx index f095572..583dd33 100644 --- a/src/tui/components/FormWizard.tsx +++ b/src/tui/components/FormWizard.tsx @@ -55,6 +55,8 @@ export interface FormWizardProps { footerExtra?: string; /** Called when user selects a suggestion from a text step's suggestion list */ onSuggestionSelected?: (suggestionValue: string) => void; + /** Called whenever the active step changes, so parent can reflect step type in the bottom bar. */ + onStepChange?: (step: WizardStep | null) => void; } const CURSOR = '\u2588'; // █ (used by textarea render) @@ -98,8 +100,12 @@ function wordBoundaryForward(text: string, pos: number): number { return i; } -export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected }: FormWizardProps) { +export function FormWizard({ title, steps, onComplete, onCancel, width, height, onPasteImage, footerExtra, onSuggestionSelected, onStepChange }: FormWizardProps) { const [currentStep, setCurrentStep] = useState(0); + useEffect(() => { + onStepChange?.(steps[currentStep] ?? null); + return () => { onStepChange?.(null); }; + }, [currentStep, steps, onStepChange]); const [values, setValues] = useState>({}); // Unified text input for single-line text steps (replaces textInput + cursorPos). @@ -434,8 +440,8 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, } if (step.type === 'textarea') { - // Ctrl+Enter / Cmd+Enter: confirm textarea - if (key.return && (key.ctrl || key.meta)) { + // Ctrl+Enter / Cmd+Enter / Ctrl+S: confirm textarea + if ((key.return && (key.ctrl || key.meta)) || (key.ctrl && input === 's')) { const val = taLines.join('\n').trim(); if (step.required && !val) { setDirty(true); return; } if (validationError !== null) { @@ -728,7 +734,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, {step.label} {step.required && *} - {!step.required && (optional, {step.type === 'textarea' ? `${CMD_KEY}+Enter` : 'Enter'} to skip)} + {!step.required && (optional, {step.type === 'textarea' ? `Ctrl+S` : 'Enter'} to skip)} {/* Step description / guidance */} @@ -899,7 +905,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height, : step.type === 'multiselect' ? '\u2191\u2193 move Space toggle Enter confirm' : step.type === 'textarea' - ? `Enter newline ${CMD_KEY}+Enter confirm \u2190\u2191\u2192\u2193 navigate` + ? `Ctrl+S save Enter newline \u2190\u2191\u2192\u2193 navigate` : browsingSuggestions ? '\u2191\u2193 browse Enter select \u2191 back to input' : step.suggestions diff --git a/test/unit/tui/form-wizard.test.tsx b/test/unit/tui/form-wizard.test.tsx index 7b0a101..5b48e2a 100644 --- a/test/unit/tui/form-wizard.test.tsx +++ b/test/unit/tui/form-wizard.test.tsx @@ -109,7 +109,7 @@ describe('FormWizard textarea', () => { await delay(50); const output = lastFrame()!; expect(output).toContain('Enter newline'); - expect(output).toContain('Enter confirm'); + expect(output).toContain('Ctrl+S'); expect(output).toContain('navigate'); }); @@ -243,6 +243,52 @@ describe('FormWizard textarea', () => { expect(onComplete).not.toHaveBeenCalled(); }); + /* ── Ctrl+S confirms textarea ── */ + + it('Ctrl+S confirms textarea and calls onComplete', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('Hello world'); + await delay(50); + stdin.write('\x13'); // Ctrl+S to confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: 'Hello world' }); + }); + + it('Ctrl+S confirms empty optional textarea', async () => { + const onComplete = vi.fn(); + const onCancel = vi.fn(); + const { stdin } = render( + React.createElement(FormWizard, { + title: 'Test', + steps: makeTextareaSteps(), + onComplete, + onCancel, + width: 60, + height: 20, + }), + ); + await delay(50); + + stdin.write('\x13'); // Ctrl+S on empty optional field should confirm + await delay(50); + + expect(onComplete).toHaveBeenCalledWith({ body: '' }); + }); + /* ── Cursor navigation ── */ it('←→ arrows move cursor in textarea', async () => { From 44e83564030291ab69b7e39613bab1254523a8f9 Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 2/3] fix(tui): cursor at input start; show saved agent on reopen Two small UX fixes: * Empty single-line inputs rendered the cursor after the placeholder, making it look like the cursor was floating mid-line. Render cursor before placeholder so it sits at column 0, matching user expectation. * Reopening the edit-agent wizard immediately after saving showed the pre-save values because refreshAll() is async. Merge the returned agent into liveAgents synchronously in the save callback, and add liveAgents to launchEditAgentWizard's dependency array so the wizard uses the latest snapshot. Co-Authored-By: Claude Opus 4.7 --- src/tui/App.tsx | 5 ++++- src/tui/components/TextInput.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 22b5be3..5030c02 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -1010,7 +1010,7 @@ export function App({ targetId: agent.id, }); setInputMode('wizard'); - }, [liveTeams]); + }, [liveTeams, liveAgents]); const launchConfigWizard = useCallback(() => { setWizardConfig({ @@ -1123,6 +1123,9 @@ export function App({ onUpdateAgent(targetId, { name: fields.name, role: fields.role, model: fields.model, effort: fields.effort }).then( (agent) => { addMessage(`\u2713 Updated agent "${agent.name}"`, tuiColors.green); + // Optimistically merge returned agent into local state so reopening the + // editor immediately after save shows fresh values without waiting for refreshAll. + setLiveAgents((prev) => prev.map((a) => a.id === agent.id ? agent : a)); // Handle team change const teamOps: Promise[] = []; if (oldTeamId && oldTeamId !== newTeamId && onLeaveTeam) { diff --git a/src/tui/components/TextInput.tsx b/src/tui/components/TextInput.tsx index b786414..35d0e80 100644 --- a/src/tui/components/TextInput.tsx +++ b/src/tui/components/TextInput.tsx @@ -116,8 +116,8 @@ export function TextInput({ return ( {prefixStr && {prefixStr}} - {placeholder && {placeholder}} {showCursor && {CURSOR_CHAR}} + {placeholder && {placeholder}} ); } From 9c2caea109c1612593fd99aaade4eaa4c25cffef Mon Sep 17 00:00:00 2001 From: superbusinesstools <96004678+superbusinesstools@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:35:15 +0000 Subject: [PATCH 3/3] chore(scripts): add dev-link and dev-unlink helpers Convenience scripts for testing a local build against the globally-installed orch binary. dev-link.sh builds and npm-links the fork; dev-unlink.sh restores the global install. Co-Authored-By: Claude Opus 4.7 --- scripts/dev-link.sh | 8 ++++++++ scripts/dev-unlink.sh | 4 ++++ 2 files changed, 12 insertions(+) create mode 100755 scripts/dev-link.sh create mode 100755 scripts/dev-unlink.sh diff --git a/scripts/dev-link.sh b/scripts/dev-link.sh new file mode 100755 index 0000000..ea9f4ba --- /dev/null +++ b/scripts/dev-link.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/.." +echo "Building..." +npm run build +echo "Linking..." +npm link +echo "Done — 'orch' now points to local build." diff --git a/scripts/dev-unlink.sh b/scripts/dev-unlink.sh new file mode 100755 index 0000000..6122840 --- /dev/null +++ b/scripts/dev-unlink.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -e +npm unlink -g @oxgeneral/orch +echo "Done — 'orch' restored to globally installed version."