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
6 changes: 5 additions & 1 deletion frontend/terminal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,11 @@ function AppInner({config}: {config: FrontendConfig}): React.JSX.Element {
if (key.tab) {
const selected = commandHints[pickerIndex];
if (selected) {
setInput(selected + ' ');
// Complete to the selected command with no trailing space —
// the user can hit Enter immediately to run it, or keep
// typing to add args. The trailing space made it look like
// Tab was "committing" with a token, which broke the flow.
setInput(selected);
}
return;
}
Expand Down
30 changes: 24 additions & 6 deletions frontend/terminal/src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,29 @@ function MultilineTextInput({
const [cursorOffset, setCursorOffset] = useState(value.length);
const {internal_eventEmitter} = useStdin();
const lastSequenceRef = useRef('');
// Tracks the last value this component produced via onChange. If the
// incoming `value` prop diverges from this, the change came from outside
// (tab completion, history recall, programmatic clear) and we should
// move the cursor to the end — otherwise the cursor stays wherever the
// user had it, which puts subsequent keystrokes in the middle of the
// newly-completed text. See HKUDS/OpenHarness#183.
const lastInternalValueRef = useRef<string>(value);

useEffect(() => {
setCursorOffset((previous) => Math.min(previous, value.length));
if (value === lastInternalValueRef.current) {
// Self-authored update; cursor was already positioned by the
// handler that called onChange.
return;
}
lastInternalValueRef.current = value;
setCursorOffset(value.length);
}, [value]);

const commitValue = (nextValue: string): void => {
lastInternalValueRef.current = nextValue;
onChange(nextValue);
};

useEffect(() => {
if (!focus) {
return;
Expand Down Expand Up @@ -59,7 +77,7 @@ function MultilineTextInput({
if (key.shift) {
const nextValue = value.slice(0, cursorOffset) + '\n' + value.slice(cursorOffset);
setCursorOffset(cursorOffset + 1);
onChange(nextValue);
commitValue(nextValue);
return;
}
onSubmit?.(value);
Expand All @@ -82,7 +100,7 @@ function MultilineTextInput({
}
const nextValue = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset);
setCursorOffset(cursorOffset - 1);
onChange(nextValue);
commitValue(nextValue);
return;
}

Expand All @@ -96,15 +114,15 @@ function MultilineTextInput({
}
const nextValue = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset);
setCursorOffset(cursorOffset - 1);
onChange(nextValue);
commitValue(nextValue);
return;
}

if (cursorOffset >= value.length) {
return;
}
const nextValue = value.slice(0, cursorOffset) + value.slice(cursorOffset + 1);
onChange(nextValue);
commitValue(nextValue);
return;
}

Expand All @@ -114,7 +132,7 @@ function MultilineTextInput({

const nextValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset);
setCursorOffset(cursorOffset + input.length);
onChange(nextValue);
commitValue(nextValue);
},
{isActive: focus},
);
Expand Down
22 changes: 17 additions & 5 deletions src/openharness/commands/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,26 @@ class SlashCommand:
handler: CommandHandler
remote_invocable: bool = True
remote_admin_opt_in: bool = False
aliases: tuple[str, ...] = ()


class CommandRegistry:
"""Map slash commands to handlers."""

def __init__(self) -> None:
# Primary commands keyed by canonical name, plus aliases pointing at
# the same SlashCommand instance. We keep a separate set of canonical
# names so help/listing output doesn't duplicate aliased entries.
self._commands: dict[str, SlashCommand] = {}
self._canonical_names: list[str] = []

def register(self, command: SlashCommand) -> None:
"""Register a command."""
"""Register a command, plus any aliases pointing at the same handler."""
if command.name not in self._commands:
self._canonical_names.append(command.name)
self._commands[command.name] = command
for alias in command.aliases:
self._commands[alias] = command

def lookup(self, raw_input: str) -> tuple[SlashCommand, str] | None:
"""Parse a slash command and return its handler plus raw args."""
Expand All @@ -129,13 +138,14 @@ def lookup(self, raw_input: str) -> tuple[SlashCommand, str] | None:
def help_text(self) -> str:
"""Return a formatted summary of all registered commands."""
lines = ["Available commands:"]
for command in sorted(self._commands.values(), key=lambda item: item.name):
commands = [self._commands[name] for name in self._canonical_names]
for command in sorted(commands, key=lambda item: item.name):
lines.append(f"/{command.name:<12} {command.description}")
return "\n".join(lines)

def list_commands(self) -> list[SlashCommand]:
"""Return commands in registration order."""
return list(self._commands.values())
"""Return canonical commands in registration order (aliases omitted)."""
return [self._commands[name] for name in self._canonical_names]


def _run_git_command(cwd: str, *args: str) -> tuple[bool, str]:
Expand Down Expand Up @@ -1810,7 +1820,9 @@ async def _ship_handler(args: str, context: CommandContext) -> CommandResult:
)

registry.register(SlashCommand("help", "Show available commands", _help_handler))
registry.register(SlashCommand("exit", "Exit OpenHarness", _exit_handler))
registry.register(
SlashCommand("exit", "Exit OpenHarness", _exit_handler, aliases=("quit",))
)
registry.register(SlashCommand("clear", "Clear conversation history", _clear_handler))
registry.register(SlashCommand("version", "Show the installed OpenHarness version", _version_handler))
registry.register(SlashCommand("status", "Show session status", _status_handler))
Expand Down
24 changes: 24 additions & 0 deletions tests/test_commands/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,3 +841,27 @@ async def test_git_commands_report_repository_state(tmp_path: Path, monkeypatch)
commit_command, commit_args = registry.lookup("/commit initial commit")
commit_result = await commit_command.handler(commit_args, context)
assert "commit" in commit_result.message.lower()


def test_quit_is_alias_for_exit():
"""Regression for #183: /quit should resolve to the same handler as /exit."""
reg = create_default_command_registry()

exit_cmd, _ = reg.lookup("/exit")
quit_cmd, _ = reg.lookup("/quit")

assert exit_cmd is quit_cmd
assert quit_cmd.name == "exit"


def test_help_and_list_do_not_duplicate_aliases():
"""Aliases share a SlashCommand object; help/listing must not repeat it."""
reg = create_default_command_registry()

names = [cmd.name for cmd in reg.list_commands()]
assert names.count("exit") == 1
assert "quit" not in names # alias is resolvable via lookup, not listed

help_text = reg.help_text()
assert help_text.count("/exit ") == 1
assert "/quit" not in help_text