Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/dev-link.sh
Original file line number Diff line number Diff line change
@@ -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."
4 changes: 4 additions & 0 deletions scripts/dev-unlink.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
npm unlink -g @oxgeneral/orch
echo "Done — 'orch' restored to globally installed version."
8 changes: 7 additions & 1 deletion src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ export function App({
const inputHook = useTextInput();
const inputValue = inputHook.value;
const [wizardConfig, setWizardConfig] = useState<WizardConfig | null>(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<string[]>([]);

Expand Down Expand Up @@ -1009,7 +1010,7 @@ export function App({
targetId: agent.id,
});
setInputMode('wizard');
}, [liveTeams]);
}, [liveTeams, liveAgents]);

const launchConfigWizard = useCallback(() => {
setWizardConfig({
Expand Down Expand Up @@ -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<unknown>[] = [];
if (oldTeamId && oldTeamId !== newTeamId && onLeaveTeam) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2627,6 +2632,7 @@ export function App({
width={W}
hasSuggestions={showSuggestions}
onboardingCompleted={initialState.onboardingCompleted}
wizardStepType={inputMode === 'wizard' ? wizardStepType : null}
/>
</Box>
);
Expand Down
65 changes: 64 additions & 1 deletion src/tui/components/CommandBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' ? (
<>
<Text bold color={tuiColors.amber}>Ctrl+S</Text>
<Text color={tuiColors.dim}> save description</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Enter</Text>
<Text color={tuiColors.dim}> newline</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>{'\u2190\u2191\u2192\u2193'}</Text>
<Text color={tuiColors.dim}> navigate</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Esc</Text>
<Text color={tuiColors.dim}> back</Text>
</>
) : wizardStepType === 'select' ? (
<>
<Text bold color={tuiColors.gray}>{'\u2191\u2193'}</Text>
<Text color={tuiColors.dim}> select</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Enter</Text>
<Text color={tuiColors.dim}> confirm</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Esc</Text>
<Text color={tuiColors.dim}> back</Text>
</>
) : wizardStepType === 'multiselect' ? (
<>
<Text bold color={tuiColors.gray}>{'\u2191\u2193'}</Text>
<Text color={tuiColors.dim}> move</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Space</Text>
<Text color={tuiColors.dim}> toggle</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Enter</Text>
<Text color={tuiColors.dim}> confirm</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Esc</Text>
<Text color={tuiColors.dim}> back</Text>
</>
) : (
<>
<Text bold color={tuiColors.gray}>Enter</Text>
<Text color={tuiColors.dim}> confirm</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>{'\u2190\u2192'}</Text>
<Text color={tuiColors.dim}> move</Text>
<Text color={tuiColors.dim}>{' '}</Text>
<Text bold color={tuiColors.gray}>Esc</Text>
<Text color={tuiColors.dim}> back</Text>
</>
);
return (
<Box paddingX={2} justifyContent="space-between" width={width}>
<Text>{hint}</Text>
</Box>
);
}

// Navigate mode — hotkey hints
return (
<Box paddingX={2} justifyContent="space-between" width={width}>
Expand Down
16 changes: 11 additions & 5 deletions src/tui/components/FormWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<Record<string, string>>({});

// Unified text input for single-line text steps (replaces textInput + cursorPos).
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -728,7 +734,7 @@ export function FormWizard({ title, steps, onComplete, onCancel, width, height,
<Box marginTop={0}>
<Text color={tuiColors.white} bold> {step.label}</Text>
{step.required && <Text color={tuiColors.red}> *</Text>}
{!step.required && <Text color={tuiColors.dim}> (optional, {step.type === 'textarea' ? `${CMD_KEY}+Enter` : 'Enter'} to skip)</Text>}
{!step.required && <Text color={tuiColors.dim}> (optional, {step.type === 'textarea' ? `Ctrl+S` : 'Enter'} to skip)</Text>}
</Box>

{/* Step description / guidance */}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/tui/components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export function TextInput({
return (
<Box borderStyle={borderStyle} borderColor={borderColor}>
{prefixStr && <Text color={prefixColor}>{prefixStr}</Text>}
{placeholder && <Text color={placeholderColor}>{placeholder}</Text>}
{showCursor && <Text color={cursorColor}>{CURSOR_CHAR}</Text>}
{placeholder && <Text color={placeholderColor}>{placeholder}</Text>}
</Box>
);
}
Expand Down
48 changes: 47 additions & 1 deletion test/unit/tui/form-wizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading