Skip to content

Commit 354877c

Browse files
Implement /new command to start a new conversation (#325)
* Implement /new command to start a new conversation This adds the /new command which allows users to start a new conversation from within the CLI without having to close and reopen it. The /new command: - Clears all dynamically added widgets from the main display - Generates a new conversation ID - Resets the conversation runner - Updates the splash screen with the new conversation ID - Shows a notification confirming the new conversation started If a conversation is currently running, the command shows a warning and does not proceed. Fixes #324 Co-authored-by: openhands <openhands@all-hands.dev> * Change /new command error severity from warning to error Co-authored-by: openhands <openhands@all-hands.dev> * Refactor: Extract get_conversation_text utility function Extract the conversation text formatting logic into a shared utility function get_conversation_text() in splash.py. This function is now used by both get_splash_content() and _handle_new_command() to ensure consistent formatting of the conversation initialization message. This addresses the code review feedback about sharing implementation between the /new command and the actual initialization code. --------- Co-authored-by: openhands <openhands@all-hands.dev>
1 parent ab8e4c7 commit 354877c

File tree

4 files changed

+228
-6
lines changed

4 files changed

+228
-6
lines changed

openhands_cli/tui/content/splash.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@
55
from openhands_cli.version_check import check_for_updates
66

77

8+
def get_conversation_text(conversation_id: str, *, theme: Theme) -> str:
9+
"""Get the formatted conversation initialization text.
10+
11+
Args:
12+
conversation_id: The conversation ID to display
13+
theme: Theme to use for colors
14+
15+
Returns:
16+
Formatted string with conversation initialization message
17+
"""
18+
return f"[{theme.accent}]Initialized conversation[/] {conversation_id}"
19+
20+
821
def get_openhands_banner() -> str:
922
"""Get the OpenHands ASCII art banner."""
1023
# ASCII art with consistent line lengths for proper alignment
@@ -35,7 +48,6 @@ def get_splash_content(conversation_id: str, *, theme: Theme) -> dict:
3548
"""
3649
# Use theme colors
3750
primary_color = theme.primary
38-
accent_color = theme.accent
3951

4052
# Use Rich markup for colored banner (apply color to each line)
4153
banner_lines = get_openhands_banner().split("\n")
@@ -50,9 +62,7 @@ def get_splash_content(conversation_id: str, *, theme: Theme) -> dict:
5062
"banner": banner,
5163
"version": f"OpenHands CLI v{version_info.current_version}",
5264
"status_text": "All set up!",
53-
"conversation_text": (
54-
f"[{accent_color}]Initialized conversation[/] {conversation_id}"
55-
),
65+
"conversation_text": get_conversation_text(conversation_id, theme=theme),
5666
"conversation_id": conversation_id,
5767
"instructions_header": f"[{primary_color}]What do you want to build?[/]",
5868
"instructions": [

openhands_cli/tui/core/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# Available commands with descriptions after the command
1515
COMMANDS = [
1616
DropdownItem(main="/help - Display available commands"),
17+
DropdownItem(main="/new - Start a new conversation"),
1718
DropdownItem(main="/confirm - Configure confirmation settings"),
1819
DropdownItem(main="/condense - Condense conversation history"),
1920
DropdownItem(main="/feedback - Send anonymous feedback about CLI"),
@@ -65,6 +66,7 @@ def show_help(main_display: VerticalScroll) -> None:
6566
[dim]Available commands:[/dim]
6667
6768
[{secondary}]/help[/{secondary}] - Display available commands
69+
[{secondary}]/new[/{secondary}] - Start a new conversation
6870
[{secondary}]/confirm[/{secondary}] - Configure confirmation settings
6971
[{secondary}]/condense[/{secondary}] - Condense conversation history
7072
[{secondary}]/feedback[/{secondary}] - Send anonymous feedback about CLI

openhands_cli/tui/textual_app.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
)
3131
from openhands.sdk.security.risk import SecurityRisk
3232
from openhands_cli.theme import OPENHANDS_THEME
33-
from openhands_cli.tui.content.splash import get_splash_content
33+
from openhands_cli.tui.content.splash import get_conversation_text, get_splash_content
3434
from openhands_cli.tui.core.commands import is_valid_command, show_help
3535
from openhands_cli.tui.core.conversation_runner import ConversationRunner
3636
from openhands_cli.tui.modals import SettingsScreen
@@ -422,6 +422,8 @@ def _handle_command(self, command: str) -> None:
422422

423423
if command == "/help":
424424
show_help(self.main_display)
425+
elif command == "/new":
426+
self._handle_new_command()
425427
elif command == "/confirm":
426428
self._handle_confirm_command()
427429
elif command == "/condense":
@@ -666,6 +668,59 @@ def _handle_feedback_command(self) -> None:
666668
severity="information",
667669
)
668670

671+
def _handle_new_command(self) -> None:
672+
"""Handle the /new command to start a new conversation.
673+
674+
This clears the terminal UI and starts a fresh conversation runner.
675+
"""
676+
# Check if a conversation is currently running
677+
if self.conversation_runner and self.conversation_runner.is_running:
678+
self.notify(
679+
title="New Conversation Error",
680+
message="Cannot start a new conversation while one is running. "
681+
"Please wait for the current conversation to complete or pause it.",
682+
severity="error",
683+
)
684+
return
685+
686+
# Generate a new conversation ID
687+
self.conversation_id = uuid.uuid4()
688+
689+
# Reset the conversation runner
690+
self.conversation_runner = None
691+
692+
# Remove any existing confirmation panel
693+
if self.confirmation_panel:
694+
self.confirmation_panel.remove()
695+
self.confirmation_panel = None
696+
697+
# Clear all dynamically added widgets from main_display
698+
# Keep only the splash widgets (those with IDs starting with "splash_")
699+
widgets_to_remove = []
700+
for widget in self.main_display.children:
701+
widget_id = widget.id or ""
702+
if not widget_id.startswith("splash_"):
703+
widgets_to_remove.append(widget)
704+
705+
for widget in widgets_to_remove:
706+
widget.remove()
707+
708+
# Update the splash conversation widget with the new conversation ID
709+
splash_conversation = self.query_one("#splash_conversation", Static)
710+
splash_conversation.update(
711+
get_conversation_text(self.conversation_id.hex, theme=OPENHANDS_THEME)
712+
)
713+
714+
# Scroll to top to show the splash screen
715+
self.main_display.scroll_home(animate=False)
716+
717+
# Notify user
718+
self.notify(
719+
title="New Conversation",
720+
message="Started a new conversation",
721+
severity="information",
722+
)
723+
669724
def _handle_confirmation_request(
670725
self, pending_actions: list[ActionEvent]
671726
) -> UserConfirmation:

tests/tui/test_commands.py

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class TestCommands:
2323
def test_commands_list_structure(self):
2424
"""Test that COMMANDS list has correct structure."""
2525
assert isinstance(COMMANDS, list)
26-
assert len(COMMANDS) == 5
26+
assert len(COMMANDS) == 6
2727

2828
# Check that all items are DropdownItems
2929
for command in COMMANDS:
@@ -36,6 +36,7 @@ def test_commands_list_structure(self):
3636
"expected_command,expected_description",
3737
[
3838
("/help", "Display available commands"),
39+
("/new", "Start a new conversation"),
3940
("/confirm", "Configure confirmation settings"),
4041
("/condense", "Condense conversation history"),
4142
("/feedback", "Send anonymous feedback about CLI"),
@@ -72,11 +73,13 @@ def test_show_help_function_signature(self):
7273
[
7374
"OpenHands CLI Help",
7475
"/help",
76+
"/new",
7577
"/confirm",
7678
"/condense",
7779
"/feedback",
7880
"/exit",
7981
"Display available commands",
82+
"Start a new conversation",
8083
"Configure confirmation settings",
8184
"Condense conversation history",
8285
"Send anonymous feedback about CLI",
@@ -145,6 +148,7 @@ def test_show_help_formatting(self):
145148
"cmd,expected",
146149
[
147150
("/help", True),
151+
("/new", True),
148152
("/confirm", True),
149153
("/condense", True),
150154
("/feedback", True),
@@ -426,3 +430,154 @@ async def test_feedback_command_opens_browser(
426430
message="Opening feedback form in your browser...",
427431
severity="information",
428432
)
433+
434+
@pytest.mark.asyncio
435+
async def test_new_command_starts_new_conversation(
436+
self,
437+
monkeypatch: pytest.MonkeyPatch,
438+
) -> None:
439+
"""`/new` should start a new conversation with a new ID."""
440+
monkeypatch.setattr(
441+
SettingsScreen,
442+
"is_initial_setup_required",
443+
lambda: False,
444+
)
445+
446+
app = OpenHandsApp(exit_confirmation=False)
447+
448+
async with app.run_test() as pilot:
449+
oh_app = cast(OpenHandsApp, pilot.app)
450+
451+
# Store the original conversation ID
452+
original_conversation_id = oh_app.conversation_id
453+
454+
# Mock notify to verify notification is shown
455+
notify_mock = mock.MagicMock()
456+
oh_app.notify = notify_mock
457+
458+
oh_app._handle_command("/new")
459+
460+
# Verify a new conversation ID was generated
461+
assert oh_app.conversation_id != original_conversation_id
462+
463+
# Verify conversation runner was reset
464+
assert oh_app.conversation_runner is None
465+
466+
# Verify notification was shown
467+
notify_mock.assert_called_once_with(
468+
title="New Conversation",
469+
message="Started a new conversation",
470+
severity="information",
471+
)
472+
473+
@pytest.mark.asyncio
474+
async def test_new_command_blocked_when_conversation_running(
475+
self,
476+
monkeypatch: pytest.MonkeyPatch,
477+
) -> None:
478+
"""`/new` should show warning when a conversation is running."""
479+
monkeypatch.setattr(
480+
SettingsScreen,
481+
"is_initial_setup_required",
482+
lambda: False,
483+
)
484+
485+
app = OpenHandsApp(exit_confirmation=False)
486+
487+
async with app.run_test() as pilot:
488+
oh_app = cast(OpenHandsApp, pilot.app)
489+
490+
# Create a mock conversation runner that is running
491+
dummy_runner = mock.MagicMock()
492+
dummy_runner.is_running = True
493+
oh_app.conversation_runner = dummy_runner
494+
495+
# Store the original conversation ID
496+
original_conversation_id = oh_app.conversation_id
497+
498+
# Mock notify to verify warning is shown
499+
notify_mock = mock.MagicMock()
500+
oh_app.notify = notify_mock
501+
502+
oh_app._handle_command("/new")
503+
504+
# Verify conversation ID was NOT changed
505+
assert oh_app.conversation_id == original_conversation_id
506+
507+
# Verify error notification was shown
508+
notify_mock.assert_called_once()
509+
call_args = notify_mock.call_args
510+
assert call_args[1]["title"] == "New Conversation Error"
511+
assert call_args[1]["severity"] == "error"
512+
513+
@pytest.mark.asyncio
514+
async def test_new_command_clears_dynamically_added_widgets(
515+
self,
516+
monkeypatch: pytest.MonkeyPatch,
517+
) -> None:
518+
"""`/new` should clear dynamically added widgets but keep splash widgets."""
519+
from textual.widgets import Static
520+
521+
monkeypatch.setattr(
522+
SettingsScreen,
523+
"is_initial_setup_required",
524+
lambda: False,
525+
)
526+
527+
app = OpenHandsApp(exit_confirmation=False)
528+
529+
async with app.run_test() as pilot:
530+
oh_app = cast(OpenHandsApp, pilot.app)
531+
532+
# Add a dynamic widget to main_display (simulating conversation content)
533+
dynamic_widget = Static("Test message", classes="user-message")
534+
oh_app.main_display.mount(dynamic_widget)
535+
await pilot.pause()
536+
537+
# Verify the widget was added
538+
assert dynamic_widget in oh_app.main_display.children
539+
540+
# Mock notify
541+
notify_mock = mock.MagicMock()
542+
oh_app.notify = notify_mock
543+
544+
oh_app._handle_command("/new")
545+
await pilot.pause()
546+
547+
# Verify dynamic widget was removed
548+
assert dynamic_widget not in oh_app.main_display.children
549+
550+
# Verify splash widgets still exist
551+
splash_banner = oh_app.query_one("#splash_banner", Static)
552+
assert splash_banner is not None
553+
554+
@pytest.mark.asyncio
555+
async def test_new_command_updates_splash_conversation_id(
556+
self,
557+
monkeypatch: pytest.MonkeyPatch,
558+
) -> None:
559+
"""`/new` should update the splash conversation widget with new ID."""
560+
from textual.widgets import Static
561+
562+
monkeypatch.setattr(
563+
SettingsScreen,
564+
"is_initial_setup_required",
565+
lambda: False,
566+
)
567+
568+
app = OpenHandsApp(exit_confirmation=False)
569+
570+
async with app.run_test() as pilot:
571+
oh_app = cast(OpenHandsApp, pilot.app)
572+
573+
# Mock notify
574+
notify_mock = mock.MagicMock()
575+
oh_app.notify = notify_mock
576+
577+
oh_app._handle_command("/new")
578+
await pilot.pause()
579+
580+
# Verify splash conversation widget contains the new conversation ID
581+
splash_conversation = oh_app.query_one("#splash_conversation", Static)
582+
# The content should contain the new conversation ID hex
583+
assert oh_app.conversation_id.hex in str(splash_conversation.content)

0 commit comments

Comments
 (0)