2 Commits
v0.2.6 ... main

12 changed files with 1197 additions and 59 deletions

View File

@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
## [0.3.0] - 2026-04-06
### Added
- Two-line compact header with live session state: version, history count, filter state, hidden/files toggles, and development summary
- Persistent history storage between sessions with configurable state path and bounded history size
- History list rendering in most-recent-first order
- History filter mode (`/` inside history) with dedicated query and navigation behavior
- Development info panel (`i`) with git context, detected stack, and suggested commands
- Configurable open-with menu order via `config.toml`:
- `open_with.files`
- `open_with.directories`
- Automatic creation of opener config template when `config.toml` is missing
- New `opencode` opener support for directory actions
### Improved
- Filter label highlighting now works consistently in both tree and history filter modes
- UI color behavior is now explicitly normalized to avoid terminal color bleeding
- History selection and filtering logic aligned with reversed (most-recent-first) history rendering
## [0.2.6] - 2026-04-04
### Added

View File

@@ -23,12 +23,15 @@ Working in the terminal often means:
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
- 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
- 📜 Persistent history between sessions (`h` history mode, `b` back, `f` forward)
- 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
- 🧭 History filter mode (`/` inside history) with most-recent-first ordering
- 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
- ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
- 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
- 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
- 🧩 Configurable app menu via `config.toml` with automatic template creation
- 🛠 Development snapshot in header + detailed info panel (`i`)
- 👀 Toggle hidden entries with `.`
- 🖥 Native terminal interface
- 🔁 Works with Bash, Zsh and other shells
@@ -49,6 +52,7 @@ Normal mode:
- `a`: Toggle file visibility
- `/`: Enter filter mode
- `o`: Open selected entry with an app
- `i`: Toggle development info panel
- `PgUp` / `PgDn`: Page jump
- `Home` / `End`: Jump to first/last entry
- `g` / `G`: Jump to first/last entry
@@ -65,10 +69,19 @@ Filter mode:
History mode:
- `↑` / `↓`: Move through history entries
- `↑` / `↓`: Move through history entries (most recent at top)
- `Enter`: Jump to selected history path
- `Esc` or `h`: Exit history mode
- `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
- `/`: Enter history filter mode
History filter mode:
- Type text: Filter history entries in real time (case-insensitive)
- `↑` / `↓`: Move inside filtered history results
- `Enter`: Jump to selected history path
- `Esc`: Exit history filter mode and clear query
- `Backspace`: Remove last filter character
Open with menu (`o`):
@@ -79,7 +92,26 @@ Open with menu (`o`):
The menu only shows apps available in your system (`PATH`) and context:
- GUI options (when GUI is available): `code`, system `open`/`xdg-open`
- Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
- Terminal options: `antigravity`, `opencode`, `nvim`, `nano`, `less`, `bat` (as applicable)
## Opener Configuration
`cd-browser` reads opener settings from:
- macOS/Linux: `~/.config/cd-browser/config.toml`
- Windows: `%APPDATA%/cd-browser/config.toml`
If the file does not exist, `cd-browser` creates it automatically with defaults.
Example:
```toml
[open_with]
files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]
directories = ["code", "opencode", "antigravity", "nvim"]
```
You can reorder or remove entries to customize app priority.
## Quick Demo

34
docs/logo-galicia-ansi-demo.zsh Executable file
View File

@@ -0,0 +1,34 @@
#!/bin/zsh
set +H
blue=$'\033[38;2;0;114;206m'
white=$'\033[38;2;245;245;245m'
reset=$'\033[0m'
print_line() {
local r="$1"
local line="$2"
local out=""
local c ch center band
center=$((6 + r*12))
band=6
for ((c=1; c<=${#line}; c++)); do
ch="${line[c]}"
if [[ "$ch" == " " ]]; then
out+=" "
elif (( c-1 >= center-band && c-1 <= center+band )); then
out+="${blue}${ch}${reset}"
else
out+="${white}${ch}${reset}"
fi
done
print -r -- "$out"
}
print_line 0 " █ ███ "
print_line 1 " █ █ █ ██ ██ "
print_line 2 " ███ ███ ███ █/█ ██ █ █ █_ █__█ █/█"
print_line 3 "█ █ █ ███ █ █ █ █ █ █ █ █ █ █ █"
print_line 4 " ███ ███ ███ █ ██ █ █ ██ ██ █"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "cd-browser"
version = "0.2.6"
version = "0.3.0"
description = "A fast keyboard-driven directory navigator for the terminal."
readme = "README.md"
requires-python = ">=3.8"

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path
from app.history import SessionHistory
from app.navigator import Navigator
from app.ui import TerminalUI
@@ -10,6 +11,6 @@ def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Pat
"""Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve()
navigator = Navigator(initial_path)
navigator = Navigator(initial_path, history=SessionHistory.persistent())
terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator)

168
src/app/devinfo.py Normal file
View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class DevSnapshot:
"""Development context for a path."""
repo_root: Path | None
git_branch: str | None
git_dirty: bool
last_commit: str | None
stack: tuple[str, ...]
commands: tuple[str, ...]
@property
def header_summary(self) -> str:
git_part = "git:-"
if self.git_branch:
dirty_marker = "*" if self.git_dirty else ""
git_part = f"git:{self.git_branch}{dirty_marker}"
stack_part = ",".join(self.stack[:2]) if self.stack else "-"
return f"{git_part} stack:{stack_part}"
def detail_lines(self, current_path: Path) -> list[str]:
lines = [f"Current path: {current_path}"]
if self.repo_root is None:
lines.append("Git: not a repository")
else:
dirty_text = "dirty" if self.git_dirty else "clean"
lines.append(f"Git root: {self.repo_root}")
lines.append(f"Git branch: {self.git_branch or '-'} ({dirty_text})")
if self.last_commit:
lines.append(f"Last commit: {self.last_commit}")
stack_text = ", ".join(self.stack) if self.stack else "-"
lines.append(f"Detected stack: {stack_text}")
if self.commands:
lines.append("Suggested commands:")
for command in self.commands:
lines.append(f" - {command}")
return lines
class DevInfoCache:
"""Simple TTL cache for development snapshots."""
def __init__(self, ttl_seconds: float = 3.0) -> None:
self._ttl_seconds = ttl_seconds
self._cached_path: Path | None = None
self._cached_at: float = 0.0
self._cached_snapshot: DevSnapshot | None = None
def get(self, path: Path) -> DevSnapshot:
resolved_path = path.expanduser().resolve()
now = time.monotonic()
if (
self._cached_snapshot is not None
and self._cached_path == resolved_path
and (now - self._cached_at) <= self._ttl_seconds
):
return self._cached_snapshot
snapshot = collect_dev_snapshot(resolved_path)
self._cached_path = resolved_path
self._cached_snapshot = snapshot
self._cached_at = now
return snapshot
def collect_dev_snapshot(path: Path) -> DevSnapshot:
"""Collect git and stack metadata for a path."""
repo_root = _git_repo_root(path)
git_branch: str | None = None
git_dirty = False
last_commit: str | None = None
if repo_root is not None:
git_branch = _run_command(
["git", "-C", str(repo_root), "rev-parse", "--abbrev-ref", "HEAD"]
)
status_output = _run_command(
["git", "-C", str(repo_root), "status", "--porcelain"]
)
git_dirty = bool(status_output)
last_commit = _run_command(
["git", "-C", str(repo_root), "log", "-1", "--pretty=format:%h %s"]
)
stack = _detect_stack(path)
return DevSnapshot(
repo_root=repo_root,
git_branch=git_branch,
git_dirty=git_dirty,
last_commit=last_commit,
stack=stack,
commands=_suggested_commands(stack, repo_root is not None),
)
def _git_repo_root(path: Path) -> Path | None:
result = _run_command(["git", "-C", str(path), "rev-parse", "--show-toplevel"])
if not result:
return None
return Path(result).expanduser().resolve()
def _detect_stack(path: Path) -> tuple[str, ...]:
markers = (
("pyproject.toml", "python"),
("requirements.txt", "python"),
("package.json", "node"),
("go.mod", "go"),
("Cargo.toml", "rust"),
)
found: list[str] = []
for marker, stack_name in markers:
if (path / marker).exists() and stack_name not in found:
found.append(stack_name)
return tuple(found)
def _suggested_commands(stack: tuple[str, ...], has_git: bool) -> tuple[str, ...]:
commands: list[str] = []
if has_git:
commands.append("git status -sb")
if "python" in stack:
commands.extend(["pytest -q", "ruff check src tests"])
if "node" in stack:
commands.extend(["npm test", "npm run lint"])
if "go" in stack:
commands.append("go test ./...")
if "rust" in stack:
commands.append("cargo test")
return tuple(commands)
def _run_command(command: list[str]) -> str | None:
try:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=0.35,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
output = result.stdout.strip()
if not output:
return None
return output

View File

@@ -1,15 +1,46 @@
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
def default_history_path() -> Path:
"""Return the default on-disk history location."""
override = os.environ.get("CD_BROWSER_HISTORY_FILE")
if override:
return Path(override).expanduser()
if os.name == "nt":
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
else:
base = Path(os.environ.get("XDG_STATE_HOME", str(Path.home() / ".local/state")))
return base / "cd-browser" / "history.txt"
@dataclass(slots=True)
class SessionHistory:
"""In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list)
_index: int = -1
storage_path: Path | None = None
max_entries: int = 500
@classmethod
def persistent(
cls, storage_path: Path | None = None, max_entries: int = 500
) -> SessionHistory:
"""Create a history instance backed by an on-disk file."""
history = cls(
storage_path=storage_path or default_history_path(),
max_entries=max_entries,
)
history._load()
return history
def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed."""
@@ -23,7 +54,9 @@ class SessionHistory:
self._entries = self._entries[: self._index + 1]
self._entries.append(resolved_path)
self._trim_to_limit()
self._index = len(self._entries) - 1
self._persist()
return resolved_path
@property
@@ -66,3 +99,48 @@ class SessionHistory:
"""Return the full history as an immutable sequence."""
return tuple(self._entries)
def _load(self) -> None:
if self.storage_path is None:
return
try:
lines = self.storage_path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
return
except OSError:
return
loaded_entries: list[Path] = []
for line in lines:
raw = line.strip()
if not raw:
continue
resolved = Path(raw).expanduser().resolve()
if loaded_entries and loaded_entries[-1] == resolved:
continue
loaded_entries.append(resolved)
self._entries = loaded_entries
self._trim_to_limit()
self._index = len(self._entries) - 1
def _persist(self) -> None:
if self.storage_path is None:
return
try:
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
contents = "".join(f"{entry}\n" for entry in self._entries)
self.storage_path.write_text(contents, encoding="utf-8")
except OSError:
return
def _trim_to_limit(self) -> None:
limit = max(1, self.max_entries)
if len(self._entries) <= limit:
return
self._entries = self._entries[-limit:]

View File

@@ -6,10 +6,18 @@ import shutil
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Literal
try:
import tomllib
except ModuleNotFoundError: # pragma: no cover - python < 3.11
tomllib = None # type: ignore[assignment]
LaunchMode = Literal["background", "blocking"]
DEFAULT_FILE_OPENERS = ("code", "open", "antigravity", "nvim", "nano", "less", "bat")
DEFAULT_DIRECTORY_OPENERS = ("code", "opencode", "antigravity", "nvim")
@dataclass(frozen=True, slots=True)
@@ -31,8 +39,11 @@ def available_openers(path: Path) -> list[OpenWithOption]:
is_directory = path.is_dir()
has_gui = _has_gui_session()
options: list[OpenWithOption] = []
catalog = _options_catalog()
configured_option_ids = _configured_option_ids(is_directory, catalog)
for option in _default_options():
for option_id in configured_option_ids:
option = catalog[option_id]
if is_directory and not option.supports_directories:
continue
if not is_directory and not option.supports_files:
@@ -89,16 +100,16 @@ def is_blocking_option(option: OpenWithOption) -> bool:
return option.launch_mode == "blocking"
def _default_options() -> tuple[OpenWithOption, ...]:
return (
OpenWithOption(
def _options_catalog() -> dict[str, OpenWithOption]:
return {
"code": OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
),
OpenWithOption(
"open": OpenWithOption(
label="Open (system)",
command_template=_system_open_template(),
executable=_system_open_executable(),
@@ -106,40 +117,151 @@ def _default_options() -> tuple[OpenWithOption, ...]:
requires_gui=True,
supports_directories=False,
),
OpenWithOption(
"opencode": OpenWithOption(
label="OpenCode",
command_template="opencode {path}",
executable="opencode",
launch_mode="background",
supports_files=False,
),
"antigravity": OpenWithOption(
label="Antigravity",
command_template="antigravity {path}",
executable="antigravity",
launch_mode="background",
),
OpenWithOption(
"nvim": OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
),
OpenWithOption(
"nano": OpenWithOption(
label="Nano",
command_template="nano {path}",
executable="nano",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
"less": OpenWithOption(
label="Less",
command_template="less {path}",
executable="less",
launch_mode="blocking",
supports_directories=False,
),
OpenWithOption(
"bat": OpenWithOption(
label="Bat",
command_template="bat {path}",
executable="bat",
launch_mode="blocking",
supports_directories=False,
),
}
def _configured_option_ids(
is_directory: bool, catalog: dict[str, OpenWithOption]
) -> list[str]:
config = _load_openers_config()
defaults = (
list(DEFAULT_DIRECTORY_OPENERS) if is_directory else list(DEFAULT_FILE_OPENERS)
)
configured = config["directories"] if is_directory else config["files"]
seen: set[str] = set()
option_ids: list[str] = []
for option_id in configured:
if option_id not in catalog or option_id in seen:
continue
seen.add(option_id)
option_ids.append(option_id)
if option_ids:
return option_ids
return [option_id for option_id in defaults if option_id in catalog]
def _openers_config_path() -> Path:
override = os.environ.get("CD_BROWSER_CONFIG_FILE")
if override:
return Path(override).expanduser()
if os.name == "nt":
base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
return base / "cd-browser" / "config.toml"
@lru_cache(maxsize=1)
def _load_openers_config() -> dict[str, list[str]]:
defaults = {
"files": list(DEFAULT_FILE_OPENERS),
"directories": list(DEFAULT_DIRECTORY_OPENERS),
}
config_path = _openers_config_path()
_ensure_default_config_file(config_path)
if tomllib is None or not config_path.exists():
return defaults
try:
raw_data = tomllib.loads(config_path.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError):
return defaults
if not isinstance(raw_data, dict):
return defaults
open_with = raw_data.get("open_with")
if not isinstance(open_with, dict):
return defaults
files = open_with.get("files")
directories = open_with.get("directories")
return {
"files": _normalize_ids(files, defaults["files"]),
"directories": _normalize_ids(directories, defaults["directories"]),
}
def _normalize_ids(value: object, fallback: list[str]) -> list[str]:
if not isinstance(value, list):
return fallback
normalized = [
item.strip() for item in value if isinstance(item, str) and item.strip()
]
return normalized if normalized else fallback
def _ensure_default_config_file(path: Path) -> None:
if path.exists():
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_default_config_toml(), encoding="utf-8")
except OSError:
return
def _default_config_toml() -> str:
file_ids = '", "'.join(DEFAULT_FILE_OPENERS)
directory_ids = '", "'.join(DEFAULT_DIRECTORY_OPENERS)
return (
"# cd-browser opener configuration\n"
"# Reorder or remove items to customize menu priority.\n"
"[open_with]\n"
f'files = ["{file_ids}"]\n'
f'directories = ["{directory_ids}"]\n'
)
def _reset_openers_config_cache() -> None:
_load_openers_config.cache_clear()
def _has_gui_session() -> bool:

View File

@@ -6,9 +6,11 @@ import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from importlib import metadata
from pathlib import Path
from typing import TextIO
from app.devinfo import DevInfoCache, DevSnapshot
from app.navigator import Navigator, VisibleEntry
from app.opener import (
OpenWithOption,
@@ -20,6 +22,18 @@ from app.opener import (
ESCAPE_KEY = 27
BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8)
DEFAULT_APP_VERSION = "0.3.0"
FILTER_COLOR_PAIR = 1
NORMAL_COLOR_PAIR = 2
def _app_version() -> str:
"""Return installed package version when available."""
try:
return metadata.version("cd-browser")
except metadata.PackageNotFoundError:
return DEFAULT_APP_VERSION
@dataclass(frozen=True, slots=True)
@@ -88,6 +102,31 @@ def build_history_lines(
]
def build_status_line(
navigator: Navigator, filter_mode: bool, filter_query: str, dev_summary: str
) -> str:
"""Build first header line with version and live session toggles."""
hidden_status = "on" if navigator.show_hidden else "off"
file_status = "on" if navigator.show_files else "off"
if filter_mode:
filter_segment = f"filter: {filter_query}" if filter_query else "filter:"
else:
filter_segment = "filter: off"
history_count = len(navigator.history.entries())
return (
f"cd-browser v{_app_version()} | hist: {history_count} | "
f"{filter_segment} | hidden: {hidden_status} | files: {file_status} | "
f"dev: {dev_summary}"
)
def build_path_line(path: Path) -> str:
"""Build second header line with current path."""
return f"path: {path}"
def clamp_scroll_offset(
selected_index: int, scroll_offset: int, visible_count: int, total_count: int
) -> int:
@@ -116,11 +155,19 @@ class TerminalUI:
self._history_selected_index = 0
self._tree_scroll_offset = 0
self._history_scroll_offset = 0
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._info_mode = False
self._filter_mode = False
self._filter_query = ""
self._filter_selected_index = 0
self._filter_scroll_offset = 0
self._status_message: str | None = None
self._normal_attr = curses.A_NORMAL
self._filter_label_attr = curses.A_BOLD
self._dev_info = DevInfoCache()
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
@@ -132,12 +179,17 @@ class TerminalUI:
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0)
stdscr.keypad(True)
self._initialize_colors()
original_path = navigator.current_path
while True:
self._render(stdscr, navigator)
key = stdscr.getch()
if self._info_mode:
self._handle_info_key(key)
continue
if self._history_mode:
self._handle_history_key(key, navigator, stdscr=stdscr)
continue
@@ -160,31 +212,51 @@ class TerminalUI:
return tree_result
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.attrset(self._normal_attr)
stdscr.erase()
height, width = stdscr.getmaxyx()
snapshot = self._dev_info.get(navigator.current_path)
if self._history_mode:
self._render_history_mode(stdscr, navigator, width=width, height=height)
self._render_history_mode(
stdscr,
navigator,
snapshot=snapshot,
width=width,
height=height,
)
stdscr.refresh()
return
hidden_status = "on" if navigator.show_hidden else "off"
file_status = "on" if navigator.show_files else "off"
path_text = (
f"{navigator.current_path} [hidden: {hidden_status}] [files: {file_status}]"
if self._info_mode:
self._render_info_mode(
stdscr,
navigator,
snapshot=snapshot,
width=width,
height=height,
)
stdscr.addnstr(0, 0, path_text, max(0, width - 1))
stdscr.refresh()
return
start_row = 2
if self._filter_mode:
stdscr.addnstr(
1,
0,
f"Filter: {self._filter_query}",
max(0, width - 1),
curses.A_BOLD,
status_line = build_status_line(
navigator,
filter_mode=self._filter_mode,
filter_query=self._filter_query,
dev_summary=snapshot.header_summary,
)
start_row = 3
path_line = build_path_line(navigator.current_path)
self._render_status_line(
stdscr,
0,
width,
status_line,
filter_active=self._filter_mode,
)
stdscr.addnstr(
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
)
start_row = 2
lines, selected_index = self._build_tree_lines_for_render(navigator)
footer_rows = 1 if self._status_message else 0
@@ -208,7 +280,13 @@ class TerminalUI:
scroll_offset = self._tree_scroll_offset
if self._filter_mode and self._filter_query and not lines:
stdscr.addnstr(start_row, 0, "No matches", max(0, width - 1), curses.A_DIM)
stdscr.addnstr(
start_row,
0,
"No matches",
max(0, width - 1),
self._normal_attr | curses.A_DIM,
)
else:
for row, line in enumerate(
lines[scroll_offset : scroll_offset + visible_count],
@@ -217,7 +295,11 @@ class TerminalUI:
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
attributes = (
self._normal_attr | curses.A_REVERSE
if line.is_selected
else self._normal_attr
)
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if self._status_message and height > 0:
@@ -226,7 +308,7 @@ class TerminalUI:
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
self._normal_attr | curses.A_DIM,
)
stdscr.refresh()
@@ -256,44 +338,99 @@ class TerminalUI:
], self._filter_selected_index
def _render_history_mode(
self, stdscr: curses.window, navigator: Navigator, width: int, height: int
self,
stdscr: curses.window,
navigator: Navigator,
snapshot: DevSnapshot,
width: int,
height: int,
) -> None:
history_lines = build_history_lines(
navigator.history.entries(), self._history_selected_index
stdscr.attrset(self._normal_attr)
status_line = build_status_line(
navigator,
filter_mode=self._history_filter_mode,
filter_query=self._history_filter_query,
dev_summary=snapshot.header_summary,
)
start_row = 1
path_line = build_path_line(navigator.current_path)
self._render_status_line(
stdscr,
0,
width,
status_line,
filter_active=self._history_filter_mode,
)
stdscr.addnstr(
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
)
history_entries = navigator.history.entries()
history_lines, selected_index = self._build_history_lines_for_render(
history_entries
)
start_row = 2
footer_rows = 1 if self._status_message else 0
visible_count = max(0, height - start_row - 1 - footer_rows)
if self._history_filter_mode:
self._history_filter_scroll_offset = clamp_scroll_offset(
selected_index,
self._history_filter_scroll_offset,
visible_count,
len(history_lines),
)
scroll_offset = self._history_filter_scroll_offset
else:
self._history_scroll_offset = clamp_scroll_offset(
self._history_selected_index,
selected_index,
self._history_scroll_offset,
visible_count,
len(history_lines),
)
scroll_offset = self._history_scroll_offset
stdscr.addnstr(start_row, 0, "History:", max(0, width - 1), curses.A_BOLD)
stdscr.addnstr(
start_row,
0,
"History:",
max(0, width - 1),
self._normal_attr | curses.A_BOLD,
)
for index, line in enumerate(
history_lines[
self._history_scroll_offset : self._history_scroll_offset
+ visible_count
],
history_lines[scroll_offset : scroll_offset + visible_count],
start=1,
):
row = start_row + index
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
attributes = (
self._normal_attr | curses.A_REVERSE
if line.is_selected
else self._normal_attr
)
stdscr.addnstr(row, 0, line.text, max(0, width - 1), attributes)
if (
self._history_filter_mode
and self._history_filter_query
and not history_lines
):
stdscr.addnstr(
start_row + 1,
0,
"No matches",
max(0, width - 1),
self._normal_attr | curses.A_DIM,
)
if self._status_message and height > 0:
stdscr.addnstr(
height - 1,
0,
self._status_message,
max(0, width - 1),
curses.A_DIM,
self._normal_attr | curses.A_DIM,
)
def _toggle_history_mode(self, navigator: Navigator) -> None:
@@ -302,14 +439,213 @@ class TerminalUI:
if self._history_mode:
self._history_selected_index = self._get_current_history_index(navigator)
self._history_scroll_offset = 0
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
def _render_info_mode(
self,
stdscr: curses.window,
navigator: Navigator,
snapshot: DevSnapshot,
width: int,
height: int,
) -> None:
status_line = build_status_line(
navigator,
filter_mode=False,
filter_query="",
dev_summary=snapshot.header_summary,
)
path_line = build_path_line(navigator.current_path)
self._render_status_line(stdscr, 0, width, status_line, filter_active=False)
stdscr.addnstr(
1, 0, path_line, max(0, width - 1), self._normal_attr | curses.A_DIM
)
stdscr.addnstr(
2,
0,
"Dev info (Esc/i to close)",
max(0, width - 1),
self._normal_attr | curses.A_BOLD,
)
detail_lines = snapshot.detail_lines(navigator.current_path)
for row, line in enumerate(detail_lines, start=3):
if row >= height:
break
stdscr.addnstr(row, 0, line, max(0, width - 1), self._normal_attr)
def _initialize_colors(self) -> None:
self._normal_attr = curses.A_NORMAL
self._filter_label_attr = curses.A_BOLD
if not curses.has_colors():
return
try:
curses.start_color()
curses.use_default_colors()
curses.init_pair(NORMAL_COLOR_PAIR, curses.COLOR_WHITE, -1)
curses.init_pair(FILTER_COLOR_PAIR, curses.COLOR_GREEN, -1)
self._normal_attr = curses.color_pair(NORMAL_COLOR_PAIR)
self._filter_label_attr = (
curses.color_pair(FILTER_COLOR_PAIR) | curses.A_BOLD
)
except curses.error:
self._normal_attr = curses.A_NORMAL
self._filter_label_attr = curses.A_BOLD
def _render_status_line(
self,
stdscr: curses.window,
row: int,
width: int,
status_line: str,
filter_active: bool,
) -> None:
max_width = max(0, width - 1)
if not filter_active:
stdscr.attrset(self._normal_attr)
stdscr.addnstr(row, 0, status_line, max_width)
stdscr.attrset(self._normal_attr)
return
prefix, marker, suffix = status_line.partition("filter:")
if not marker:
stdscr.attrset(self._normal_attr)
stdscr.addnstr(row, 0, status_line, max_width)
stdscr.attrset(self._normal_attr)
return
x = 0
stdscr.attrset(self._normal_attr)
stdscr.addnstr(row, x, prefix, max_width)
x += min(len(prefix), max_width)
if x >= max_width:
return
marker_width = max_width - x
stdscr.attrset(self._filter_label_attr)
stdscr.addnstr(row, x, marker, marker_width)
x += min(len(marker), marker_width)
if x >= max_width:
return
stdscr.attrset(self._normal_attr)
stdscr.addnstr(row, x, suffix, max_width - x)
stdscr.attrset(self._normal_attr)
def _handle_history_key(
self, key: int, navigator: Navigator, stdscr: curses.window | None = None
) -> None:
history_entries = navigator.history.entries()
matches = self._matching_history_indices(history_entries)
if self._history_filter_mode:
if key == ESCAPE_KEY:
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
if key in (curses.KEY_ENTER, 10, 13):
if not matches:
return
selected_entry_index = matches[self._history_filter_selected_index]
navigator.select_history_entry(selected_entry_index)
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._tree_scroll_offset = 0
return
if key == curses.KEY_UP:
self._history_filter_selected_index = move_selection(
self._history_filter_selected_index,
step=-1,
entry_count=len(matches),
)
return
if key == curses.KEY_DOWN:
self._history_filter_selected_index = move_selection(
self._history_filter_selected_index,
step=1,
entry_count=len(matches),
)
return
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=3)
self._history_filter_selected_index = move_selection(
self._history_filter_selected_index,
step=step,
entry_count=len(matches),
)
return
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=3)
self._history_filter_selected_index = move_selection(
self._history_filter_selected_index,
step=step,
entry_count=len(matches),
)
return
if key == curses.KEY_HOME:
self._history_filter_selected_index = 0
return
if key == curses.KEY_END and matches:
self._history_filter_selected_index = len(matches) - 1
return
if key in BACKSPACE_KEYS:
self._history_filter_query = self._history_filter_query[:-1]
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
if 32 <= key <= 126:
self._history_filter_query += chr(key)
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
if key in (ESCAPE_KEY, ord("h")):
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
if key == ord("i"):
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._info_mode = True
return
if key == ord("/"):
self._history_filter_mode = True
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
return
if key == curses.KEY_UP:
@@ -329,7 +665,7 @@ class TerminalUI:
return
if key == curses.KEY_PPAGE:
step = -self._page_step(stdscr, start_row=1)
step = -self._page_step(stdscr, start_row=3)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
@@ -338,7 +674,7 @@ class TerminalUI:
return
if key == curses.KEY_NPAGE:
step = self._page_step(stdscr, start_row=1)
step = self._page_step(stdscr, start_row=3)
self._history_selected_index = move_selection(
self._history_selected_index,
step=step,
@@ -355,8 +691,15 @@ class TerminalUI:
return
if key in (curses.KEY_ENTER, 10, 13) and history_entries:
navigator.select_history_entry(self._history_selected_index)
selected_entry_index = self._history_display_indices(history_entries)[
self._history_selected_index
]
navigator.select_history_entry(selected_entry_index)
self._history_mode = False
self._history_filter_mode = False
self._history_filter_query = ""
self._history_filter_selected_index = 0
self._history_filter_scroll_offset = 0
self._tree_scroll_offset = 0
def _handle_filter_key(
@@ -560,11 +903,19 @@ class TerminalUI:
self._open_with_selected(navigator, stdscr)
return None
if key == ord("i"):
self._info_mode = True
return None
if key in (curses.KEY_ENTER, 10, 13):
return navigator.selected_entry.path
return None
def _handle_info_key(self, key: int) -> None:
if key in (ESCAPE_KEY, ord("i")):
self._info_mode = False
def _matching_indices(self, entries: tuple[VisibleEntry, ...]) -> list[int]:
if not self._filter_query:
return list(range(len(entries)))
@@ -576,6 +927,57 @@ class TerminalUI:
if query in entry.name.casefold()
]
def _matching_history_indices(self, entries: tuple[Path, ...]) -> list[int]:
display_indices = self._history_display_indices(entries)
if not self._history_filter_query:
return display_indices
query = self._history_filter_query.casefold()
return [
entry_index
for entry_index in display_indices
if query in str(entries[entry_index]).casefold()
]
def _build_history_lines_for_render(
self, entries: tuple[Path, ...]
) -> tuple[list[HistoryLine], int]:
if not self._history_filter_mode:
display_indices = self._history_display_indices(entries)
if not display_indices:
self._history_selected_index = 0
return [], 0
self._history_selected_index = max(
0, min(self._history_selected_index, len(display_indices) - 1)
)
return [
HistoryLine(
text=str(entries[entry_index]),
is_selected=index == self._history_selected_index,
)
for index, entry_index in enumerate(display_indices)
], self._history_selected_index
matches = self._matching_history_indices(entries)
if not matches:
self._history_filter_selected_index = 0
return [], 0
self._history_filter_selected_index = max(
0, min(self._history_filter_selected_index, len(matches) - 1)
)
return [
HistoryLine(
text=str(entries[entry_index]),
is_selected=match_index == self._history_filter_selected_index,
)
for match_index, entry_index in enumerate(matches)
], self._history_filter_selected_index
def _history_display_indices(self, entries: tuple[Path, ...]) -> list[int]:
return list(range(len(entries) - 1, -1, -1))
def _page_step(self, stdscr: curses.window | None, start_row: int) -> int:
if stdscr is None:
return 10
@@ -710,7 +1112,7 @@ class TerminalUI:
for index in range(len(history_entries) - 1, -1, -1):
if history_entries[index] == navigator.current_path:
return index
return len(history_entries) - index - 1
return 0

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import pytest
from app.history import SessionHistory
from app.history import SessionHistory, default_history_path
def test_visit_records_current_directory(tmp_path: Path) -> None:
@@ -82,3 +82,48 @@ def test_select_raises_for_invalid_index() -> None:
with pytest.raises(IndexError):
history.select(0)
def test_persistent_history_loads_entries_from_disk(tmp_path: Path) -> None:
first = tmp_path / "first"
second = tmp_path / "second"
for directory in (first, second):
directory.mkdir()
history_file = tmp_path / "history.txt"
history_file.write_text(f"{first}\n{second}\n", encoding="utf-8")
history = SessionHistory.persistent(storage_path=history_file)
assert history.entries() == (first.resolve(), second.resolve())
assert history.current == second.resolve()
def test_persistent_history_visit_writes_to_disk_and_applies_limit(
tmp_path: Path,
) -> None:
first = tmp_path / "first"
second = tmp_path / "second"
third = tmp_path / "third"
for directory in (first, second, third):
directory.mkdir()
history_file = tmp_path / "state" / "history.txt"
history = SessionHistory.persistent(storage_path=history_file, max_entries=2)
history.visit(first)
history.visit(second)
history.visit(third)
assert history.entries() == (second.resolve(), third.resolve())
assert history_file.read_text(encoding="utf-8").splitlines() == [
str(second.resolve()),
str(third.resolve()),
]
def test_default_history_path_uses_override_env(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
override = tmp_path / "custom-history.txt"
monkeypatch.setenv("CD_BROWSER_HISTORY_FILE", str(override))
assert default_history_path() == override

View File

@@ -7,6 +7,7 @@ import pytest
from app.opener import (
OpenWithOption,
_reset_openers_config_cache,
available_openers,
is_blocking_option,
launch_background_option,
@@ -14,6 +15,11 @@ from app.opener import (
)
@pytest.fixture(autouse=True)
def reset_openers_config_cache() -> None:
_reset_openers_config_cache()
def test_available_openers_headless_file_excludes_gui(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@@ -65,6 +71,107 @@ def test_available_openers_gui_directory_filters_file_only_tools(
assert labels == ["VS Code", "Antigravity", "Neovim"]
def test_available_openers_respects_configured_order(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text(
('[open_with]\nfiles = ["nvim", "code"]\ndirectories = ["opencode", "code"]\n'),
encoding="utf-8",
)
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr("app.opener.sys.platform", "darwin")
available = {
"opencode": "/usr/local/bin/opencode",
"nvim": "/usr/local/bin/nvim",
"code": "/usr/local/bin/code",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
folder = tmp_path / "project"
folder.mkdir()
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
folder_labels = [option.label for option in available_openers(folder)]
file_labels = [option.label for option in available_openers(file_path)]
assert folder_labels == ["OpenCode", "VS Code"]
assert file_labels == ["Neovim", "VS Code"]
def test_available_openers_invalid_config_falls_back_to_defaults(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
config_path.write_text("[open_with\n", encoding="utf-8")
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
available = {
"antigravity": "/usr/local/bin/antigravity",
"nvim": "/usr/local/bin/nvim",
"nano": "/usr/bin/nano",
"less": "/usr/bin/less",
"bat": "/usr/local/bin/bat",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
labels = [option.label for option in available_openers(file_path)]
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
def test_available_openers_creates_default_config_when_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
monkeypatch.setattr(
"app.opener.shutil.which",
lambda name: (
"/usr/bin/true" if name in {"nvim", "nano", "less", "bat"} else None
),
)
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
available_openers(file_path)
assert config_path.exists()
config_text = config_path.read_text(encoding="utf-8")
assert "[open_with]" in config_text
assert (
'files = ["code", "open", "antigravity", "nvim", "nano", "less", "bat"]'
in config_text
)
def test_available_openers_does_not_override_existing_config(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config_path = tmp_path / "config.toml"
original = '[open_with]\nfiles = ["nvim"]\ndirectories = ["opencode"]\n'
config_path.write_text(original, encoding="utf-8")
monkeypatch.setenv("CD_BROWSER_CONFIG_FILE", str(config_path))
monkeypatch.setenv("SSH_CONNECTION", "1")
monkeypatch.setattr(
"app.opener.shutil.which",
lambda name: "/usr/bin/true" if name in {"nvim", "opencode"} else None,
)
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
available_openers(file_path)
assert config_path.read_text(encoding="utf-8") == original
def test_launch_background_option_returns_success(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:

View File

@@ -5,11 +5,13 @@ import pytest
from app.navigator import Navigator, VisibleEntry
from app.ui import (
ESCAPE_KEY,
HistoryLine,
RenderLine,
TerminalUI,
build_history_lines,
build_render_lines,
build_status_line,
clamp_scroll_offset,
format_entry,
move_selection,
@@ -119,7 +121,7 @@ def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> No
ui._toggle_history_mode(navigator)
assert ui._history_mode is True
assert ui._history_selected_index == 1
assert ui._history_selected_index == 0
def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
@@ -139,7 +141,7 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
ui = TerminalUI()
ui._toggle_history_mode(navigator)
ui._handle_history_key(curses.KEY_UP, navigator)
ui._handle_history_key(curses.KEY_DOWN, navigator)
ui._handle_history_key(10, navigator)
assert ui._history_mode is False
@@ -147,6 +149,86 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
assert navigator.selected_index == 0
def test_history_mode_renders_most_recent_entry_first(tmp_path: Path) -> None:
root = tmp_path / "root"
first = root / "first"
second = root / "second"
root.mkdir()
first.mkdir()
second.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
navigator.go_to_parent_directory()
navigator.set_selected_index(2)
navigator.enter_selected_directory()
ui = TerminalUI()
ui._toggle_history_mode(navigator)
lines, _selected_index = ui._build_history_lines_for_render(
navigator.history.entries()
)
assert lines[0].text == str(second.resolve())
def test_history_mode_slash_activates_history_filter() -> None:
ui = TerminalUI()
navigator = Navigator(Path.cwd())
ui._history_mode = True
ui._handle_history_key(ord("/"), navigator)
assert ui._history_filter_mode is True
assert ui._history_filter_query == ""
def test_history_filter_enter_selects_filtered_entry_and_exits_history(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
alpha = root / "alpha"
beta = root / "beta"
root.mkdir()
alpha.mkdir()
beta.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
navigator.go_to_parent_directory()
navigator.set_selected_index(2)
navigator.enter_selected_directory()
ui = TerminalUI()
ui._toggle_history_mode(navigator)
ui._history_filter_mode = True
ui._history_filter_query = "alpha"
ui._handle_history_key(10, navigator)
assert navigator.current_path == alpha.resolve()
assert ui._history_mode is False
assert ui._history_filter_mode is False
def test_history_filter_escape_disables_only_history_filter(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._toggle_history_mode(navigator)
ui._history_filter_mode = True
ui._history_filter_query = "ro"
ui._handle_history_key(ESCAPE_KEY, navigator)
assert ui._history_mode is True
assert ui._history_filter_mode is False
assert ui._history_filter_query == ""
def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
@@ -264,6 +346,40 @@ def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
assert matches == [1]
def test_build_status_line_shows_filter_off_when_filter_mode_is_disabled(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(
navigator,
filter_mode=False,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: off" in status_line
def test_build_status_line_shows_empty_filter_without_off_when_filter_is_active(
tmp_path: Path,
) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
status_line = build_status_line(
navigator,
filter_mode=True,
filter_query="",
dev_summary="git:- stack:-",
)
assert "filter: |" in status_line
def test_filter_mode_uses_g_and_g_upper_as_query_characters(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
@@ -340,6 +456,20 @@ def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None:
assert navigator.selected_index == len(navigator.visible_entries) - 1
def test_info_mode_toggles_with_i_and_escape(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
assert ui._info_mode is False
assert ui._handle_tree_key(ord("i"), navigator) is None
assert ui._info_mode is True
ui._handle_info_key(ESCAPE_KEY)
assert ui._info_mode is False
def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None: