Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat: add cursor movement and editing shortcuts to text inputs by aidandaly24 · Pull Request #7 · aws/agentcore-cli · GitHub
Skip to content
Merged
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
153 changes: 64 additions & 89 deletions src/cli/tui/components/SecretInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ZodString } from 'zod';

/** Custom validation beyond schema - returns true if valid, or error message string if invalid */
Expand DownExpand Up@@ -76,99 +78,77 @@ export function SecretInput({
maskChar = '*',
revealChars = 0,
}: SecretInputProps) {
const [value, setValue] = useState(initialValue);
const [showValue, setShowValue] = useState(false);
const [showError, setShowError] = useState(false);
const [cursorVisible, setCursorVisible] = useState(true);

// Cursor blink effect
useEffect(() => {
const timer = setInterval(() => setCursorVisible(prev => !prev), 500);
return () => clearInterval(timer);
}, []);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput(
(input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();

// Empty value handling
if (!trimmedValue) {
if (onSkip) {
onSkip();
} else {
onCancel();
}
return;
}

// Validate non-empty value
const validationError = validateValue(trimmedValue, schema, customValidation);
if (!validationError) {
onSubmit(trimmedValue);
const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
if (!trimmed) {
if (onSkip) {
onSkip();
} else {
setShowError(true);
onCancel();
}
return;
}
const validationError = validateValue(trimmed, schema, customValidation);
if (!validationError) {
onSubmit(trimmed);
} else {
setShowError(true);
}
},
onCancel,
onChange: () => setShowError(false),
isActive,
});

// Toggle show/hide with Tab
// Handle Tab separately for show/hide toggle
useInput(
(_input, key) => {
if (key.tab) {
setShowValue(s => !s);
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
{ isActive }
);

// Generate masked display value
const getDisplayValue = (): string => {
const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

// Generate masked display value with cursor position
const getMaskedSegments = (): { before: string; after: string } => {
if (showValue) {
return value;
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

if (value.length === 0) {
return '';
return { before: '', after: '' };
}

// Full mask
if (revealChars === 0) {
return maskChar.repeat(value.length);
const cursorAtEnd = cursor === value.length;

// Editing (cursor not at end) - show actual value so user can see what they're doing
if (!cursorAtEnd) {
return { before: value.slice(0, cursor), after: value.slice(cursor) };
}

// Partial reveal (show first and last N chars)
if (value.length <= revealChars * 2) {
// Value too short for partial reveal, just mask all
return maskChar.repeat(value.length);
// Done typing (cursor at end) - show partial reveal if configured
if (revealChars > 0 && value.length > revealChars * 2) {
const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return { before: `${start}${maskChar.repeat(middleLength)}${end}`, after: '' };
}

const start = value.slice(0, revealChars);
const end = value.slice(-revealChars);
const middleLength = value.length - revealChars * 2;
return `${start}${maskChar.repeat(middleLength)}${end}`;
// Full mask (no reveal configured or value too short)
return { before: maskChar.repeat(value.length), after: '' };
};

const displayValue = getDisplayValue();
const { before, after } = getMaskedSegments();
const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
Expand All@@ -183,28 +163,23 @@ export function SecretInput({
</Box>
)}
<Box marginTop={1}>
<Text>
<Text color="cyan">&gt; </Text>
{displayValue ? (
<>
{displayValue}
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
</>
) : placeholder ? (
cursorVisible ? (
<>
<Text color="white">▋</Text>
<Text dimColor>{placeholder.slice(1)}</Text>
</>
) : (
<Text dimColor>{placeholder}</Text>
)
) : (
<Text color="white">{cursorVisible ? '▋' : ' '}</Text>
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Text>
<Text color="cyan">&gt; </Text>
{value ? (
<>
<Text>{before}</Text>
<Cursor />
<Text>{after}</Text>
</>
) : placeholder ? (
<>
<Cursor />
<Text dimColor>{placeholder}</Text>
</>
) : (
<Cursor />
)}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
{(showError || showInvalidMark) && validationErrorMsg && (
<Box marginTop={1}>
Expand Down
54 changes: 21 additions & 33 deletions src/cli/tui/components/TextInput.tsx
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { useTextInput } from '../hooks';
import { Cursor } from './Cursor';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { useState } from 'react';
import type { ZodString } from 'zod';

Expand DownExpand Up@@ -55,58 +56,45 @@ export function TextInput({
mask,
hideArrow = false,
}: TextInputProps) {
const [value, setValue] = useState(initialValue);
const [showError, setShowError] = useState(false);

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

useInput((input, key) => {
if (key.escape) {
onCancel();
return;
}

if (key.return) {
const trimmedValue = value.trim();
const hasValue = allowEmpty || trimmedValue;
const validationError = validateValue(trimmedValue, schema, customValidation);

const { value, cursor } = useTextInput({
initialValue,
onSubmit: val => {
const trimmed = val.trim();
const hasValue = allowEmpty || trimmed;
const validationError = validateValue(trimmed, schema, customValidation);
if (hasValue && !validationError) {
onSubmit(trimmedValue);
onSubmit(trimmed);
} else {
setShowError(true);
}
return;
}

if (key.backspace || key.delete) {
setValue(v => v.slice(0, -1));
setShowError(false);
return;
}

if (input && !key.ctrl && !key.meta) {
setValue(v => v + input);
setShowError(false);
}
},
onCancel,
});

const trimmed = value.trim();
const validationErrorMsg = validateValue(trimmed, schema, customValidation);
const isValid = !validationErrorMsg;

const hasInput = trimmed.length > 0;
const hasValidation = Boolean(schema ?? customValidation);
const showCheckmark = hasInput && isValid && hasValidation;
const showInvalidMark = hasInput && !isValid && hasValidation;

const displayValue = mask ? mask.repeat(value.length) : value;
// Display with cursor position
const beforeCursor = mask ? mask.repeat(cursor) : value.slice(0, cursor);
const afterCursor = mask ? mask.repeat(value.length - cursor) : value.slice(cursor);

return (
<Box flexDirection="column">
{prompt && <Text>{prompt}</Text>}
<Box>
{!hideArrow && <Text color="cyan">&gt; </Text>}
<Text>{displayValue ?? (placeholder ? <Text dimColor>{placeholder}</Text> : null)}</Text>
<Text>{beforeCursor}</Text>
<Cursor />
<Text>{afterCursor}</Text>
{!value && placeholder && <Text dimColor>{placeholder}</Text>}
{showCheckmark && <Text color="green"> ✓</Text>}
{showInvalidMark && <Text color="red"> ✗</Text>}
</Box>
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,3 +10,5 @@ export { useAvailableAgents, useCreateGateway, useCreateMcpTool, useExistingGate
export { useDevServer } from './useDevServer';
export { useProject } from './useProject';
export type { UseProjectResult, ProjectContext } from './useProject';
export { useTextInput } from './useTextInput';
export type { UseTextInputOptions, UseTextInputResult } from './useTextInput';
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useListNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,8 @@ interface UseListNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Optional hotkey extractor - return hotkeys for an item */
getHotkeys?: (item: T) => string[] | undefined;
/** Callback when a hotkey matches an item */
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useListNavigation<T>({
onSelect,
onExit,
isActive = true,
textInputActive = false,
getHotkeys,
onHotkeySelect,
isDisabled,
Expand DownExpand Up@@ -87,13 +90,13 @@ export function useListNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, -1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setSelectedIndex(i => findNextIndex(i, 1));
return;
}
Expand Down
9 changes: 6 additions & 3 deletions src/cli/tui/hooks/useMultiSelectNavigation.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,8 @@ interface UseMultiSelectNavigationOptions<T> {
onExit?: () => void;
/** Whether navigation is active (default: true) */
isActive?: boolean;
/** Whether a text input is currently focused - disables j/k keys (default: false) */
textInputActive?: boolean;
/** Whether to require at least one selection before confirm (default: false) */
requireSelection?: boolean;
}
Expand DownExpand Up@@ -52,6 +54,7 @@ export function useMultiSelectNavigation<T>({
onConfirm,
onExit,
isActive = true,
textInputActive = false,
requireSelection = false,
}: UseMultiSelectNavigationOptions<T>): UseMultiSelectNavigationResult {
const [cursorIndex, setCursorIndex] = useState(0);
Expand DownExpand Up@@ -85,13 +88,13 @@ export function useMultiSelectNavigation<T>({
return;
}

// Handle arrow navigation
if (key.upArrow && items.length > 0) {
// Handle arrow navigation (and j/k when no text input)
if ((key.upArrow || (!textInputActive && input === 'k')) && items.length > 0) {
setCursorIndex(i => Math.max(0, i - 1));
return;
}

if (key.downArrow && items.length > 0) {
if ((key.downArrow || (!textInputActive && input === 'j')) && items.length > 0) {
setCursorIndex(i => Math.min(items.length - 1, i + 1));
return;
}
Expand Down
Loading