feat: improve terminal navigation and UI behavior

- refine tree and history navigation behavior
- improve scrolling continuity in the terminal UI
- keep AI worklog and prompt log updated
- validate changes with project quality checks
This commit is contained in:
2026-03-16 00:28:27 +01:00
parent 6195f7b8a3
commit 60723c33bc
4 changed files with 336 additions and 55 deletions

View File

@@ -26,3 +26,18 @@ Store only the user prompt and a short result summary.
- prompt summary: Introduce a standard AI log entry format in `instructions-agent.md` and record the change in the repository AI logs.
- result summary: Added a standardized `AI BUILD ENTRY` and `AI PLAN ENTRY` format, improved consistency expectations for AI logs, and updated the log files to reflect the new format.
## 2026-03-15
- prompt summary: Improve keyboard navigation in `src/app/ui.py` so Right expands or enters, Left collapses or goes to the parent, Enter confirms the selected destination, and ESC cancels back to the starting directory.
- result summary: Updated the tree-mode key handling in the terminal UI, preserved history mode behavior, and added focused UI tests for the new navigation rules.
## 2026-03-15
- prompt summary: Improve vertical scrolling in `src/app/ui.py` so the selected entry always remains visible in tree mode and history mode when the list is taller than the screen.
- result summary: Added UI scroll offset handling for tree and history views, kept existing navigation behavior unchanged, and added focused tests for the scroll helper logic.
## 2026-03-16 00:27
- prompt summary: Improve terminal UI navigation by refining tree and history keyboard behavior, adding scrolling continuity so selection stays visible, and preserving confirmation, cancellation, and history mode behavior.
- result summary: Updated `src/app/ui.py` and `tests/test_ui.py` to support dedicated history selection, improved Right/Left/Enter/ESC behavior, and vertical scrolling for both tree and history views while keeping the AI logs current.

View File

@@ -14,23 +14,80 @@ Each entry should include:
Entries must be concise and chronological.
## 2026-03-15 - build
### AI BUILD ENTRY
Date: 2026-03-15
Task: build
Objective: Replace the temporary history debug overlay with a dedicated history mode in the terminal UI.
- objective: Replace the temporary history debug overlay with a dedicated history mode in the terminal UI.
- files modified: `src/app/ui.py`, `tests/test_ui.py`
- key decisions: Keep `b` and `f` unchanged, add separate history-mode selection state inside the UI, hide the tree while history mode is active, and use Enter to select a history entry and return to the tree view.
- validation: `make fix`, `make quality`
- result: History mode now shows only the history list, supports Up/Down selection, allows Enter to jump to a prior directory, and exits cleanly back to normal navigation.
- open issues: None.
Files Modified:
- `src/app/ui.py`
- `tests/test_ui.py`
## 2026-03-15 - build
Key Decisions:
- Keep `b` and `f` unchanged.
- Add separate history-mode selection state inside the UI.
- Hide the tree while history mode is active.
- Use Enter to select a history entry and return to the tree view.
- objective: Introduce mandatory AI logging rules and document the repository logging workflow.
- files modified: `instructions-agent.md`, `docs/ai-worklog.md`, `docs/ai-prompts.md`
- key decisions: Require AI agents to log significant plan and build tasks, preserve separate logs for work performed and prompts received, and keep entries concise and user-facing.
- validation: not run
- result: The repository now defines mandatory AI logging in `instructions-agent.md` and includes dedicated log files for AI work history and prompt summaries.
- open issues: None.
Validation:
- make fix
- make quality
Result:
- History mode now shows only the history list, supports Up/Down selection, allows Enter to jump to a prior directory, and exits cleanly back to normal navigation.
Open Issues:
- None.
### AI BUILD ENTRY
Date: 2026-03-16 00:27
Task: build
Objective: Consolidate recent terminal UI navigation improvements and document the updated interaction behavior.
Files Modified:
- `src/app/ui.py`
- `tests/test_ui.py`
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
Key Decisions:
- Keep dedicated history mode while refining tree-mode keyboard behavior for Right, Left, Enter, and ESC.
- Add scroll continuity for both tree and history views so the selected row remains visible on long lists.
- Cover the UI-focused behavior with helper-level tests instead of changing navigator or filesystem logic.
Validation:
- make fix (passed)
- make quality (passed)
Result:
- The terminal UI now combines dedicated history mode, clearer confirmation and cancellation behavior, improved tree navigation semantics, and vertical scrolling that keeps the current selection visible in both tree and history views.
Open Issues:
- None.
### AI BUILD ENTRY
Date: 2026-03-15
Task: build
Objective: Introduce mandatory AI logging rules and document the repository logging workflow.
Files Modified:
- `instructions-agent.md`
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
Key Decisions:
- Require AI agents to log significant plan and build tasks.
- Preserve separate logs for work performed and prompts received.
- Keep entries concise and user-facing.
Validation:
- not run
Result:
- The repository now defines mandatory AI logging in `instructions-agent.md` and includes dedicated log files for AI work history and prompt summaries.
Open Issues:
- None.
### AI BUILD ENTRY
Date: 2026-03-15
@@ -55,3 +112,53 @@ Result:
Open Issues:
- None.
### AI BUILD ENTRY
Date: 2026-03-15
Task: build
Objective: Improve vertical scrolling in the terminal UI so the selected row remains visible in tree mode and history mode.
Files Modified:
- `src/app/ui.py`
- `tests/test_ui.py`
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
Key Decisions:
- Add separate scroll offsets for tree mode and history mode inside the UI state.
- Introduce a small helper to clamp scroll offsets so the selected row always stays within the visible window.
- Keep all existing navigation semantics unchanged while limiting the change to UI rendering behavior.
Validation:
- make fix (passed)
- make quality (passed)
Result:
- The terminal UI now scrolls vertically in both tree mode and history mode to keep the current selection visible when the list is taller than the screen.
Open Issues:
- None.
### AI BUILD ENTRY
Date: 2026-03-15
Task: build
Objective: Improve keyboard navigation behavior in the terminal UI for tree mode.
Files Modified:
- `src/app/ui.py`
- `tests/test_ui.py`
Key Decisions:
- Make Right expand collapsed directories and enter already expanded directories.
- Make Left collapse expanded directories and otherwise navigate to the parent directory.
- Make Enter confirm the selected path as the final destination and make ESC return the original starting directory.
Validation:
- make fix (passed)
- make quality (passed)
Result:
- Tree-mode keyboard navigation now supports expand-or-enter on Right, collapse-or-parent on Left, explicit selection confirmation on Enter, and cancel-to-start behavior on ESC.
Open Issues:
- None.

View File

@@ -76,12 +76,34 @@ def build_history_lines(
]
def clamp_scroll_offset(
selected_index: int, scroll_offset: int, visible_count: int, total_count: int
) -> int:
"""Keep the selected row within the visible scrolling window."""
if visible_count <= 0 or total_count <= visible_count:
return 0
max_offset = total_count - visible_count
offset = max(0, min(scroll_offset, max_offset))
if selected_index < offset:
return selected_index
if selected_index >= offset + visible_count:
return selected_index - visible_count + 1
return offset
class TerminalUI:
"""Minimal curses UI for the directory navigator MVP."""
def __init__(self) -> None:
self._history_mode = False
self._history_selected_index = 0
self._tree_scroll_offset = 0
self._history_scroll_offset = 0
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
@@ -93,13 +115,14 @@ class TerminalUI:
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0)
stdscr.keypad(True)
original_path = navigator.current_path
while True:
self._render(stdscr, navigator)
key = stdscr.getch()
if key == ESCAPE_KEY:
return navigator.current_path
return original_path
if key == ord("h"):
self._toggle_history_mode(navigator)
@@ -109,44 +132,9 @@ class TerminalUI:
self._handle_history_key(key, navigator)
continue
if key == curses.KEY_UP:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_DOWN:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=1,
entry_count=len(navigator.visible_entries),
)
)
continue
if key == curses.KEY_RIGHT:
navigator.expand_selected_directory()
continue
if key == curses.KEY_LEFT:
navigator.collapse_selected_directory()
continue
if key == ord("b"):
navigator.go_back()
continue
if key == ord("f"):
navigator.go_forward()
continue
if key in (curses.KEY_ENTER, 10, 13):
navigator.enter_selected_directory()
tree_result = self._handle_tree_key(key, navigator)
if tree_result is not None:
return tree_result
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.erase()
@@ -160,7 +148,19 @@ class TerminalUI:
path_text = str(navigator.current_path)
stdscr.addnstr(0, 0, path_text, width - 1)
for row, line in enumerate(build_render_lines(navigator), start=2):
lines = build_render_lines(navigator)
visible_count = max(0, height - 2)
self._tree_scroll_offset = clamp_scroll_offset(
navigator.selected_index,
self._tree_scroll_offset,
visible_count,
len(lines),
)
for row, line in enumerate(
lines[self._tree_scroll_offset : self._tree_scroll_offset + visible_count],
start=2,
):
if row >= height:
break
@@ -176,10 +176,23 @@ class TerminalUI:
navigator.history.entries(), self._history_selected_index
)
start_row = 1
visible_count = max(0, height - start_row - 1)
self._history_scroll_offset = clamp_scroll_offset(
self._history_selected_index,
self._history_scroll_offset,
visible_count,
len(history_lines),
)
stdscr.addnstr(start_row, 0, "History:", width - 1, curses.A_BOLD)
for index, line in enumerate(history_lines, start=1):
for index, line in enumerate(
history_lines[
self._history_scroll_offset : self._history_scroll_offset
+ visible_count
],
start=1,
):
row = start_row + index
if row >= height:
break
@@ -191,6 +204,7 @@ class TerminalUI:
self._history_mode = not self._history_mode
if self._history_mode:
self._history_selected_index = self._get_current_history_index(navigator)
self._history_scroll_offset = 0
def _handle_history_key(self, key: int, navigator: Navigator) -> None:
history_entries = navigator.history.entries()
@@ -214,6 +228,64 @@ class TerminalUI:
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
navigator.select_history_entry(self._history_selected_index)
self._history_mode = False
self._tree_scroll_offset = 0
def _handle_tree_key(self, key: int, navigator: Navigator) -> Path | None:
if key == curses.KEY_UP:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=-1,
entry_count=len(navigator.visible_entries),
)
)
return None
if key == curses.KEY_DOWN:
navigator.set_selected_index(
move_selection(
navigator.selected_index,
step=1,
entry_count=len(navigator.visible_entries),
)
)
return None
if key == curses.KEY_RIGHT:
was_expanded = navigator.selected_entry.is_expanded
if not navigator.expand_selected_directory():
navigator.enter_selected_directory()
self._tree_scroll_offset = 0
return None
if was_expanded:
navigator.enter_selected_directory()
self._tree_scroll_offset = 0
return None
if key == curses.KEY_LEFT:
if not navigator.collapse_selected_directory():
navigator.go_to_parent_directory()
self._tree_scroll_offset = 0
return None
if key == ord("b"):
navigator.go_back()
self._tree_scroll_offset = 0
return None
if key == ord("f"):
navigator.go_forward()
self._tree_scroll_offset = 0
return None
if key in (curses.KEY_ENTER, 10, 13):
return navigator.selected_entry.path
return None
def _get_current_history_index(self, navigator: Navigator) -> int:
history_entries = navigator.history.entries()

View File

@@ -10,6 +10,7 @@ from app.ui import (
TerminalUI,
build_history_lines,
build_render_lines,
clamp_scroll_offset,
format_entry,
move_selection,
open_terminal_streams,
@@ -79,6 +80,18 @@ def test_build_history_lines_marks_selected_history_entry(tmp_path: Path) -> Non
]
def test_clamp_scroll_offset_keeps_selection_visible_when_moving_down() -> None:
assert clamp_scroll_offset(5, scroll_offset=0, visible_count=3, total_count=10) == 3
def test_clamp_scroll_offset_keeps_selection_visible_when_moving_up() -> None:
assert clamp_scroll_offset(1, scroll_offset=4, visible_count=3, total_count=10) == 1
def test_clamp_scroll_offset_returns_zero_when_all_items_fit() -> None:
assert clamp_scroll_offset(2, scroll_offset=1, visible_count=5, total_count=3) == 0
def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> None:
root = tmp_path / "root"
child = root / "child"
@@ -134,6 +147,80 @@ def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None
assert navigator.current_path == root.resolve()
def test_right_arrow_expands_collapsed_directory(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
scripts = workspace / "scripts"
build = scripts / "build"
workspace.mkdir()
scripts.mkdir()
build.mkdir()
navigator = Navigator(workspace)
navigator.set_selected_index(1)
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None
assert navigator.current_path == workspace.resolve()
assert [entry.name for entry in navigator.visible_entries] == [
"..",
"scripts",
"build",
]
def test_right_arrow_enters_already_expanded_directory(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
scripts = workspace / "scripts"
build = scripts / "build"
workspace.mkdir()
scripts.mkdir()
build.mkdir()
navigator = Navigator(workspace)
navigator.set_selected_index(1)
navigator.expand_selected_directory()
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_RIGHT, navigator) is None
assert navigator.current_path == scripts.resolve()
assert navigator.selected_index == 0
def test_left_arrow_goes_to_parent_when_selected_entry_is_collapsed(
tmp_path: Path,
) -> None:
parent = tmp_path / "parent"
current = parent / "current"
child = current / "child"
parent.mkdir()
current.mkdir()
child.mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_LEFT, navigator) is None
assert navigator.current_path == parent.resolve()
assert navigator.selected_index == 0
def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
child = workspace / "child"
workspace.mkdir()
child.mkdir()
navigator = Navigator(workspace)
navigator.set_selected_index(1)
ui = TerminalUI()
result = ui._handle_tree_key(10, navigator)
assert result == child.resolve()
assert navigator.current_path == workspace.resolve()
def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None: