Initial release: cd-browser v0.1.0

This commit is contained in:
2026-03-14 17:21:49 +01:00
commit f03417f2f2
39 changed files with 2351 additions and 0 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Application settings
APP_NAME="New Project"
APP_ENV="development"
APP_DEBUG=true
# Logging
LOG_LEVEL="INFO"
# Example external service configuration
API_URL="https://api.example.com"
API_KEY="your-api-key-here"
# Database example (optional)
DATABASE_URL="sqlite:///app.db"

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Virtual environments
.venv/
venv/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Tool caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Build artifacts
build/
dist/
*.egg-info/
# IDEs
.vscode/
.idea/
# OS files
.DS_Store
.env/

16
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
repos:
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.10
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: end-of-file-fixer

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11.9

19
CHANGELOG.md Normal file
View File

@@ -0,0 +1,19 @@
# Changelog
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.
## [1.0.0] - 2026-03-14
### Added
- Initial release of the Python AI Dev Template
- Universal Python project structure using `src/`
- `pyproject.toml`-based project configuration
- Development tooling with `black`, `ruff`, `mypy`, and `pytest`
- `pre-commit` integration
- `Makefile` with development, quality, and diagnostic commands
- `instructions-agent.md` for AI coding agent guidance
- `.env.example` for environment-based configuration
- README with setup, workflow, and template reuse instructions
- MIT license

181
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,181 @@
# Contributing
Thank you for your interest in contributing to this project.
This repository is intended to be a clean, reusable, and professional Python project template. Contributions should preserve that goal.
## General Principles
- Keep the template generic and adaptable.
- Prefer clarity over cleverness.
- Avoid unnecessary complexity.
- Follow the existing project structure and conventions.
- Write all code, comments, docstrings, and documentation in English.
## Development Setup
It is recommended to use the provided Makefile.
### First-time setup
```bash
make dev
# Contributing
Thank you for your interest in contributing to this project.
This repository is intended to be a **clean, reusable, and professional Python project template**.
All contributions should aim to **improve the template without making it specific to a single project**.
The goal is to keep this repository:
- simple
- professional
- maintainable
- widely reusable
---
# General Principles
When contributing, please follow these principles:
- Keep the template **generic and adaptable**.
- Prefer **clarity over cleverness**.
- Avoid unnecessary complexity.
- Follow the existing **project structure and conventions**.
- Keep the repository **easy to understand for new developers**.
- Write all **code, comments, docstrings, and documentation in English**.
This repository should remain a **solid starting point for many different types of Python projects**.
---
# Development Setup
It is recommended to use the provided **Makefile** to set up the development environment.
## First-time setup
```bash
make dev
```
This command will:
- create the `.venv` virtual environment
- install project dependencies
- install development tools
- configure `pre-commit` hooks
---
# Development Workflow
Typical workflow when working on the project:
```bash
make fix
make quality
make run
```
Command summary:
| Command | Description |
|-------|-------------|
| `make dev` | bootstrap development environment |
| `make install` | reinstall dependencies |
| `make fix` | auto-format and fix code issues |
| `make quality` | run formatting, linting, type checks, and tests |
| `make ci` | simulate CI validation |
| `make doctor` | show environment and tool versions |
| `make run` | run the application |
---
# Code Standards
All contributions should follow these standards:
- Python **3.11+**
- Use **type hints** where appropriate
- Write **clean and modular code**
- Prefer **readable code over clever shortcuts**
- Avoid dead code
- Use **consistent docstrings** for public APIs
Tools used in this project:
- **black** → formatting
- **ruff** → linting
- **mypy** → static typing
- **pytest** → testing
---
# Quality Checks
Before submitting changes, run:
```bash
make fix
make quality
pre-commit run --all-files
```
All checks should pass before committing.
---
# Documentation
If you modify project structure, development workflow, or setup instructions, please update the relevant documentation:
- `README.md`
- `CHANGELOG.md`
- `instructions-agent.md`
Clear documentation is as important as clean code.
---
# Commit Messages
Use clear and concise commit messages.
Examples:
```
Add make doctor command
Improve README template usage instructions
Add environment configuration example
Fix formatting issues
```
Good commit messages make the project history easier to understand.
---
# Scope of Contributions
Good contributions include:
- improving the project structure
- improving development workflow
- improving documentation
- improving tooling and automation
- fixing template inconsistencies
- simplifying developer experience
Please avoid turning this repository into a **project-specific application**.
This repository should remain a **general-purpose Python template**.
---
# Acknowledgements
This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools.
Contributions that improve the template while preserving its simplicity are always welcome.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Saky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

70
Makefile Normal file
View File

@@ -0,0 +1,70 @@
PYTHON := python
PIP := pip
.PHONY: help install dev format fix check lint typecheck test run quality ci doctor clean
help:
@echo "Available commands:"
@echo " make install Install project with development dependencies"
@echo " make dev Create local dev environment and install tools"
@echo " make format Format source code with Black"
@echo " make fix Auto-fix code issues (Black + Ruff)"
@echo " make check Check formatting with Black"
@echo " make lint Run Ruff"
@echo " make typecheck Run MyPy"
@echo " make test Run pytest"
@echo " make run Run the application"
@echo " make quality Run all quality checks"
@echo " make ci Run CI-style validation checks"
@echo " make doctor Show environment and tool versions"
@echo " make clean Remove cache files"
install:
$(PIP) install --upgrade pip
$(PIP) install -e '.[dev]'
dev:
$(PYTHON) -m venv .venv
. .venv/bin/activate && pip install --upgrade pip
. .venv/bin/activate && pip install -e '.[dev]'
. .venv/bin/activate && pre-commit install
format:
black src tests
fix:
black src tests
ruff check --fix src tests
check:
black --check src tests
lint:
ruff check src tests
typecheck:
mypy src
test:
pytest
run:
$(PYTHON) -m app.main
quality: check lint typecheck test
ci: check lint typecheck test
doctor:
@echo "Python:"
@$(PYTHON) --version
@echo ""
@echo "Tool versions:"
@black --version
@ruff --version
@mypy --version
@pytest --version
clean:
find . -type d \( -name "__pycache__" -o -name ".pytest_cache" -o -name ".mypy_cache" -o -name ".ruff_cache" \) -exec rm -rf {} +
find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete

111
README.md Normal file
View File

@@ -0,0 +1,111 @@
# cd-browser
`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line.
It provides an interactive interface for exploring directories with the keyboard and returns the selected path at the end of the session so shell wrappers can change the current shell directory.
## Features
- Interactive terminal directory browser
- Keyboard-driven navigation
- Parent directory entry via `..`
- Expand and collapse directory trees
- Session navigation history support in the application state
- Installable CLI command: `cd_browser`
## Installation
Install the project in user mode:
```bash
pip install .
```
For editable local development:
```bash
pip install -e '.[dev]'
```
After installation, the application command is:
```bash
cd_browser
```
When the interactive session exits, the program prints the final selected directory path.
## Shell Integration For `cd_`
Because a Python subprocess cannot directly change the parent shell directory, use a shell wrapper that captures the final path printed by `cd_browser`.
Bash or Zsh example:
```bash
cd_() {
local target
target="$(cd_browser)" || return
if [ -n "$target" ] && [ -d "$target" ]; then
cd "$target"
fi
}
```
After adding the function to your shell profile, reload it:
```bash
source ~/.bashrc
```
Or:
```bash
source ~/.zshrc
```
Then use:
```bash
cd_
```
## Uninstall
If the project was installed with `pip`, remove it with:
```bash
pip uninstall cd-browser
```
If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration.
## Developer Setup
Recommended setup:
```bash
make dev
```
Run the application in development mode:
```bash
python -m app.main
```
Useful development commands:
```bash
make fix
make quality
make run
```
## Template Origin
This project was created from the Python AI Dev Template.
The original template documentation has been preserved in `README_TEMPLATE.md` so the project-specific README can focus on `cd-browser` usage and development.
For the original template setup, conventions, and generic workflow notes, see `README_TEMPLATE.md`.

