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." diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 4788d27..5030c02 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([]); @@ -1009,7 +1010,7 @@ export function App({ targetId: agent.id, }); setInputMode('wizard'); - }, [liveTeams]); + }, [liveTeams, liveAgents]); const launchConfigWizard = useCallback(() => { setWizardConfig({ @@ -1122,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) { @@ -2521,6 +2525,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 +2632,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/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}} ); } 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 () => {