Repository: napisani/procmux (language: python)

Issue:
Interpolation modal
Change the command form view into a modal dialog. Add previous input, cancel, and submit keybindings for the dialog. Register the keybindings at the application level to enable help view to inform user of available keybindings during interpolation.


https://user-images.githubusercontent.com/34012432/194680972-b3d7cf44-8f44-44c7-866a-902dae282fa7.mov


Discussion / hints:

Fix patch:
diff --git a/app/config.py b/app/config.py
index 1b14db2..6915b50 100644
--- a/app/config.py
+++ b/app/config.py
@@ -101,6 +101,9 @@ class KeybindingConfig:
     filter: List[str] = field(default_factory=lambda: ['/'])
     submit_filter: List[str] = field(default_factory=lambda: ['enter'])
     next_input: List[str] = field(default_factory=lambda: ['tab', 'down'])
+    previous_input: List[str] = field(default_factory=lambda: ['s-tab', 'up'])
+    submit_dialog: List[str] = field(default_factory=lambda: ['enter'])
+    cancel_dialog: List[str] = field(default_factory=lambda: ['escape'])
     start: List[str] = field(default_factory=lambda: ['s'])
     stop: List[str] = field(default_factory=lambda: ['x'])
     up: List[str] = field(default_factory=lambda: ['up', 'k'])
diff --git a/app/tui/app.py b/app/tui/app.py
index e7e95f4..7e0ad60 100644
--- a/app/tui/app.py
+++ b/app/tui/app.py
@@ -2,14 +2,11 @@
 
 from prompt_toolkit.application import Application
 from prompt_toolkit.key_binding import DynamicKeyBindings
-from prompt_toolkit.layout import ConditionalContainer, DynamicContainer, HSplit, Layout, VSplit, Window
+from prompt_toolkit.layout import ConditionalContainer, DynamicContainer, FloatContainer, HSplit, Layout, VSplit, Window
 from prompt_toolkit.styles import Style
 
 from app.config import ProcMuxConfig
-from app.tui.keybindings import DocumentedKeybindings
-from app.tui.types import FocusWidget
 from app.tui.controller.tui_controller import TUIController
-from app.tui.view.command_form import CommandForm
 from app.tui.view.docs import DocsDialog
 from app.tui.view.help import HelpPanel
 from app.tui.view.process_description import ProcessDescriptionPanel
@@ -22,15 +19,17 @@ def start_tui(config: ProcMuxConfig):
                                   width=config.style.width_100,
                                   height=config.style.height_100)
 
-    controller = TUIController(config, terminal_placeholder, CommandForm)
+    dynamic_container = DynamicContainer(get_container=lambda: None)
+    float_container = FloatContainer(content=dynamic_container, floats=[])
+
+    controller = TUIController(config, terminal_placeholder, float_container)
 
-    terminal_wrapper = TerminalPanel(controller)
     side_bar = SideBar(controller)
 
     main_layout_container = HSplit([
         VSplit([
             side_bar,
-            terminal_wrapper
+            TerminalPanel(controller)
         ]),
         ConditionalContainer(content=ProcessDescriptionPanel(controller),
                              filter=not controller.config.layout.hide_process_description_panel),
@@ -52,19 +51,13 @@ def _get_layout_container():
         elif controller.docs_open:
             return docs_layout_container
         return main_layout_container
-
-    def _get_app_keybindings() -> DocumentedKeybindings:
-        if controller.focused_widget == FocusWidget.TERMINAL:
-            return terminal_wrapper.get_keybindings()
-        return DocumentedKeybindings()
+    dynamic_container.get_container = _get_layout_container
 
     application = Application(
-        layout=Layout(
-            container=DynamicContainer(get_container=_get_layout_container),
-            focused_element=side_bar),
+        layout=Layout(container=controller.float_container, focused_element=side_bar),
         full_screen=True,
         mouse_support=controller.config.enable_mouse,
-        key_bindings=DynamicKeyBindings(get_key_bindings=_get_app_keybindings),
+        key_bindings=DynamicKeyBindings(get_key_bindings=controller.get_app_keybindings),
         style=Style(list((controller.config.style.style_classes or {}).items())),
         color_depth=controller.config.style.color_depth
     )
diff --git a/app/tui/controller/tui_controller.py b/app/tui/controller/tui_controller.py
index a1a1e1d..ecaa41d 100644
--- a/app/tui/controller/tui_controller.py
+++ b/app/tui/controller/tui_controller.py
@@ -1,30 +1,37 @@
 from copy import deepcopy
 from functools import cached_property
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional, Union
 
 from prompt_toolkit.application import get_app
 from prompt_toolkit.buffer import Buffer
-from prompt_toolkit.layout import Window
+from prompt_toolkit.layout import FloatContainer, Window
 from prompt_toolkit.mouse_events import MouseEvent, MouseEventType
 from ptterm import Terminal
 
 from app.config import ProcMuxConfig
 from app.log import logger
+from app.tui.interpolation_dialog import InterpolationDialog
+from app.tui.keybindings import DocumentedKeybindings
 from app.tui.controller.terminal_controller import TerminalController
 from app.tui.state.process_state import ProcessState
 from app.tui.state.tui_state import TUIState
-from app.tui.types import FocusTarget, FocusWidget, KeybindingDocumentation, Process
+from app.tui.types import FocusTarget, FocusWidget, Process
 from app.util.interpolation import Interpolation
 
 
 class TUIController:
-    def __init__(self, config: ProcMuxConfig, terminal_placeholder: Window, command_form_ctor):
+    def __init__(self, config: ProcMuxConfig, terminal_placeholder: Window, float_container: FloatContainer):
         self._terminal_placeholder = terminal_placeholder
-        self._command_form_ctor = command_form_ctor
+        self._float_container = float_container
         self._tui_state: TUIState = TUIState(config)
         self._process_state: ProcessState = ProcessState(config)
         self._terminal_controllers: Dict[int, TerminalController] = \
             self._create_terminal_controllers(config, self._process_state.process_list)
+        self._filter_change_handlers: List[Callable[[str], None]] = []
+
+    @property
+    def float_container(self) -> FloatContainer:
+        return self._float_container
 
     @cached_property
     def config(self) -> ProcMuxConfig:
@@ -47,8 +54,8 @@ def quitting(self) -> bool:
         return self._tui_state.quitting
 
     @property
-    def interpolating(self) -> bool:
-        return self._tui_state.interpolating
+    def modal_open(self) -> bool:
+        return self._tui_state.modal_open
 
     # Processes
 
@@ -108,9 +115,7 @@ def current_terminal_controller(self) -> Optional[TerminalController]:
         return None
 
     @property
-    def current_terminal(self) -> Union[Terminal, Window, Any]:
-        if self.interpolating and self._tui_state._command_form:
-            return self._tui_state._command_form
+    def current_terminal(self) -> Union[Terminal, Window]:
         if self.current_terminal_controller and self.current_terminal_controller.terminal:
             return self.current_terminal_controller.terminal
         return self._terminal_placeholder
@@ -120,36 +125,41 @@ def start_process_in_terminal(self, process: Process):
         if self.quitting:
             return  # if procmux is in the process of quitting, don't start a new process
 
-        def finish_interpolating():
-            self._tui_state.stop_interpolating()
-            self.deregister_focusable_element(FocusWidget.TERMINAL_COMMAND_INPUTS)
-            self.focus_to_sidebar()
-
-        def start_cmd(final_interpolations: Optional[List[Interpolation]] = None):
-            if self.interpolating:
-                finish_interpolating()
-            terminal_controller = self._terminal_controllers[process.index]
-            if terminal_controller:
-                run_in_background = self.selected_process is None or self.selected_process.index != process.index
-                terminal_controller.spawn_terminal(run_in_background, final_interpolations)
-
-        if len(process.config.interpolations) > 0:
-            form = self._command_form_ctor(self,
-                                           process.config.interpolations,
-                                           start_cmd,
-                                           finish_interpolating)
-            kb = form.get_keybindings()
-            self.register_focusable_element(FocusWidget.TERMINAL_COMMAND_INPUTS, form, kb.help_docs)
-            self._tui_state.start_interpolating(form)
-            self.focused_widget = FocusWidget.TERMINAL_COMMAND_INPUTS
-            return
-        start_cmd()
+        terminal_controller = self._terminal_controllers[process.index]
+        if terminal_controller:
+            run_in_background = self.selected_process is None or self.selected_process.index != process.index
+            interpolations: List[Interpolation] = []
+
+            if len(process.config.interpolations) > 0:
+                id = InterpolationDialog(process,
+                                         run_in_background,
+                                         self.config,
+                                         self.float_container,
+                                         self.on_finish_interpolation)
+                self._tui_state.open_modal(id.get_keybindings())
+                id.start_interpolation()
+                return
+
+            terminal_controller.spawn_terminal(run_in_background, interpolations)
+
+    def on_finish_interpolation(self,
+                                process: Process,
+                                run_in_background: bool,
+                                interpolations: Optional[List[Interpolation]]
+                                ):
+        self._tui_state.close_modal()
+        self.focus_to_sidebar()
+        terminal_controller = self._terminal_controllers[process.index]
+        if interpolations and terminal_controller:
+            terminal_controller.spawn_terminal(run_in_background, interpolations)
 
     # /Terminal
 
     # Focus
 
     def _focused_target(self) -> Optional[FocusTarget]:
+        if self.modal_open:
+            return None
         app = get_app()
         if app:
             for target in self._tui_state.focus_targets:
@@ -168,13 +178,6 @@ def focused_widget(self) -> FocusWidget:
     def focused_widget(self, value: FocusWidget):
         self._set_focus(self._tui_state.get_focus_element(value))
 
-    @property
-    def focused_keybinding_help(self) -> List[KeybindingDocumentation]:
-        target = self._focused_target()
-        if not target or not target.keybinding_help:
-            return []
-        return target.keybinding_help
-
     @property
     def is_focused_on_free_form_input(self):
         return self.focused_widget == FocusWidget.SIDE_BAR_FILTER
@@ -184,22 +187,15 @@ def _set_focus(self, element: Optional[Any]):
         if app and element:
             app.layout.focus(element)
 
-    def register_focusable_element(
-            self,
-            widget: FocusWidget,
-            element: Any,
-            keybinding_help: Optional[List[KeybindingDocumentation]] = None):
-        self._tui_state.register_focusable_element(
-            FocusTarget(widget=widget,
-                        element=element,
-                        keybinding_help=keybinding_help if keybinding_help else []))
+    def register_focusable_element(self, widget: FocusWidget, element: Any):
+        self._tui_state.register_focusable_element(FocusTarget(widget=widget, element=element))
 
     def deregister_focusable_element(self, widget: FocusWidget):
         self._tui_state.deregister_focusable_element(widget)
 
     def focus_to_current_terminal(self) -> bool:
         logger.info('focusing on current terminal')
-        if self.current_terminal:
+        if isinstance(self.current_terminal, Terminal):
             application = get_app()
             if application:
                 logger.info('focusing on selected process terminal')
@@ -264,22 +260,49 @@ def sidebar_up(self):
     def sidebar_down(self):
         self.move_process_selection(1)
 
+    def switch_focus(self):
+        logger.info('in switch_focus')
+        if not self.is_focused_on_free_form_input:
+            self.toggle_sidebar_terminal_focus()
+
+    # /Sidebar
+
+    # Filter
+
+    def register_filter_change_handler(self, handler: Callable[[str], None]):
+        self._filter_change_handlers.append(handler)
+
+    def on_filter_change(self):
+        for handler in self._filter_change_handlers:
+            handler(self._process_state.filter_text)
+
     def start_filter(self):
         logger.info('in start_filter')
         self._tui_state.filter_mode = True
-        self._process_state.selected_process = None
+        self._process_state.apply_filter('')
+        self.on_filter_change()
         self.focused_widget = FocusWidget.SIDE_BAR_FILTER
 
     def update_filter(self, buffer: Buffer):
         logger.info(f'in update filter: {buffer.text}')
         self._process_state.apply_filter(buffer.text)
+        self.on_filter_change()
 
-    def exit_filter(self, search_text: str):
-        logger.info(f'in exit_filter: {search_text}')
-        self._process_state.apply_filter(search_text)
+    def close_filter(self):
+        logger.info('in close_filter')
         self._tui_state.filter_mode = False
         self.focus_to_sidebar()
 
+    def cancel_filter(self):
+        logger.info('in cancel_filter')
+        self._process_state.apply_filter('')
+        self.on_filter_change()
+        self.close_filter()
+
+    # /Filter
+
+    # Docs
+
     def view_docs(self):
         logger.info('in view_docs')
         self.toggle_docs()
@@ -296,10 +319,9 @@ def toggle_docs(self):
         else:
             self.focused_widget = FocusWidget.SIDE_BAR_SELECT
 
-    def switch_focus(self):
-        logger.info('in switch_focus')
-        if not self.is_focused_on_free_form_input:
-            self.toggle_sidebar_terminal_focus()
+    # /Docs
+
+    # Zoom
 
     def zoom(self):
         logger.info('in zoom')
@@ -317,6 +339,8 @@ def toggle_zoom(self):
                 logger.info('setting zoomed_in to True')
                 self._tui_state.zoomed_in = True
 
+    # /Zoom
+
     # /Sidebar
 
     # Scroll
@@ -336,6 +360,95 @@ def toggle_scroll(self):
 
     # /Scroll
 
+    # Keybindings
+
+    def get_app_keybindings(self) -> DocumentedKeybindings:
+        if self.modal_open:
+            return self._tui_state.modal_keybindings
+
+        kb = DocumentedKeybindings()
+        if self.focused_widget == FocusWidget.DOCS:
+            self._add_docs_keybindings(kb)
+        if self.focused_widget == FocusWidget.SIDE_BAR_FILTER:
+            self._add_filter_keybindings(kb)
+        if self.focused_widget == FocusWidget.SIDE_BAR_SELECT:
+            self._add_side_bar_keybindings(kb)
+            self._add_terminal_keybindings(kb)
+        if self.focused_widget == FocusWidget.TERMINAL:
+            self._add_terminal_keybindings(kb)
+        return kb
+
+    def _add_docs_keybindings(self, kb: DocumentedKeybindings):
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.docs,
+            self.close_docs,
+            'close docs')
+
+    def _add_filter_keybindings(self, kb: DocumentedKeybindings):
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.filter,
+            self.cancel_filter,
+            'exit filter')
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.submit_filter,
+            self.close_filter,
+            'submit filter')
+
+    def _add_side_bar_keybindings(self, kb: DocumentedKeybindings):
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.up,
+            self.sidebar_up,
+            'up')
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.down,
+            self.sidebar_down,
+            'down')
+
+        if self.selected_process and self.selected_process.running:
+            kb.register_configured_keybinding_sans_event(
+                self.config.keybinding.stop,
+                self.stop_process,
+                'stop')
+        else:
+            kb.register_configured_keybinding_sans_event(
+                self.config.keybinding.start,
+                self.start_process,
+                'start')
+
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.quit,
+            self.quit,
+            'quit')
+
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.filter,
+            self.start_filter,
+            'filter')
+
+        kb.register_configured_keybinding_sans_event(
+            self.config.keybinding.docs,
+            self.view_docs,
+            'docs')
+
+        return kb
+
+    def _add_terminal_keybindings(self, kb: DocumentedKeybindings):
+        if isinstance(self.current_terminal, Terminal):
+            kb.register_configured_keybinding_sans_event(
+                self.config.keybinding.switch_focus,
+                self.switch_focus,
+                'switch focus')
+            kb.register_configured_keybinding_sans_event(
+                self.config.keybinding.zoom,
+                self.zoom,
+                'zoom')
+            kb.register_configured_keybinding_sans_event(
+                self.config.keybinding.toggle_scroll,
+                self.toggle_scroll,
+                'toggle scroll')
+
+    # /Keybindings
+
     def _create_terminal_controllers(self,
                                      config: ProcMuxConfig,
                                      process_list: List[Process],
diff --git a/app/tui/interpolation_dialog.py b/app/tui/interpolation_dialog.py
new file mode 100644
index 0000000..02b8a1e
--- /dev/null
+++ b/app/tui/interpolation_dialog.py
@@ -0,0 +1,117 @@
+from typing import Callable, List, Optional
+from prompt_toolkit.application import get_app
+from prompt_toolkit.layout import Dimension, Float, FloatContainer, HSplit, VSplit
+from prompt_toolkit.widgets import Box, Button, Frame, TextArea
+
+from app.config import ProcMuxConfig
+from app.log import logger
+from app.tui.keybindings import DocumentedKeybindings
+from app.tui.types import Process
+from app.util.interpolation import Interpolation
+
+
+class InterpolationDialog():
+    def __init__(self,
+                 process: Process,
+                 run_in_background: bool,
+                 config: ProcMuxConfig,
+                 float_container: FloatContainer,
+                 on_finished: Callable[[Process, bool, Optional[List[Interpolation]]], None]
+                 ):
+        self._process = process
+        self._run_in_background = run_in_background
+        self._interpolations = process.config.interpolations
+        self._config = config
+        self._float_container = float_container
+        self._on_finished = on_finished
+        self._tab_ix = 0
+
+        self._text_inputs = [
+            TextArea(
+                height=1,
+                width=50,
+                prompt=self._config.layout.field_replacement_prompt.replace('__FIELD_NAME__', interp.field),
+                style="class:input-field",
+                multiline=False,
+                wrap_lines=False,
+                text=interp.default_value,
+                focus_on_click=True,
+            ) for interp in self._interpolations
+        ]
+        self._move_cursors_to_last_character()
+
+        start_button = Button('Start', self._on_start)
+        cancel_button = Button('Cancel', self._on_cancel)
+        self._focusable_components = [*self._text_inputs, start_button, cancel_button]
+
+        self._container = HSplit(
+            [
+                Box(
+                    body=HSplit(
+                        children=self._text_inputs,
+                        padding=1
+                    ),
+                    padding=Dimension(min=1, max=1, preferred=1),
+                    padding_bottom=0
+                ),
+                Box(
+                    body=VSplit(
+                        children=[start_button, cancel_button],
+                        padding=Dimension(min=1, max=5, preferred=5)
+                    ),
+                    height=Dimension(min=1, max=3, preferred=3)
+                )
+            ]
+        )
+
+    def _move_cursors_to_last_character(self):
+        for ti in self._text_inputs:
+            buff = ti.control.buffer
+            buff.cursor_position = len(buff.document.current_line_after_cursor)
+
+    def _on_start(self):
+        final_input_interpolations: List[Interpolation] = []
+        for input_field, interp in zip(self._text_inputs, self._interpolations):
+            final_input_interpolations.append(Interpolation(
+                field=interp.field,
+                value=input_field.text,
+                default_value=interp.default_value
+            ))
+        self._float_container.floats = []
+        self._on_finished(self._process, self._run_in_background, final_input_interpolations)
+
+    def _on_cancel(self):
+        self._float_container.floats = []
+        self._on_finished(self._process, self._run_in_background, None)
+
+    def _move_focus(self, direction: int):
+        self._tab_ix = (self._tab_ix + direction) % len(self._focusable_components)
+        current_input = self._focusable_components[self._tab_ix]
+        app = get_app()
+        if app:
+            app.layout.focus(current_input)
+
+    def get_keybindings(self) -> DocumentedKeybindings:
+        def next_input():
+            logger.info('interpolation dialog next_input - focusing on next tab index')
+            self._move_focus(1)
+
+        def previous_input():
+            logger.info('interpolation dialog previous_input - focusing on next tab index')
+            self._move_focus(-1)
+
+        kb = DocumentedKeybindings()
+        kb.register_configured_keybinding_sans_event(self._config.keybinding.next_input, next_input, 'next input')
+        kb.register_configured_keybinding_sans_event(
+            self._config.keybinding.previous_input, previous_input, 'previous input')
+        kb.register_configured_keybinding_sans_event(self._config.keybinding.submit_dialog, self._on_start, 'start')
+        kb.register_configured_keybinding_sans_event(self._config.keybinding.cancel_dialog, self._on_cancel, 'cancel')
+        return kb
+
+    def start_interpolation(self):
+        app = get_app()
+        if app and self._text_inputs:
+            self._float_container.floats = [Float(Frame(self._container))]
+            app.layout.focus(self._text_inputs[0])
+        else:
+            self._on_cancel()
diff --git a/app/tui/keybindings.py b/app/tui/keybindings.py
index 94c4455..c11aaea 100644
--- a/app/tui/keybindings.py
+++ b/app/tui/keybindings.py
@@ -2,7 +2,6 @@
 
 from prompt_toolkit.key_binding import KeyBindings
 
-from app.config import ProcMuxConfig
 from app.tui.types import KeybindingDocumentation
 
 
@@ -11,75 +10,29 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.help_docs: List[KeybindingDocumentation] = []
 
-    def add_keybinding_help_doc(self,
-                                label: str,
-                                keys: List[str],
-                                should_show_help: Optional[Callable[[], bool]] = None):
+    def add_keybinding_help_doc(self, label: str, keys: List[str]):
         if keys:
-            self.help_docs.append(KeybindingDocumentation(
-                label=label,
-                help=keys[0],
-                should_display=should_show_help if should_show_help else (lambda: True)))
-
-
-def register_configured_keybinding_no_event(
-        keys: List[str],
-        handler: Callable[[], None],
-        kb: DocumentedKeybindings,
-        help_label: Optional[str] = None,
-        should_show_help: Optional[Callable[[], bool]] = None) -> DocumentedKeybindings:
-    def _handler(_) -> None:
-        handler()
-
-    return register_configured_keybinding(keys, _handler, kb,
-                                          help_label=help_label,
-                                          should_show_help=should_show_help)
-
-
-def register_configured_keybinding(
-        keys: List[str],
-        handler: Callable[[Any], None],
-        kb: DocumentedKeybindings,
-        help_label: Optional[str] = None,
-        should_show_help: Optional[Callable[[], bool]] = None) -> DocumentedKeybindings:
-    if 'disabled' in keys:
-        return kb
-
-    for keybinding in keys:
-        @kb.add(keybinding)
-        def _(event) -> None:
-            handler(event)
-    if help_label:
-        kb.add_keybinding_help_doc(label=help_label,
-                                   keys=keys,
-                                   should_show_help=should_show_help)
-
-    return kb
-
-
-def register_app_wide_configured_keybindings(config: ProcMuxConfig,
-                                             switch_focus: Callable[[], None],
-                                             zoom: Callable[[], None],
-                                             toggle_scroll: Callable[[], None],
-                                             kb: DocumentedKeybindings,
-                                             should_show_help: Optional[Callable[[], bool]] = None
-                                             ) -> DocumentedKeybindings:
-    kb = register_configured_keybinding_no_event(
-        config.keybinding.switch_focus,
-        switch_focus,
-        kb,
-        help_label='switch_focus',
-        should_show_help=should_show_help)
-    kb = register_configured_keybinding_no_event(
-        config.keybinding.zoom,
-        zoom,
-        kb,
-        help_label='zoom',
-        should_show_help=should_show_help)
-    kb = register_configured_keybinding_no_event(
-        config.keybinding.toggle_scroll,
-        toggle_scroll,
-        kb,
-        help_label='toggle scroll',
-        should_show_help=should_show_help)
-    return kb
+            self.help_docs.append(KeybindingDocumentation(label=label, help=keys[0]))
+
+    def register_configured_keybinding_sans_event(self,
+                                                  keys: List[str],
+                                                  handler: Callable[[], None],
+                                                  help_label: Optional[str] = None,
+                                                  ):
+        self.register_configured_keybinding(keys, lambda _: handler(), help_label=help_label)
+
+    def register_configured_keybinding(self,
+                                       keys: List[str],
+                                       handler: Callable[[Any], None],
+                                       help_label: Optional[str] = None
+                                       ):
+        if 'disabled' in keys:
+            return
+
+        for keybinding in keys:
+            @self.add(keybinding)
+            def _(event) -> None:
+                handler(event)
+
+        if help_label:
+            self.add_keybinding_help_doc(label=help_label, keys=keys)
diff --git a/app/tui/state/process_state.py b/app/tui/state/process_state.py
index eb31432..88941b0 100644
--- a/app/tui/state/process_state.py
+++ b/app/tui/state/process_state.py
@@ -23,6 +23,10 @@ def has_running_processes(self) -> bool:
                 return True
         return False
 
+    @property
+    def filter_text(self) -> str:
+        return self._filter
+
     def select_first_process(self):
         if self.filtered_process_list:
             self.selected_process = self.filtered_process_list[0]
diff --git a/app/tui/state/tui_state.py b/app/tui/state/tui_state.py
index a69b10b..6bb9f2c 100644
--- a/app/tui/state/tui_state.py
+++ b/app/tui/state/tui_state.py
@@ -1,6 +1,7 @@
 from typing import Any, List, Optional
 
 from app.config import ProcMuxConfig
+from app.tui.keybindings import DocumentedKeybindings
 from app.tui.types import FocusTarget, FocusWidget
 
 
@@ -12,20 +13,20 @@ def __init__(self, config: ProcMuxConfig):
         self.docs_open: bool = False
         self.quitting: bool = False
         self._focus_targets: List[FocusTarget] = []
-        self._interpolating: bool = False
-        self._command_form: Optional[Any] = None
+        self._modal_open: bool = False
+        self._modal_keybindings: DocumentedKeybindings = DocumentedKeybindings()
 
     @property
     def focus_targets(self) -> List[FocusTarget]:
         return self._focus_targets
 
     @property
-    def interpolating(self) -> bool:
-        return self._interpolating
+    def modal_open(self) -> bool:
+        return self._modal_open
 
     @property
-    def command_form(self) -> Optional[Any]:
-        return self._command_form
+    def modal_keybindings(self) -> DocumentedKeybindings:
+        return self._modal_keybindings
 
     def get_focus_element(self, widget: FocusWidget) -> Optional[Any]:
         return next((ft.element for ft in self._focus_targets if ft.widget == widget), None)
@@ -36,10 +37,10 @@ def register_focusable_element(self, target: FocusTarget):
     def deregister_focusable_element(self, widget: FocusWidget):
         self._focus_targets = [fe for fe in self._focus_targets if fe.widget != widget]
 
-    def start_interpolating(self, command_form: Any):
-        self._command_form = command_form
-        self._interpolating = True
+    def open_modal(self, kb: DocumentedKeybindings = DocumentedKeybindings()):
+        self._modal_open = True
+        self._modal_keybindings = kb
 
-    def stop_interpolating(self):
-        self._interpolating = False
-        self._command_form = None
+    def close_modal(self):
+        self._modal_open = False
+        self._modal_keybindings = DocumentedKeybindings()
diff --git a/app/tui/types.py b/app/tui/types.py
index d897316..867d6d2 100644
--- a/app/tui/types.py
+++ b/app/tui/types.py
@@ -1,6 +1,6 @@
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from enum import Enum, auto
-from typing import Any, Callable, List
+from typing import Any
 
 from app.config import ProcessConfig
 
@@ -13,24 +13,22 @@ class FocusWidget(Enum):
     DOCS = auto()
 
 
-@dataclass
-class Process:
-    index: int
-    config: ProcessConfig
-    name: str
-    running: bool = False
-    scroll_mode: bool = False
+@dataclass(frozen=True)
+class FocusTarget:
+    widget: FocusWidget
+    element: Any
 
 
 @dataclass(frozen=True)
 class KeybindingDocumentation:
     label: str
     help: str
-    should_display: Callable[[], bool] = field(default_factory=lambda: lambda: True)
 
 
-@dataclass(frozen=True)
-class FocusTarget:
-    widget: FocusWidget
-    element: Any
-    keybinding_help: List[KeybindingDocumentation] = field(default_factory=lambda: [])
+@dataclass
+class Process:
+    index: int
+    config: ProcessConfig
+    name: str
+    running: bool = False
+    scroll_mode: bool = False
diff --git a/app/tui/view/command_form.py b/app/tui/view/command_form.py
deleted file mode 100644
index 9eb53a9..0000000
--- a/app/tui/view/command_form.py
+++ /dev/null
@@ -1,89 +0,0 @@
-from typing import Callable, List
-
-from prompt_toolkit.application import get_app
-from prompt_toolkit.layout import HSplit, VSplit
-from prompt_toolkit.widgets import Button, TextArea
-
-from app.log import logger
-from app.tui.keybindings import DocumentedKeybindings, register_configured_keybinding
-from app.tui.controller.tui_controller import TUIController
-from app.util.interpolation import Interpolation
-
-
-class CommandForm:
-    def __init__(self,
-                 controller: TUIController,
-                 interpolations: List[Interpolation],
-                 on_start: Callable,
-                 on_cancel: Callable):
-        assert on_start
-        assert on_cancel
-        self._controller = controller
-        self._interpolations = interpolations
-        self._tab_idx = 0
-
-        start_button = Button(
-            text='Start',
-            handler=lambda: on_start(self._collect_input_as_interpolations()))
-        cancel_button = Button(
-            text='Cancel',
-            handler=on_cancel)
-        prompt_template = self._controller.config.layout.field_replacement_prompt
-        self._text_inputs = [
-            TextArea(
-                height=1,
-                prompt=prompt_template.replace('__FIELD_NAME__', interp.field),
-                style="class:input-field",
-                multiline=False,
-                wrap_lines=False,
-                text=interp.default_value,
-                focus_on_click=True,
-            ) for interp in self._interpolations
-        ]
-        self._focusable_components = [
-            *self._text_inputs,
-            start_button,
-            cancel_button
-        ]
-        self._move_cursors_to_last_character()
-        self._container = HSplit([
-            *self._text_inputs,
-            VSplit([
-                start_button,
-                cancel_button
-            ])],
-            width=self._controller.config.style.width_100,
-            height=self._controller.config.style.height_100,
-            key_bindings=self.get_keybindings())
-
-    def _move_cursors_to_last_character(self):
-        for ti in self._text_inputs:
-            buff = ti.control.buffer
-            buff.cursor_position = len(buff.document.current_line_after_cursor)
-
-    def get_keybindings(self) -> DocumentedKeybindings:
-        def next_input(_):
-            logger.info('next_input - focusing on next tab index')
-            self._tab_idx = (self._tab_idx + 1) % len(self._focusable_components)
-            current_input = self._focusable_components[self._tab_idx]
-            app = get_app()
-            if app:
-                app.layout.focus(current_input)
-
-        kb = register_configured_keybinding(self._controller.config.keybinding.next_input,
-                                            next_input,
-                                            DocumentedKeybindings())
-        return kb
-
-    def _collect_input_as_interpolations(self):
-        final_input_interpolations = []
-        for input_field, interp in zip(self._text_inputs, self._interpolations):
-            final_input_interpolations.append(Interpolation(
-                field=interp.field,
-                value=input_field.text,
-                default_value=interp.default_value
-            ))
-        return final_input_interpolations
-
-    def __pt_container__(self):
-        return self._container
diff --git a/app/tui/view/docs.py b/app/tui/view/docs.py
index 74dd8ce..5d33cfa 100644
--- a/app/tui/view/docs.py
+++ b/app/tui/view/docs.py
@@ -6,7 +6,6 @@
 from prompt_toolkit.layout import FormattedTextControl, Window
 from prompt_toolkit.widgets import Frame
 
-from app.tui.keybindings import DocumentedKeybindings, register_configured_keybinding_no_event
 from app.tui.types import FocusWidget
 from app.tui.controller.tui_controller import TUIController
 
@@ -14,24 +13,15 @@
 class DocsDialog:
     def __init__(self, controller: TUIController):
         self._controller: TUIController = controller
-        kb = self._get_key_bindings()
         self._container: Frame = Frame(
             title=self._get_title,
-            key_bindings=kb,
             body=Window(
                 content=FormattedTextControl(
                     text=self._get_formatted_text,
                     focusable=True,
                     show_cursor=False
                 )))
-        self._controller.register_focusable_element(FocusWidget.DOCS, self._container, keybinding_help=kb.help_docs)
-
-    def _get_key_bindings(self) -> DocumentedKeybindings:
-        return register_configured_keybinding_no_event(
-            self._controller.config.keybinding.docs,
-            self._controller.close_docs,
-            DocumentedKeybindings(),
-            'docs')
+        self._controller.register_focusable_element(FocusWidget.DOCS, self._container)
 
     def _get_title(self) -> str:
         process = self._controller.selected_process
diff --git a/app/tui/view/help.py b/app/tui/view/help.py
index ff12bfa..d7b5f90 100644
--- a/app/tui/view/help.py
+++ b/app/tui/view/help.py
@@ -29,11 +29,7 @@ def intersperse(lst, item):
             full_list[0::2] = lst
             return full_list
 
-        result = [
-            self._get_key_combo_text(help_)
-            for help_ in self._controller.focused_keybinding_help
-            if help_.should_display()
-        ]
+        result = [self._get_key_combo_text(help_) for help_ in self._controller.get_app_keybindings().help_docs]
         if result:
             result = intersperse(result, delimiter)
         return merge_formatted_text(result)
diff --git a/app/tui/view/side_bar.py b/app/tui/view/side_bar.py
index 9a20120..40c6c98 100644
--- a/app/tui/view/side_bar.py
+++ b/app/tui/view/side_bar.py
@@ -7,10 +7,7 @@
 from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
 from prompt_toolkit.layout.dimension import D, Dimension
 from prompt_toolkit.widgets import Frame
-from ptterm import Terminal
 
-from app.tui.keybindings import DocumentedKeybindings, register_app_wide_configured_keybindings, \
-    register_configured_keybinding_no_event
 from app.tui.types import FocusWidget
 from app.tui.controller.tui_controller import TUIController
 
@@ -23,24 +20,16 @@ def __init__(self, controller: TUIController):
         self._fixed_width: int = self._controller.config.layout.processes_list_width
 
         self._filter_buffer: Buffer = Buffer(on_text_changed=self._controller.update_filter)
-        buffer_input_keybindings = self._get_buffer_input_key_bindings()
-        selection_keybindings = self._get_selection_key_bindings()
-        self._buffer_control: BufferControl = BufferControl(
-            buffer=self._filter_buffer,
-            key_bindings=buffer_input_keybindings)
+        self._buffer_control: BufferControl = BufferControl(buffer=self._filter_buffer)
         self._list_control: FormattedTextControl = FormattedTextControl(
             text=self._get_text_fragments,
             show_cursor=False,
-            focusable=True,
-            key_bindings=selection_keybindings
+            focusable=True
         )
 
-        self._controller.register_focusable_element(FocusWidget.SIDE_BAR_FILTER,
-                                                    self._buffer_control,
-                                                    buffer_input_keybindings.help_docs)
-        self._controller.register_focusable_element(FocusWidget.SIDE_BAR_SELECT,
-                                                    self._list_control,
-                                                    selection_keybindings.help_docs)
+        self._controller.register_focusable_element(FocusWidget.SIDE_BAR_FILTER, self._buffer_control)
+        self._controller.register_focusable_element(FocusWidget.SIDE_BAR_SELECT, self._list_control)
+        self._controller.register_filter_change_handler(self.on_filter_change)
 
         def _get_filter_container() -> Window:
             if self._controller.filter_mode or self._filter_buffer.text:
@@ -65,6 +54,10 @@ def _get_filter_container() -> Window:
                 )
             ]))
 
+    def on_filter_change(self, filter_text: str):
+        if filter_text != self._filter_buffer.text:
+            self._filter_buffer.text = filter_text
+
     def _get_text_fragments(self) -> List[Any]:
         result = []
         for process in self._controller.filtered_process_list:
@@ -98,75 +91,5 @@ def _get_text_fragments(self) -> List[Any]:
 
         return result
 
-    def _get_buffer_input_key_bindings(self):
-        def on_exit_filter():
-            self._controller.exit_filter(self._filter_buffer.text)
-
-        kb = register_configured_keybinding_no_event(
-            self._controller.config.keybinding.filter,
-            on_exit_filter,
-            DocumentedKeybindings(),
-            'exit filter')
-        return register_configured_keybinding_no_event(
-            self._controller.config.keybinding.submit_filter,
-            on_exit_filter,
-            kb,
-            'filter')
-
-    def _get_selection_key_bindings(self):
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.up,
-                                                     self._controller.sidebar_up,
-                                                     DocumentedKeybindings(),
-                                                     'up')
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.down,
-                                                     self._controller.sidebar_down,
-                                                     kb,
-                                                     'down')
-
-        def is_running():
-            proc = self._controller.selected_process
-            if not proc or not proc.running:
-                return False
-            return True
-
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.start,
-                                                     self._controller.start_process,
-                                                     kb,
-                                                     'start',
-                                                     should_show_help=lambda: not is_running())
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.stop,
-                                                     self._controller.stop_process,
-                                                     kb,
-                                                     'stop',
-                                                     should_show_help=is_running)
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.quit,
-                                                     self._controller.quit,
-                                                     kb,
-                                                     'quit')
-
-        def start_filter():
-            self._filter_buffer.text = ''
-            self._controller.start_filter()
-
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.filter,
-                                                     start_filter,
-                                                     kb,
-                                                     'filter')
-        kb = register_configured_keybinding_no_event(self._controller.config.keybinding.docs,
-                                                     self._controller.view_docs,
-                                                     kb,
-                                                     'docs')
-
-        def has_terminal() -> bool:
-            return isinstance(self._controller.current_terminal, Terminal)
-        kb = register_app_wide_configured_keybindings(self._controller.config,
-                                                      self._controller.switch_focus,
-                                                      self._controller.zoom,
-                                                      self._controller.toggle_scroll,
-                                                      kb,
-                                                      has_terminal
-                                                      )
-        return kb
-
     def __pt_container__(self):
         return self._container
diff --git a/app/tui/view/terminal.py b/app/tui/view/terminal.py
index f7972ef..2c6b36e 100644
--- a/app/tui/view/terminal.py
+++ b/app/tui/view/terminal.py
@@ -1,7 +1,6 @@
 from prompt_toolkit.layout import DynamicContainer, VSplit  # , Window
 from prompt_toolkit.widgets import Frame
 
-from app.tui.keybindings import DocumentedKeybindings, register_app_wide_configured_keybindings
 from app.tui.types import FocusWidget
 from app.tui.controller.tui_controller import TUIController
 
@@ -14,16 +13,7 @@ def __init__(self, controller: TUIController):
             body=VSplit([
                 DynamicContainer(get_container=lambda: self._controller.current_terminal),
             ]))
-        self._controller.register_focusable_element(FocusWidget.TERMINAL,
-                                                    element=self,
-                                                    keybinding_help=self.get_keybindings().help_docs)
-
-    def get_keybindings(self) -> DocumentedKeybindings:
-        return register_app_wide_configured_keybindings(self._controller.config,
-                                                        self._controller.switch_focus,
-                                                        self._controller.zoom,
-                                                        self._controller.toggle_scroll,
-                                                        DocumentedKeybindings())
+        self._controller.register_focusable_element(FocusWidget.TERMINAL, element=self)
 
     def __pt_container__(self):
         return self._container


Test patch:
diff --git a/test/test_tui.py b/test/test_tui.py
index 1ac0863..25db531 100644
--- a/test/test_tui.py
+++ b/test/test_tui.py
@@ -13,13 +13,13 @@ def _get_config_yaml(log_file: str) -> str:
     layout:
       hide_help: false
     keybinding:
-      switch_focus: 'w' 
-      up: 'k' 
+      switch_focus: 'w'
+      up: 'k'
       down: 'j'
     procs:
       "tail log":
         shell: "tail -f {log_file}"
-        autostart: true 
+        autostart: true
         description: 'tail the app log'
         docs: long description for tail log
         meta_tags:
@@ -27,7 +27,7 @@ def _get_config_yaml(log_file: str) -> str:
       "print envs":
         shell: "echo $SOME_TEST"
         description: 'this command will print env vars that are configured in the child pid'
-        env: 
+        env:
           SOME_TEST: "ENV_VARS_ARE_WORKING"
         categories:
           - 'echo'
@@ -43,7 +43,7 @@ def _get_config_yaml(log_file: str) -> str:
           - 'echo'
       "interpolate":
         shell: "echo '<first_echo:some default>' && echo '<second_echo>'"
-        autostart: false 
+        autostart: false
         description: 'test interpolation'
 
     """
@@ -116,26 +116,37 @@ def assert_terminal_help_bar(screen):
         assert '<x> stop' not in full_screen
         assert '<w> switch' in full_screen
 
-    preform_test_within_tui(keys=['w'], assertion=assert_terminal_help_bar)
+    preform_test_within_tui(keys=['j', 'j', 'w'], assertion=assert_terminal_help_bar)
 
 
 def test_tui_filter():
     def assert_filter(screen):
         full_screen = join_screen_to_str(screen)
         assert '
 start' not in full_screen
-        assert '<enter> filter' in full_screen
+        assert '<enter> submit filter' in full_screen
         assert 'test long running proc' in full_screen
         assert 'tail log' not in full_screen
 
     preform_test_within_tui(keys=['/', *list('test long run')], assertion=assert_filter)
 
 
+def test_tui_filter_cancel():
+    def assert_filter(screen):
+        full_screen = join_screen_to_str(screen)
+        assert '
 start' in full_screen
+        assert '<enter> submit filter' not in full_screen
+        assert 'test long running proc' in full_screen
+        assert 'tail log' in full_screen
+
+    preform_test_within_tui(keys=['/', *list('test long run'), '/'], assertion=assert_filter)
+
+
 def test_tui_env_vars_in_child_pid():
     def assert_env_vars_printed(screen):
         full_screen = join_screen_to_str(screen)
         assert 'ENV_VARS_ARE_WORKING' in full_screen
 
-    preform_test_within_tui(keys=['/', *list('print env'), '/', 'j', 's'], assertion=assert_env_vars_printed)
+    preform_test_within_tui(keys=['/', *list('print env'), '\n', 's'], assertion=assert_env_vars_printed)
 
 
 def test_tui_filter_for_missing_process_shows_no_results():
@@ -146,7 +157,7 @@ def assert_no_results(screen):
         assert 'vim' not in full_screen
         assert 'test long running proc' not in full_screen
 
-    preform_test_within_tui(keys=['/', *list('NEVER'), '/', 'j'], assertion=assert_no_results)
+    preform_test_within_tui(keys=['/', *list('NEVER'), '\n', 'j'], assertion=assert_no_results)
 
 
 def test_tui_filter_against_category():
@@ -156,7 +167,7 @@ def assert_no_results(screen):
         assert 'print envs' in full_screen
         assert 'vim' not in full_screen
 
-    preform_test_within_tui(keys=['/', *list('cat:echo'), '/', 'j'], assertion=assert_no_results)
+    preform_test_within_tui(keys=['/', *list('cat:echo'), '\n', 'j'], assertion=assert_no_results)
 
 
 def test_tui_filter_against_meta_tags():
@@ -167,7 +178,7 @@ def assert_no_results(screen):
         assert 'vim' not in full_screen
         assert 'tail log' in full_screen
 
-    preform_test_within_tui(keys=['/', *list('follow'), '/', 'j'], assertion=assert_no_results)
+    preform_test_within_tui(keys=['/', *list('follow'), '\n', 'j'], assertion=assert_no_results)
 
 
 def test_long_docs_get_displayed():
@@ -176,7 +187,7 @@ def assert_no_results(screen):
         assert 'long description for tail log' in full_screen
         assert 'print envs' not in full_screen
 
-    preform_test_within_tui(keys=['/', *list('tail log'), '/', 'j', '?'], assertion=assert_no_results)
+    preform_test_within_tui(keys=['/', *list('tail log'), '\n', 'j', '?'], assertion=assert_no_results)
 
 
 def test_tui_autostart():
@@ -189,4 +200,3 @@ def assert_autostart(screen):
             raise RuntimeError('Active tail log process not found')
 
     preform_test_within_tui(keys=[], assertion=assert_autostart)
-