397
README_TEMPLATE.md Normal file
View File

@@ -0,0 +1,397 @@
# Python AI Dev Template
![Python](https://img.shields.io/badge/python-3.11+-blue)
![License](https://img.shields.io/badge/license-MIT-green)
![Style](https://img.shields.io/badge/code%20style-black-000000)
![Lint](https://img.shields.io/badge/lint-ruff-red)
![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen)
A universal and adaptable **Python project template** designed for professional development and AIassisted coding workflows (OpenCode, AI agents, etc.).
License: MIT
This repository provides a clean and productionready starting point for building:
- CLI tools
- backend services
- automation scripts
- desktop applications
- internal developer tools
The template enforces **modern Python standards**, **clean architecture**, and **automated quality checks**.
---
# Requirements
- Python **3.11+**
- `pyenv` recommended for Python version management
Check Python version:
```bash
python --version
```
---
# Quick Start
Clone the template repository:
```bash
git clone <template-repository-url> new-project
cd new-project
```
Create a virtual environment:
```bash
python -m venv .venv
source .venv/bin/activate
```
Install development dependencies:
```bash
pip install --upgrade pip
pip install -e '.[dev]'
```
---
# Using the Makefile (Recommended)
This template includes a **Makefile** to simplify common development tasks.
### First time setup
To prepare the full development environment automatically:
```bash
make dev
```
This command will:
- create the `.venv` virtual environment
- install project dependencies
- install development tools
- configure `pre-commit` hooks
This is the **recommended command after cloning the repository**.
### Reinstall or update dependencies
If the virtual environment already exists and you only want to reinstall or update dependencies:
```bash
make install
```
### Development utilities
Some useful commands:
```bash
make fix # autoformat and fix code issues
make quality # run full quality checks
make ci # simulate CI validation
make doctor # show environment and tool versions
make run # run the application
```
Using the Makefile ensures a consistent development workflow across machines.
---
# Environment Configuration
This template supports **environment-based configuration** using a `.env` file.
An example configuration file is provided:
```
.env.example
```
To create your local configuration, copy the example file:
```bash
cp .env.example .env
```
Then edit `.env` and adjust the values for your local environment.
Typical uses include:
- application settings
- API endpoints
- API keys
- database connection strings
- feature flags
⚠️ The `.env` file is **ignored by git** and should **never contain secrets in the repository**.
Only `.env.example` should be committed.
---
# Run the Application
```bash
python -m app.main
```
Expected output:
```
cd-browser is ready.
```
---
# Development Workflow
Before committing changes, always validate the project using the following steps.
### 1. Run the application
```bash
python -m app.main
```
### 2. Run tests
```bash
pytest
```
### 3. Format code
```bash
black src tests
```
### 4. Check formatting
```bash
black --check src tests
```
### 5. Lint code
```bash
ruff check src tests
```
### 6. Type checking
```bash
mypy src
```
If all commands succeed, the project is considered valid.
---
# Quick Quality Check
Run all checks quickly:
```bash
black src tests
ruff check src tests
mypy src
pytest
```
---
# Create a New Repository From This Template
If you downloaded or cloned this template and want to start a **new project repository**, follow these steps.
### 1. Clone the template
```bash
git clone https://gitea.sakydogalo.es/saky/python-ai-dev-template.git new-project
cd new-project
```
### Recommended: Initialize the project automatically
You can initialize a new project using the helper script:
```bash
./scripts/init_project.sh my-project-name
```
This script will:
- reset the git history
- prepare the repository for a fresh start
- keep the template structure intact
After running the script, create your new remote repository and push the code as described below.
---
### Manual initialization (alternative)
If you prefer to perform the steps manually, follow the instructions below.
### 2. Remove the original remote
```bash
git remote remove origin
```
### 3. Reset Git history (start clean)
```bash
rm -rf .git
git init
```
### 4. Update project metadata
Update these files to match your new project:
- `pyproject.toml` → project name and description
- `README.md`
- `src/app/config.py` → application name
### 5. Validate the project
```bash
make fix
make quality
```
### 6. Create your new repository
Create a new repository on your Git server (Gitea, GitHub, etc.).
Example:
```
https://gitea.sakydogalo.es/saky/new-project
```
### 7. Connect the new remote
```bash
git remote add origin <your-new-repository-url>
```
### 8. Create the first commit
```bash
git add .
git commit -m "Initial new-project from template"
```
### 9. Push to the new repository
```bash
git branch -M main
git push -u origin main
```
Your new project repository is now ready.
---
# Project Structure
```
new-project/
├── src/
│ └── app/
│ ├── main.py
│ ├── config.py
│ ├── logging_config.py
│ ├── services/
│ ├── domain/
│ ├── infrastructure/
│ └── utils/
├── tests/
│ └── test_smoke.py
├── scripts/
├── pyproject.toml
├── instructions-agent.md
├── README.md
└── .editorconfig
```
---
# Tooling
This template includes modern Python development tools:
| Tool | Purpose |
|-----|--------|
| pytest | testing framework |
| black | automatic code formatting |
| ruff | linting and static analysis |
| mypy | static type checking |
---
# Purpose of this Template
This repository is intentionally **generic and adaptable**.
It can evolve into:
- a CLI tool
- a web API
- a microservice
- a desktop application
- an automation system
The goal is to provide a **clean, maintainable, and AIfriendly Python project foundation**.
---
# AI Agent Support
This repository includes:
instructions-agent.md
This file defines rules and expectations for AI coding agents such as:
- OpenCode
- AI IDE assistants
- autonomous coding agents
Agents should follow those instructions when modifying the project.
---
# License
This project is released under the **MIT License**, one of the most permissive opensource licenses available.
You are free to:
- use this code for personal or commercial projects
- modify it
- distribute it
- include it in proprietary software
No special permission is required.
Copyright (c) 2025 Saky
This template was created by **Saky**, drawing on many years of professional software engineering experience and with the assistance of modern AI development tools.
The goal of this repository is to provide a practical, productionready starting point for Python projects while remaining as open and reusable as possible.

61
instructions-agent.md Normal file
View File

@@ -0,0 +1,61 @@
# Agent Instructions: Universal Python Project Template
## Project Overview
This repository is a universal and adaptable Python template intended to serve as a professional starting point for new projects.
## Core Directives
1. All code, comments, variables, string literals, commit messages, and documentation must be written in English.
2. Use Python 3.11+ with type hints whenever possible.
3. Follow clean code principles and PEP 8.
4. Keep modules small, cohesive, and maintainable.
5. Use consistent docstrings for public classes and functions.
6. Prefer explicit error handling over silent failures.
## Architecture Guidelines
1. Keep business logic separate from infrastructure concerns.
2. Avoid mixing configuration, I/O, and domain logic in the same module.
3. Prefer reusable services over duplicated logic.
4. Do not introduce unnecessary complexity or premature abstractions.
## Quality Standards
1. Every meaningful feature should include tests.
2. Run formatting, linting, and type checks before considering a task complete.
3. Keep the README updated whenever setup, commands, or structure changes.
4. Do not leave placeholder code unless clearly marked.
## Tooling
- Testing: pytest
- Formatting: black
- Linting: ruff
- Static typing: mypy
## Expected Project Structure
src/app/
tests/
scripts/
README.md
instructions-agent.md
pyproject.toml
## Agent Workflow
1. First understand the repository structure.
2. Propose a short implementation plan before major changes.
3. Reuse existing modules whenever possible.
4. Keep changes minimal, coherent, and production-oriented.
5. Validate changes with tests and quality tools when possible.
## Application Specification
This repository includes an application specification that defines the expected behavior of the project.
Primary specification file:
- `specs/cd_browser_spec.md`
Agent rules:
1. Before implementing any feature, read the application specification.
2. If implementation details are unclear, follow the specification first.
3. If the specification conflicts with a previous assumption, the specification takes precedence.
4. Keep implementation aligned with the MVP scope unless explicitly asked to extend it.
5. After code changes, run the appropriate validation workflow defined by this repository.

56
pyproject.toml Normal file
View File

@@ -0,0 +1,56 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "cd-browser"
version = "0.1.0"
description = "A universal and adaptable Python project template for AI-assisted development."
readme = "README.md"
requires-python = ">=3.11"
authors = [
{ name = "Saky" }
]
dependencies = []
[project.scripts]
cd_browser = "app.main:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"black>=24.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
"pre-commit>=3.7.0"
]
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
[tool.black]
line-length = 88
target-version = ["py311"]
[tool.ruff]
line-length = 88
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
ignore = []
[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_configs = true
disallow_untyped_defs = true
ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-q"

2
scripts/format.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
black src tests

68
scripts/init_project.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# This script is designed to work on:
# - macOS
# - Linux
# - Windows when using Git Bash (recommended)
#
# On Windows, run it from Git Bash:
# ./scripts/init_project.sh my-project-name
set -e
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
cd "$REPO_ROOT"
if [ -d ".git" ]; then
echo "Error: this repository still contains a .git directory."
echo "Please remove .git first or run this script only on a fresh copy of the template."
exit 1
fi
PROJECT_NAME=${1:-}
if [ -z "$PROJECT_NAME" ]; then
echo "Usage: ./scripts/init_project.sh <project-name>"
exit 1
fi
if ! printf '%s' "$PROJECT_NAME" | grep -Eq '^[a-zA-Z0-9._-]+$'; then
echo "Error: project name contains unsupported characters."
echo "Use only letters, numbers, dots, underscores, and hyphens."
exit 1
fi
echo "Creating project: $PROJECT_NAME"
echo "Working directory: $REPO_ROOT"
# rename project metadata (portable sed usage for macOS, Linux, and Git Bash on Windows)
# create temporary backup files (.bak) and remove them after replacement
if [ ! -f "README.md" ] || [ ! -f "pyproject.toml" ]; then
echo "Error: README.md or pyproject.toml not found in repository root."
exit 1
fi
sed -i.bak "s/New Project/$PROJECT_NAME/g" README.md
sed -i.bak "s/python-ai-dev-template/$PROJECT_NAME/g" pyproject.toml
rm -f README.md.bak pyproject.toml.bak
# reset git history
rm -rf .git
git init
git branch -M main
echo ""
echo "Project initialized."
echo ""
echo "Next steps:"
echo ""
echo "git add ."
echo "git commit -m \"Initial $PROJECT_NAME\""
echo ""
echo "Create a remote repository and run:"
echo ""
echo "git remote add origin <repo-url>"
echo "git push -u origin main"

2
scripts/lint.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
ruff check src tests

2
scripts/test.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
pytest

2
scripts/typecheck.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
mypy src

305
specs/cd_browser_spec.md Normal file
View File

@@ -0,0 +1,305 @@
# cd_ — Terminal Directory Navigator
## Overview
`cd_` is an interactive terminal utility designed to make directory navigation faster and more intuitive directly from the command line.
Instead of manually typing `cd` commands, users can browse directories using a keyboard-driven interface.
The tool behaves like a **lightweight directory browser inside the terminal**.
---
# Goals
The utility should provide:
- fast directory navigation
- keyboard-driven interface
- minimal latency
- intuitive exploration of directory trees
- ability to preview folder structure without entering directories
The tool must remain **lightweight and terminal-native**.
---
# Execution
The tool is executed from the terminal:
```bash
cd_
```
When executed, it opens an **interactive terminal navigation interface**.
---
# Interface Behavior
The interface displays:
- the current working directory
- a list of subdirectories
- a special entry:
```
..
```
which navigates to the parent directory.
Example:
```
/home/user/projects
▶ backend
▶ scripts
docs
..
```
---
# Directory Indicators
Directories must visually indicate whether they contain subdirectories.
Recommended symbols:
| Symbol | Meaning |
|------|------|
| ▶ | collapsed directory containing subdirectories |
| ▼ | expanded directory |
| space | directory without subdirectories |
Example:
```
▶ backend
▼ scripts
build
deploy
docs
```
---
# Navigation Controls
## Arrow Up / Arrow Down
Move selection between entries.
---
## Enter
Enter the selected directory.
If `..` is selected, navigate to the parent directory.
---
## ESC
Exit interactive mode.
The shell should remain in the directory currently selected.
---
# Tree Expansion
Users should be able to explore directory structure without entering directories.
## Arrow Right
Expand the selected directory.
Example:
Before:
```
▶ scripts
```
After pressing →:
```
▼ scripts
build
deploy
tools
```
---
## Arrow Left
Collapse expanded directory.
---
# Navigation History
The application maintains a session navigation history.
---
## h
Display navigation history.
The user can select a previous directory from the list.
---
## b
Navigate backward in history.
---
## f
Navigate forward in history.
---
# UI Principles
The interface must be:
- responsive
- minimal
- keyboard-focused
- easy to understand visually
---
# Future Extensions
These features are optional and may be added later.
## Search Mode
Activated by pressing:
```
/
```
Allows filtering directories by name.
---
## Favorites
Mark current directory:
```
m
```
Open favorites:
```
F
```
---
# Technical Constraints
The implementation must:
- be written in Python
- follow the repository coding standards
- pass formatting, linting, typing, and tests
Validation commands:
```
make fix
make quality
```
---
# Installation Requirements
The tool must support two usage modes.
## Developer Mode
Clone repository and run:
```
make dev
```
Then execute:
```
python -m app.main
```
---
## User Mode (Installable CLI)
The tool should be installable as a CLI command.
Example:
```
pip install .
```
After installation, users should be able to run:
```
cd_
```
directly from the terminal.
---
# Shell Integration Constraint
Because a subprocess cannot change the parent shell directory, the tool must output the selected directory path.
Shell integration should be implemented using a wrapper function.
Example:
```bash
cd_() {
cd "$(cd_browser)"
}
```
---
# MVP Scope
The first version must include:
- directory navigation
- tree expansion
- keyboard controls
- session history
- terminal interface
Advanced features are optional.
---
# Summary
`cd_` is a terminal-native interactive directory navigator designed to accelerate filesystem navigation while remaining lightweight and keyboard-focused.

0
src/app/__init__.py Normal file
View File

15
src/app/cli.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from pathlib import Path
from app.navigator import Navigator
from app.ui import TerminalUI
def run_cli(start_path: Path | None = None, ui: TerminalUI | None = None) -> Path:
"""Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve()
navigator = Navigator(initial_path)
terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator)

9
src/app/config.py Normal file
View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass
@dataclass(slots=True)
class AppConfig:
"""Application configuration."""
app_name: str = "Python AI Dev Template"
debug: bool = False

View File

40
src/app/filesystem.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class DirectoryEntry:
"""Directory metadata used by the navigator layer."""
name: str
path: Path
has_children: bool
def list_directories(path: Path) -> list[DirectoryEntry]:
"""Return direct child directories sorted by name."""
directory_path = path.expanduser().resolve()
entries: list[DirectoryEntry] = []
for child in directory_path.iterdir():
if child.is_dir():
entries.append(
DirectoryEntry(
name=child.name,
path=child,
has_children=has_subdirectories(child),
)
)
return sorted(entries, key=lambda entry: entry.name.casefold())
def has_subdirectories(path: Path) -> bool:
"""Return whether the directory contains at least one subdirectory."""
directory_path = path.expanduser().resolve()
return any(child.is_dir() for child in directory_path.iterdir())

68
src/app/history.py Normal file
View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(slots=True)
class SessionHistory:
"""In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list)
_index: int = -1
def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed."""
resolved_path = path.expanduser().resolve()
if self.current == resolved_path:
return resolved_path
if self._index < len(self._entries) - 1:
self._entries = self._entries[: self._index + 1]
self._entries.append(resolved_path)
self._index = len(self._entries) - 1
return resolved_path
@property
def current(self) -> Path | None:
"""Return the current history entry."""
if self._index == -1:
return None
return self._entries[self._index]
def back(self) -> Path | None:
"""Move backward in history when possible."""
if self._index <= 0:
return None
self._index -= 1
return self._entries[self._index]
def forward(self) -> Path | None:
"""Move forward in history when possible."""
if self._index == -1 or self._index >= len(self._entries) - 1:
return None
self._index += 1
return self._entries[self._index]
def select(self, index: int) -> Path:
"""Jump to a previously visited directory by history index."""
if index < 0 or index >= len(self._entries):
raise IndexError("History index out of range")
self._index = index
return self._entries[self._index]
def entries(self) -> tuple[Path, ...]:
"""Return the full history as an immutable sequence."""
return tuple(self._entries)

View File

11
src/app/logging_config.py Normal file
View File

@@ -0,0 +1,11 @@
import logging
import sys
def setup_logging() -> None:
"""Configure application logging."""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
stream=sys.stderr,
)

16
src/app/main.py Normal file
View File

@@ -0,0 +1,16 @@
import sys
from app.cli import run_cli
from app.logging_config import setup_logging
def main() -> None:
"""Run the application entry point."""
setup_logging()
final_path = run_cli()
sys.stdout.write(f"{final_path}\n")
if __name__ == "__main__":
main()

204
src/app/navigator.py Normal file
View File

@@ -0,0 +1,204 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from app.filesystem import list_directories
from app.history import SessionHistory
@dataclass(frozen=True, slots=True)
class VisibleEntry:
"""A directory row visible in the navigator."""
name: str
path: Path
depth: int
has_children: bool
is_expanded: bool
is_parent: bool = False
@dataclass(slots=True)
class Navigator:
"""State container for directory navigation behavior."""
start_path: Path
history: SessionHistory = field(default_factory=SessionHistory)
current_path: Path = field(init=False)
selected_index: int = field(init=False, default=0)
_expanded_paths: set[Path] = field(init=False, default_factory=set)
def __post_init__(self) -> None:
self.current_path = self.start_path.expanduser().resolve()
self.history.visit(self.current_path)
@property
def visible_entries(self) -> tuple[VisibleEntry, ...]:
"""Return the current flattened directory view."""
entries = [
VisibleEntry(
name="..",
path=self.current_path.parent,
depth=0,
has_children=True,
is_expanded=False,
is_parent=True,
)
]
entries.extend(self._build_entries(self.current_path, depth=0))
return tuple(entries)
@property
def selected_entry(self) -> VisibleEntry:
"""Return the currently selected visible entry."""
return self.visible_entries[self.selected_index]
def set_selected_index(self, index: int) -> None:
"""Set the selected index within the visible entry range."""
entry_count = len(self.visible_entries)
if index < 0 or index >= entry_count:
raise IndexError("Selected index out of range")
self.selected_index = index
def enter_selected_directory(self) -> Path:
"""Enter the currently selected directory and record it in history."""
return self._change_directory(self.selected_entry.path)
def go_to_parent_directory(self) -> Path:
"""Navigate to the parent directory of the current path."""
return self._change_directory(self.current_path.parent)
def expand_selected_directory(self) -> bool:
"""Expand the selected directory if it has children."""
return self.expand_directory(self.selected_entry.path)
def collapse_selected_directory(self) -> bool:
"""Collapse the selected directory when possible."""
selected = self.selected_entry
if selected.is_parent:
return False
if selected.path in self._expanded_paths:
self._collapse_path(selected.path)
return True
if selected.depth > 0:
parent = self._find_visible_parent(selected)
self.set_selected_index(self.visible_entries.index(parent))
return self.collapse_selected_directory()
return False
def expand_directory(self, path: Path) -> bool:
"""Expand a visible directory by path."""
resolved_path = path.expanduser().resolve()
for entry in self.visible_entries:
if (
entry.path == resolved_path
and not entry.is_parent
and entry.has_children
):
self._expanded_paths.add(resolved_path)
return True
return False
def collapse_directory(self, path: Path) -> bool:
"""Collapse a previously expanded directory by path."""
resolved_path = path.expanduser().resolve()
if resolved_path not in self._expanded_paths:
return False
self._collapse_path(resolved_path)
return True
def go_back(self) -> Path | None:
"""Move backward through navigation history."""
path = self.history.back()
if path is None:
return None
self._set_current_path(path)
return path
def go_forward(self) -> Path | None:
"""Move forward through navigation history."""
path = self.history.forward()
if path is None:
return None
self._set_current_path(path)
return path
def select_history_entry(self, index: int) -> Path:
"""Jump to a directory stored in session history."""
path = self.history.select(index)
self._set_current_path(path)
return path
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
entries: list[VisibleEntry] = []
for directory in list_directories(path):
is_expanded = directory.path in self._expanded_paths
entries.append(
VisibleEntry(
name=directory.name,
path=directory.path,
depth=depth,
has_children=directory.has_children,
is_expanded=is_expanded,
)
)
if is_expanded:
entries.extend(self._build_entries(directory.path, depth=depth + 1))
return entries
def _change_directory(self, path: Path) -> Path:
resolved_path = path.expanduser().resolve()
self.history.visit(resolved_path)
self._set_current_path(resolved_path)
return resolved_path
def _set_current_path(self, path: Path) -> None:
self.current_path = path.expanduser().resolve()
self.selected_index = 0
self._expanded_paths.clear()
def _collapse_path(self, path: Path) -> None:
self._expanded_paths = {
expanded_path
for expanded_path in self._expanded_paths
if expanded_path != path and path not in expanded_path.parents
}
def _find_visible_parent(self, entry: VisibleEntry) -> VisibleEntry:
entries = self.visible_entries
current_index = entries.index(entry)
for candidate_index in range(current_index - 1, -1, -1):
candidate = entries[candidate_index]
if (
candidate.depth == entry.depth - 1
and entry.path.parent == candidate.path
):
return candidate
raise ValueError("Visible parent entry not found")

View File

167
src/app/ui.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import curses
import os
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TextIO
from app.navigator import Navigator, VisibleEntry
ESCAPE_KEY = 27
@dataclass(frozen=True, slots=True)
class RenderLine:
"""A rendered navigator row ready for terminal output."""
text: str
is_selected: bool
def move_selection(current_index: int, step: int, entry_count: int) -> int:
"""Move the selected index within the available entry range."""
if entry_count <= 0:
return 0
return max(0, min(current_index + step, entry_count - 1))
def format_entry(entry: VisibleEntry) -> str:
"""Format a visible entry for terminal display."""
if entry.is_parent:
return entry.name
indent = " " * entry.depth
indicator = " "
if entry.has_children:
indicator = "" if entry.is_expanded else ""
return f"{indent}{indicator} {entry.name}"
def build_render_lines(navigator: Navigator) -> list[RenderLine]:
"""Build all visible lines for the current navigator state."""
return [
RenderLine(
text=format_entry(entry),
is_selected=index == navigator.selected_index,
)
for index, entry in enumerate(navigator.visible_entries)
]
class TerminalUI:
"""Minimal curses UI for the directory navigator MVP."""
def run(self, navigator: Navigator) -> Path:
"""Start the interactive session and return the final directory path."""
with open_terminal_streams() as (input_stream, output_stream):
with redirect_standard_streams(input_stream, output_stream):
return curses.wrapper(self._run_session, navigator)
def _run_session(self, stdscr: curses.window, navigator: Navigator) -> Path:
curses.curs_set(0)
stdscr.keypad(True)
while True:
self._render(stdscr, navigator)
key = stdscr.getch()
if key == ESCAPE_KEY:
return navigator.current_path
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 in (curses.KEY_ENTER, 10, 13):
navigator.enter_selected_directory()
def _render(self, stdscr: curses.window, navigator: Navigator) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx()
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):
if row >= height:
break
attributes = curses.A_REVERSE if line.is_selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line.text, width - 1, attributes)
stdscr.refresh()
@contextmanager
def open_terminal_streams() -> Iterator[tuple[TextIO, TextIO]]:
"""Open terminal streams suitable for interactive rendering."""
try:
with (
open("/dev/tty", encoding="utf-8") as input_stream,
open("/dev/tty", "w", encoding="utf-8") as output_stream,
):
yield input_stream, output_stream
return
except OSError:
if not sys.stdin.isatty() or not sys.stderr.isatty():
raise RuntimeError("Interactive terminal is required") from None
yield sys.stdin, sys.stderr
@contextmanager
def redirect_standard_streams(
input_stream: TextIO, output_stream: TextIO
) -> Iterator[None]:
"""Temporarily redirect stdin and stdout to terminal-backed streams."""
sys.stdout.flush()
sys.stderr.flush()
original_stdin = os.dup(0)
original_stdout = os.dup(1)
try:
os.dup2(input_stream.fileno(), 0)
os.dup2(output_stream.fileno(), 1)
yield
finally:
os.dup2(original_stdin, 0)
os.dup2(original_stdout, 1)
os.close(original_stdin)
os.close(original_stdout)

View File

0
tests/__init__.py Normal file
View File

43
tests/test_cli.py Normal file
View File

@@ -0,0 +1,43 @@
from pathlib import Path
import pytest
from app.cli import run_cli
from app.navigator import Navigator
class StubUI:
def __init__(self, result: Path) -> None:
self.result = result
self.navigator: Navigator | None = None
def run(self, navigator: Navigator) -> Path:
self.navigator = navigator
return self.result
def test_run_cli_uses_current_working_directory(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
stub_ui = StubUI(tmp_path)
monkeypatch.setattr(Path, "cwd", lambda: tmp_path)
result = run_cli(ui=stub_ui)
assert result == tmp_path.resolve()
assert stub_ui.navigator is not None
assert stub_ui.navigator.current_path == tmp_path.resolve()
def test_run_cli_uses_provided_start_path(tmp_path: Path) -> None:
start_path = tmp_path / "workspace"
start_path.mkdir()
final_path = tmp_path / "final"
final_path.mkdir()
stub_ui = StubUI(final_path)
result = run_cli(start_path=start_path, ui=stub_ui)
assert result == final_path.resolve()
assert stub_ui.navigator is not None
assert stub_ui.navigator.current_path == start_path.resolve()

46
tests/test_filesystem.py Normal file
View File

@@ -0,0 +1,46 @@
from pathlib import Path
import pytest
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
parent = tmp_path / "parent"
child = parent / "child"
child.mkdir(parents=True)
assert has_subdirectories(parent) is True
def test_has_subdirectories_returns_false_without_nested_directories(
tmp_path: Path,
) -> None:
directory = tmp_path / "empty"
directory.mkdir()
(directory / "file.txt").write_text("content", encoding="utf-8")
assert has_subdirectories(directory) is False
def test_list_directories_returns_sorted_directory_metadata(tmp_path: Path) -> None:
alpha = tmp_path / "alpha"
beta = tmp_path / "Beta"
file_path = tmp_path / "notes.txt"
alpha.mkdir()
beta.mkdir()
(beta / "nested").mkdir()
file_path.write_text("ignore me", encoding="utf-8")
assert list_directories(tmp_path) == [
DirectoryEntry(name="alpha", path=alpha, has_children=False),
DirectoryEntry(name="Beta", path=beta, has_children=True),
]
def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
missing_path = tmp_path / "missing"
with pytest.raises(FileNotFoundError):
list_directories(missing_path)

84
tests/test_history.py Normal file
View File

@@ -0,0 +1,84 @@
from pathlib import Path
import pytest
from app.history import SessionHistory
def test_visit_records_current_directory(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
first.mkdir()
visited = history.visit(first)
assert visited == first.resolve()
assert history.current == first.resolve()
assert history.entries() == (first.resolve(),)
def test_visit_drops_forward_history_after_new_branch(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
third = tmp_path / "third"
for path in (first, second, third):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.back() == first.resolve()
history.visit(third)
assert history.entries() == (first.resolve(), third.resolve())
assert history.current == third.resolve()
assert history.forward() is None
def test_visit_does_not_duplicate_current_entry(tmp_path: Path) -> None:
history = SessionHistory()
directory = tmp_path / "directory"
directory.mkdir()
history.visit(directory)
history.visit(directory)
assert history.entries() == (directory.resolve(),)
def test_back_and_forward_move_through_history(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
for path in (first, second):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.back() == first.resolve()
assert history.back() is None
assert history.forward() == second.resolve()
assert history.forward() is None
def test_select_moves_to_specific_history_entry(tmp_path: Path) -> None:
history = SessionHistory()
first = tmp_path / "first"
second = tmp_path / "second"
for path in (first, second):
path.mkdir()
history.visit(first)
history.visit(second)
assert history.select(0) == first.resolve()
assert history.current == first.resolve()
def test_select_raises_for_invalid_index() -> None:
history = SessionHistory()
with pytest.raises(IndexError):
history.select(0)

182
tests/test_navigator.py Normal file
View File

@@ -0,0 +1,182 @@
from pathlib import Path
import pytest
from app.history import SessionHistory
from app.navigator import Navigator
def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
alpha = current / "alpha"
beta = current / "beta"
alpha.mkdir()
beta.mkdir()
navigator = Navigator(current)
assert navigator.current_path == current.resolve()
assert navigator.selected_index == 0
assert [entry.name for entry in navigator.visible_entries] == [
"..",
"alpha",
"beta",
]
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
build = scripts / "build"
build.mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
assert navigator.expand_selected_directory() is True
assert [(entry.name, entry.depth) for entry in navigator.visible_entries] == [
("..", 0),
("scripts", 0),
("build", 1),
]
def test_expand_selected_directory_returns_false_without_children(
tmp_path: Path,
) -> None:
current = tmp_path / "workspace"
current.mkdir()
docs = current / "docs"
docs.mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
assert navigator.expand_selected_directory() is False
assert [entry.name for entry in navigator.visible_entries] == ["..", "docs"]
def test_collapse_selected_directory_hides_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
(scripts / "build").mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
navigator.expand_selected_directory()
assert navigator.collapse_selected_directory() is True
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
def test_collapse_selected_child_moves_to_parent_and_collapses(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
scripts = current / "scripts"
scripts.mkdir()
(scripts / "build").mkdir()
navigator = Navigator(current)
navigator.set_selected_index(1)
navigator.expand_selected_directory()
navigator.set_selected_index(2)
assert navigator.collapse_selected_directory() is True
assert navigator.selected_entry.name == "scripts"
assert [entry.name for entry in navigator.visible_entries] == ["..", "scripts"]
def test_enter_selected_directory_changes_current_path_and_updates_history(
tmp_path: Path,
) -> None:
current = tmp_path / "workspace"
current.mkdir()
child = current / "child"
child.mkdir()
history = SessionHistory()
navigator = Navigator(current, history=history)
navigator.set_selected_index(1)
selected_path = navigator.enter_selected_directory()
assert selected_path == child.resolve()
assert navigator.current_path == child.resolve()
assert navigator.selected_index == 0
assert navigator.history.entries() == (current.resolve(), child.resolve())
def test_enter_parent_entry_moves_to_parent_directory(tmp_path: Path) -> None:
parent = tmp_path / "parent"
parent.mkdir()
current = parent / "current"
current.mkdir()
navigator = Navigator(current)
moved_path = navigator.enter_selected_directory()
assert moved_path == parent.resolve()
assert navigator.current_path == parent.resolve()
def test_go_to_parent_directory_moves_up_one_level(tmp_path: Path) -> None:
parent = tmp_path / "parent"
parent.mkdir()
current = parent / "current"
current.mkdir()
navigator = Navigator(current)
assert navigator.go_to_parent_directory() == parent.resolve()
assert navigator.current_path == parent.resolve()
def test_history_navigation_restores_previous_path(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
first = root / "first"
second = root / "second"
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()
assert navigator.go_back() == root.resolve()
assert navigator.current_path == root.resolve()
assert navigator.go_back() == first.resolve()
assert navigator.go_forward() == root.resolve()
assert navigator.go_forward() == second.resolve()
def test_select_history_entry_changes_current_path(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
child = root / "child"
child.mkdir()
navigator = Navigator(root)
navigator.set_selected_index(1)
navigator.enter_selected_directory()
assert navigator.select_history_entry(0) == root.resolve()
assert navigator.current_path == root.resolve()
def test_set_selected_index_raises_for_invalid_index(tmp_path: Path) -> None:
current = tmp_path / "workspace"
current.mkdir()
navigator = Navigator(current)
with pytest.raises(IndexError):
navigator.set_selected_index(1)

18
tests/test_smoke.py Normal file
View File

@@ -0,0 +1,18 @@
from pathlib import Path
import pytest
from app.main import main
def test_main_runs(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr("app.main.run_cli", lambda: Path("/tmp/final"))
main()
captured = capsys.readouterr()
assert captured.out == "/tmp/final\n"
assert captured.err == ""

80
tests/test_ui.py Normal file
View File

@@ -0,0 +1,80 @@
from pathlib import Path
import pytest
from app.navigator import Navigator, VisibleEntry
from app.ui import (
RenderLine,
build_render_lines,
format_entry,
move_selection,
open_terminal_streams,
)
def test_move_selection_stays_within_bounds() -> None:
assert move_selection(current_index=0, step=-1, entry_count=3) == 0
assert move_selection(current_index=1, step=1, entry_count=3) == 2
assert move_selection(current_index=2, step=1, entry_count=3) == 2
def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> None:
parent_entry = VisibleEntry(
name="..",
path=tmp_path,
depth=0,
has_children=True,
is_expanded=False,
is_parent=True,
)
collapsed_entry = VisibleEntry(
name="scripts",
path=tmp_path / "scripts",
depth=0,
has_children=True,
is_expanded=False,
)
expanded_child = VisibleEntry(
name="build",
path=tmp_path / "scripts" / "build",
depth=1,
has_children=False,
is_expanded=False,
)
assert format_entry(parent_entry) == ".."
assert format_entry(collapsed_entry) == "▶ scripts"
assert format_entry(expanded_child) == " build"
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
alpha = workspace / "alpha"
beta = workspace / "beta"
alpha.mkdir()
beta.mkdir()
navigator = Navigator(workspace)
navigator.set_selected_index(2)
assert build_render_lines(navigator) == [
RenderLine(text="..", is_selected=False),
RenderLine(text=" alpha", is_selected=False),
RenderLine(text=" beta", is_selected=True),
]
def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def raise_os_error(*args: object, **kwargs: object) -> object:
raise OSError
monkeypatch.setattr("builtins.open", raise_os_error)
monkeypatch.setattr("sys.stdin.isatty", lambda: False)
monkeypatch.setattr("sys.stderr.isatty", lambda: False)
with pytest.raises(RuntimeError, match="Interactive terminal is required"):
with open_terminal_streams():
pass