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:
Mason Daugherty
2026-07-13 10:07:47 -04:00
committed by GitHub
parent e4c29b5264
commit ef9a4a8770
3 changed files with 109 additions and 12 deletions
+45 -11
View File
@@ -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.
+39
View File
@@ -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: