mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): capture input typed before TUI startup (#4684)
`dcode` now preserves text typed immediately after launch and places it in the chat input when the TUI appears. --- When `dcode` starts, terminal input typed before the first TUI frame is already queued for Textual. The default screen initially focused the chat scroll container because it appears before the input in the widget tree, so those queued keys were delivered to the scroll container and disappeared. The main chat screen now selects the chat input during Textual's initial autofocus pass. The selector is scoped to the main screen so modal screens retain their normal first-focusable control and keyboard navigation.
This commit is contained in:
@@ -29,13 +29,14 @@ from textual.css.query import NoMatches
|
||||
from textual.events import Click
|
||||
from textual.message import Message
|
||||
from textual.notifications import Notification as _Notification, Notify as _Notify
|
||||
from textual.screen import ModalScreen
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.style import Style as TStyle
|
||||
from textual.theme import Theme
|
||||
from textual.widgets import Header, Static
|
||||
from textual.widgets._toast import ( # noqa: PLC2701
|
||||
Toast as _Toast, # for Toast click routing
|
||||
)
|
||||
from typing_extensions import override
|
||||
|
||||
# Applied as an import-time side effect; must come before any App is created.
|
||||
from deepagents_code import (
|
||||
@@ -1911,6 +1912,29 @@ class _ChatScroll(VerticalScroll):
|
||||
return self.max_scroll_y > 0
|
||||
|
||||
|
||||
class _MainScreen(Screen[None]):
|
||||
"""Default screen containing the main chat interface.
|
||||
|
||||
Exists so `AUTO_FOCUS` can be scoped to this screen rather than the `App`.
|
||||
An App-level `AUTO_FOCUS` is the fallback for *every* screen, so a
|
||||
`"#chat-input"` selector there resolves to nothing on modals (whose DOM has
|
||||
no such widget) and leaves them opening with no focused control. Keeping it
|
||||
on the main screen lets modals retain Textual's default first-focusable
|
||||
behavior.
|
||||
"""
|
||||
|
||||
AUTO_FOCUS = "#chat-input"
|
||||
"""Focus the chat text area whenever this screen needs a focus target.
|
||||
|
||||
Overrides Textual's default `"*"`, which would focus the earlier-composed,
|
||||
still-focusable `_ChatScroll` (`#chat`) instead. The first automatic focus
|
||||
pass runs before the nested chat text area is mounted, so
|
||||
`DeepAgentsApp.on_mount` applies the startup focus synchronously once the
|
||||
input exists. This selector remains the focus target when the screen later
|
||||
resumes without a focused widget.
|
||||
"""
|
||||
|
||||
|
||||
class DeepAgentsApp(App):
|
||||
"""Main Textual application for deepagents-code."""
|
||||
|
||||
@@ -2001,6 +2025,11 @@ class DeepAgentsApp(App):
|
||||
"""App-level keybindings for interrupt, quit, toggles, and approval menu
|
||||
navigation."""
|
||||
|
||||
@override
|
||||
def get_default_screen(self) -> Screen[None]:
|
||||
"""Return the main screen with chat-specific startup focus."""
|
||||
return _MainScreen(id="_default")
|
||||
|
||||
class ServerReady(Message):
|
||||
"""Posted by the background server-startup worker on success."""
|
||||
|
||||
@@ -3043,8 +3072,12 @@ class DeepAgentsApp(App):
|
||||
if self._auto_approve:
|
||||
self._status_bar.set_auto_approve(enabled=True)
|
||||
|
||||
# Focus the input immediately so the cursor is visible on first paint
|
||||
self._chat_input.focus_input()
|
||||
# `Widget.focus()` defers the actual focus change by posting a callback.
|
||||
# Terminal keys may already be ahead of that callback in the app queue,
|
||||
# so set focus synchronously while startup is still handling Mount.
|
||||
input_widget = self._chat_input.input_widget
|
||||
if input_widget is not None:
|
||||
self.screen.set_focus(input_widget)
|
||||
|
||||
if self._launch_init_requested:
|
||||
dependency_screen, dependency_result = (
|
||||
@@ -14617,15 +14650,16 @@ class DeepAgentsApp(App):
|
||||
is either a competing screen binding or default key handling:
|
||||
|
||||
- `open_notifications` (`ctrl+n`): `ModelSelectorScreen` has its own
|
||||
priority `ctrl+n -> toggle_names` binding that then wins.
|
||||
priority `ctrl+n -> toggle_names` binding that then wins.
|
||||
- `toggle_auto_approve` (`shift+tab`): `DebugConsoleScreen` has no
|
||||
binding for the key; stepping aside lets it fall through to the
|
||||
console's `key_shift_tab` reverse-focus traversal. Without this the
|
||||
binding fires `action_toggle_auto_approve`, which no-ops under any
|
||||
`ModalScreen`, so the key would be silently swallowed. Note this keys
|
||||
on the action, and `toggle_auto_approve` is also bound to `ctrl+t`, so
|
||||
that (harmless, already a no-op under modals) binding is stepped aside
|
||||
too while the console is open.
|
||||
binding for the key; stepping aside lets it fall through to the
|
||||
console's `key_shift_tab` reverse-focus traversal. Without this the
|
||||
binding fires `action_toggle_auto_approve`, which no-ops under a
|
||||
`ModalScreen` that lacks dedicated `shift+tab` handling (as
|
||||
`DebugConsoleScreen` does), so the key would be silently swallowed.
|
||||
Note this keys on the action, and `toggle_auto_approve` is
|
||||
also bound to `ctrl+t`, so that (harmless, already a no-op
|
||||
under modals) binding is stepped aside too while the console is open.
|
||||
|
||||
Branches on action names, not keys, so this stays correct if a binding is
|
||||
ever rebound.
|
||||
|
||||
@@ -265,6 +265,7 @@ class TestInitialPromptOnMount:
|
||||
chat.styles = SimpleNamespace(scrollbar_size_vertical=None)
|
||||
status_bar = MagicMock(spec=StatusBar)
|
||||
chat_input = MagicMock(spec=ChatInput)
|
||||
chat_input.input_widget = None
|
||||
|
||||
def query_one(selector: object, *_args: object) -> object:
|
||||
if selector == "#chat":
|
||||
@@ -1798,6 +1799,44 @@ class TestStartupSequence:
|
||||
)
|
||||
|
||||
|
||||
class TestStartupFocus:
|
||||
"""Tests for focus selection before the app starts processing input."""
|
||||
|
||||
async def test_queued_startup_input_reaches_chat_input(self) -> None:
|
||||
"""Keys queued before startup should reach the mounted chat input."""
|
||||
app = DeepAgentsApp()
|
||||
app.post_message(events.Key("x", "x"))
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
|
||||
chat_input = app.query_one("#input-area", ChatInput)
|
||||
assert chat_input.value == "x"
|
||||
|
||||
async def test_modal_uses_first_focusable_widget(self) -> None:
|
||||
"""Modal keyboard navigation should retain Textual's focus fallback."""
|
||||
from deepagents_code.tui.widgets.effort_selector import EffortSelectorScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
results: list[str | None] = []
|
||||
async with app.run_test() as pilot:
|
||||
await app.push_screen(
|
||||
EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("low", "medium", "high"),
|
||||
current_effort="low",
|
||||
),
|
||||
results.append,
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
assert app.focused is not None
|
||||
assert app.focused.id == "effort-options"
|
||||
await pilot.press("down", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert results == ["medium"]
|
||||
|
||||
|
||||
class TestAppCSSValidation:
|
||||
"""Test that app CSS is valid and doesn't cause runtime errors."""
|
||||
|
||||
|
||||
@@ -752,10 +752,34 @@ class TestDebugConsoleToggle:
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
# This focus move is the discriminating assertion: without the
|
||||
# `check_action` step-aside, shift+tab is swallowed and focus stays
|
||||
# on `select`. The `_auto_approve` check below is defense-in-depth
|
||||
# only -- the toggle already no-ops under any modal, so it reads
|
||||
# `False` in both the fixed and broken cases.
|
||||
assert screen.focused is log
|
||||
# Shift+Tab navigated; it must not have fired the auto-approve toggle.
|
||||
assert app._auto_approve is False
|
||||
|
||||
async def test_check_action_gates_toggle_binding_by_screen(self) -> None:
|
||||
"""`check_action` steps aside the toggle binding only under the console.
|
||||
|
||||
Guards the enabled path the reverse-focus fix depends on: on the main
|
||||
screen `check_action` must leave `toggle_auto_approve` enabled (return
|
||||
`True`) so shift+tab and ctrl+t still toggle auto-approve; the
|
||||
`test_shift_tab_reverses_focus_*` test only exercises the disabled path.
|
||||
Branching on the action name (not the key) means the same gate covers
|
||||
the `ctrl+t` binding, which also maps to `toggle_auto_approve`.
|
||||
"""
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.check_action("toggle_auto_approve", ()) is True
|
||||
|
||||
await pilot.press("ctrl+backslash")
|
||||
await pilot.pause()
|
||||
assert isinstance(app.screen, DebugConsoleScreen)
|
||||
assert app.check_action("toggle_auto_approve", ()) is False
|
||||
|
||||
async def test_toggle_action_closes_open_console(self) -> None:
|
||||
app = DeepAgentsApp(agent=MagicMock(), thread_id="thread-123")
|
||||
async with app.run_test() as pilot:
|
||||
|
||||
Reference in New Issue
Block a user