13 Commits

Author SHA1 Message Date
c9f08895be release: 0.3.0 persistent history, configurable openers, and dev info panel 2026-04-06 01:09:07 +02:00
beab56393a checkpoint: 0.3.0 ui header, history persistence and history filter 2026-04-06 00:42:42 +02:00
6f78f6d9ca release: 0.2.6 add advanced navigation and contextual open actions 2026-04-04 01:53:00 +02:00
8259b2e132 release: 0.2.5 add repository link for PyPI users 2026-04-03 22:51:08 +02:00
4052c6f71f release: 0.2.4 fix PyPI README gif rendering 2026-04-03 21:30:40 +02:00
0e94895349 merge: bring 0.2.x releases into main 2026-04-03 21:14:23 +02:00
d9cf164b2c release: 0.2.3 add demo gif 2026-04-02 22:38:42 +02:00
a64bc768b5 release: 0.2.2 fix post-install packaging and robustness 2026-04-02 21:35:45 +02:00
f7f09b895d feat: update pyproject.toml for PyPI release
- Lower Python requirement to >=3.8 for broader compatibility
- Update version to 0.1.1
- Add classifiers for better PyPI discoverability
- Fix description to match cd-browser functionality
2026-04-02 17:06:16 +02:00
10c3849fb3 cleanup: remove AI internal files for public repo focus
- Removed AGENT.md, AGENT_WORKFLOW.md, AI_TASK_TEMPLATE.md, instructions-agent.md
- These were internal AI development tools, not needed for end-users
- Repository now cleaner and more product-focused
- Files can be recovered from git history if needed for future development
2026-04-02 16:48:58 +02:00
f28fef7758 docs: update changelog with hidden toggle feature 2026-04-02 16:45:15 +02:00
ab689cfbb4 feat: toggle hidden directories in cd-browser with . key 2026-04-02 16:28:21 +02:00
9688ed0974 docs: update changelog and readme for cd-browser v0.1.1 2026-04-02 16:00:06 +02:00
25 changed files with 2601 additions and 1080 deletions

260
AGENT.md
View File

@@ -1,260 +0,0 @@
# AGENT.md
AI Agent Development Guide
This file defines the operational rules that AI agents must follow when working in this repository.
The goal is to ensure:
- reproducible AI-assisted development
- traceable decisions
- consistent repository history
- safe automated contributions
This document complements:
- `instructions-agent.md`
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
---
# Core Principles
Agents must prioritize:
1. Deterministic changes
2. Minimal scope modifications
3. Explicit logging of work
4. Repository stability
Agents must never:
- fabricate repository state
- invent files that do not exist
- modify unrelated parts of the project
- skip validation steps
---
# Development Workflow
Agents operate using two primary task types:
## PLAN
Used to:
- investigate problems
- inspect repository state
- propose architecture changes
PLAN tasks must:
- analyze the current implementation
- identify the root cause
- propose minimal changes
PLAN tasks **must not modify code**.
---
## BUILD
Used to:
- implement changes
- update code
- add tests
- update documentation
BUILD tasks may:
- modify source files
- update tests
- update documentation
BUILD tasks must:
- remain minimal in scope
- preserve existing behavior unless explicitly changed
---
# Validation Requirements
All BUILD tasks must run:
```
make fix
make quality
```
These commands must pass before changes are considered valid.
---
# Logging System
All significant AI actions must be recorded.
Two log files are used:
```
docs/ai-worklog.md
docs/ai-prompts.md
```
---
# Worklog Entries
Executed work must be recorded in:
```
docs/ai-worklog.md
```
Each entry must follow the standard format defined in `instructions-agent.md`.
Mandatory fields:
- Date (YYYY-MM-DD HH:MM)
- Task ID
- Agent
- Task
- Objective
- Scope
- Files Modified / Files Inspected
- Key Decisions / Proposed Changes
- Validation
- Result
- Open Issues
---
# Prompt Log
Prompts that trigger significant work must be recorded in:
```
docs/ai-prompts.md
```
Each prompt entry must include:
- Date
- Task ID
- Agent
- Task
- Scope
- Prompt Summary
- Result Summary
Chain-of-thought reasoning must **never be stored**.
---
# Task IDs
All tasks must include a unique identifier.
Format:
```
BUILD-YYYYMMDD-XXX
PLAN-YYYYMMDD-XXX
```
Examples:
```
BUILD-20260315-001
PLAN-20260316-002
```
This allows prompts and worklog entries to be correlated.
---
# Git Rules
Agents must follow these repository rules.
Agents must **not commit or push** unless explicitly requested.
When committing:
Use structured commit messages.
Example:
```
feat: improve terminal navigation behavior
- refine tree navigation
- improve scrolling continuity
- update AI logs
```
Documentation-only updates should use:
```
docs:
```
---
# Code Modification Rules
Agents should prefer:
- small targeted patches
- isolated logic changes
- preserving existing abstractions
Agents should avoid:
- rewriting entire modules
- introducing unnecessary dependencies
- altering unrelated code paths
---
# UI Changes
When modifying UI behavior:
- keep navigation predictable
- preserve keyboard ergonomics
- ensure selections remain visible
- maintain compatibility with history mode
---
# Testing Rules
Whenever logic changes:
- update existing tests
- add focused tests when appropriate
Tests should validate behavior, not implementation details.
---
# Scope Discipline
Agents must strictly respect scope defined in prompts.
If additional changes appear necessary:
Agents must propose them in a PLAN task before implementing.
---
# Repository Safety
Agents must not:
- delete important files
- alter project configuration without justification
- introduce breaking changes without explicit instruction
---
# End of Document
---

View File

@@ -1,298 +0,0 @@
# AGENT_WORKFLOW.md
AI Agent Operational Workflow
This document defines the deterministic workflow that AI agents must follow when performing work in this repository.
The workflow ensures:
- predictable development cycles
- traceable decisions
- minimal risk of unintended changes
- consistent logging of AI actions
This file complements:
- `AGENT.md`
- `instructions-agent.md`
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
- `docs/ai-execution-log.md`
---
# Standard Workflow
Every AI task must follow this sequence:
PLAN → REVIEW → BUILD → VALIDATE → LOG
---
# Execution Order (Strict)
The following operational order must be respected when executing any BUILD task.
1. Implement changes (BUILD)
2. Run validation
make fix
make quality
3. Update AI logs
- docs/ai-execution-log.md
- docs/ai-prompts.md
- docs/ai-worklog.md
4. Stage all modified files including logs
5. Create commit
6. Push only if explicitly requested
Important rule:
Logs must always be written **before creating a commit**. If log files are modified after a commit, the task is considered incomplete and must be corrected.
---
This sequence must not be skipped.
---
# 1. PLAN
Goal:
Understand the problem and determine the minimal solution.
Actions:
- inspect repository state
- read relevant source files
- analyze existing architecture
- identify root cause of the problem
- propose a minimal change
Rules:
- PLAN must **not modify code**
- PLAN must **not change files**
- PLAN must only produce analysis and a proposal
Output should include:
- diagnosis summary
- proposed changes
- expected file scope
---
# 2. REVIEW
Goal:
Validate the plan before implementing it.
Actions:
- confirm the proposed scope is minimal
- ensure no unrelated components are affected
- verify the change aligns with project architecture
Rules:
- If the scope expands unexpectedly, return to PLAN.
---
# 3. BUILD
Goal:
Implement the approved change.
Actions:
- modify only the files defined in scope
- preserve existing architecture
- avoid unnecessary refactors
- maintain compatibility with existing behavior
BUILD tasks may modify:
- source files
- tests
- documentation
BUILD tasks must remain minimal and targeted.
---
# 4. VALIDATE
Goal:
Ensure the repository remains stable.
Agents must run:
```
make fix
make quality
```
Validation must pass before changes are considered complete.
---
# 5. LOG
Goal:
Record the AI activity for traceability.
Three log files must be updated.
### Work Log
```
docs/ai-worklog.md
```
Record:
- AI BUILD ENTRY
- or AI PLAN ENTRY
Including:
- Date (YYYY-MM-DD HH:MM)
- Task ID
- Agent
- Task
- Objective
- Scope
- Files Modified / Files Inspected
- Key Decisions / Proposed Changes
- Validation
- Result
- Open Issues
### Prompt Log
```
docs/ai-prompts.md
```
Record:
- Date (YYYY-MM-DD HH:MM)
- Task ID
- Agent
- Task
- prompt summary
- scope
- result summary
Do not store chain-of-thought reasoning.
### Execution Log
```
docs/ai-execution-log.md
```
Record the full visible execution trace including:
- Date (YYYY-MM-DD HH:MM)
- Task ID
- Agent
- Task Type
- Full prompt text
- Agent todos
- Execution report
The execution log preserves the complete operational record of the AI session.
---
# Task ID Coordination
Every PLAN or BUILD must generate a unique Task ID.
Format:
```
PLAN-YYYYMMDD-XXX
BUILD-YYYYMMDD-XXX
```
Example:
```
PLAN-20260316-001
BUILD-20260316-002
```
The same Task ID must appear in:
- ai-execution-log.md
- ai-prompts.md
- ai-worklog.md
---
# Commit Workflow
Agents must **not commit or push automatically** unless explicitly instructed.
Before creating a commit the agent must ensure:
- validation has passed
- AI logs have been written
- docs/ai-execution-log.md, docs/ai-worklog.md and docs/ai-prompts.md are staged
Commits that omit required AI log entries violate the repository workflow rules.
When commits are requested:
1. Validate repository state
2. Stage modified files
3. Use structured commit messages
Example:
```
feat: improve terminal navigation
- refine tree navigation
- improve scrolling
- update AI logs
```
Documentation-only updates should use:
```
docs:
```
---
# Failure Handling
If validation fails:
Agents must:
1. stop the BUILD process
2. report the error
3. propose a fix
Agents must **not silently bypass failing checks**.
---
# Scope Control
Agents must strictly respect the defined scope.
If additional changes appear necessary:
- return to PLAN
- propose a new change
Do not expand scope during BUILD.
---
# End of Document

View File

@@ -1,179 +0,0 @@
# AI Task Template
Reusable prompt template for launching tasks with AI agents.
This template enforces the repository workflow defined in:
- AGENT.md
- AGENT_WORKFLOW.md
- instructions-agent.md
It ensures every task follows the deterministic sequence:
PLAN → REVIEW → BUILD → VALIDATE → LOG
---
# Basic Task Structure
Use the following structure when requesting work from an AI agent.
```
Task Type:
PLAN | BUILD
Task ID:
PLAN-YYYYMMDD-XXX | BUILD-YYYYMMDD-XXX
Date:
YYYY-MM-DD HH:MM
Objective:
Short description of the goal.
Context:
Relevant background about the current repository state.
Scope:
List of files or directories the agent may modify or inspect.
Requirements:
Detailed description of the behavior that must be implemented or analyzed.
Constraints:
Things the agent must NOT change or must preserve.
Testing:
Describe expected tests or validations.
Validation:
The agent must run:
make fix
make quality
Logging requirements:
- Append an entry to docs/ai-worklog.md
- Append an entry to docs/ai-prompts.md
- Append an entry to docs/ai-execution-log.md
- Include Date, Task ID, Agent, Scope, Result
Execution Logging requirements:
- Append an entry to docs/ai-execution-log.md
- Include:
- full prompt
- agent todos
- final execution report
- Use the standard execution log format defined in docs/ai-execution-log.md
Git requirements:
- Do NOT commit or push unless explicitly requested.
Expected Report:
The agent must summarize:
- files changed
- behavior changes
- validation results
```
---
# Example BUILD Task
```
Task Type:
BUILD
Task ID:
BUILD-YYYYMMDD-XXX
Date:
YYYY-MM-DD HH:MM
Objective:
Improve vertical scrolling behavior in the terminal UI.
Scope:
- src/app/ui.py
- tests/test_ui.py
Requirements:
Ensure the selected entry remains visible when navigating through long directory lists.
Constraints:
Do not modify navigator logic.
Testing:
Add helper tests if necessary.
Validation:
make fix
make quality
Logging requirements:
- Update `docs/ai-worklog.md` and `docs/ai-prompts.md` using the standard log format.
Execution Logging requirements:
- Update docs/ai-execution-log.md with:
- full prompt
- agent todos
- final execution report
Git requirements:
- Do NOT commit or push unless explicitly requested.
```
---
# Example PLAN Task
```
Task Type:
PLAN
Task ID:
PLAN-YYYYMMDD-XXX
Date:
YYYY-MM-DD HH:MM
Objective:
Investigate inconsistent navigation behavior.
Scope:
- src/app/ui.py
- src/app/navigator.py
Requirements:
Analyze the current navigation flow and propose a minimal fix.
Constraints:
Do not modify code.
Validation:
make fix
make quality
Logging requirements:
- Update `docs/ai-worklog.md` and `docs/ai-prompts.md` using the standard log format.
Execution Logging requirements:
- Update docs/ai-execution-log.md with:
- full prompt
- agent todos
- final execution report
Git requirements:
- Do NOT commit or push unless explicitly requested.
Expected Output:
- diagnosis summary
- proposed changes
- expected file scope
```
---
# Notes
This template keeps AI interactions consistent across tasks and helps maintain a clean development history.

View File

@@ -4,16 +4,87 @@ 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. The format is based on Keep a Changelog, and this project follows Semantic Versioning.
## [1.0.0] - 2026-03-14 ## [0.3.0] - 2026-04-06
### Added ### Added
- Initial release of the Python AI Dev Template - Two-line compact header with live session state: version, history count, filter state, hidden/files toggles, and development summary
- Universal Python project structure using `src/` - Persistent history storage between sessions with configurable state path and bounded history size
- `pyproject.toml`-based project configuration - History list rendering in most-recent-first order
- Development tooling with `black`, `ruff`, `mypy`, and `pytest` - History filter mode (`/` inside history) with dedicated query and navigation behavior
- `pre-commit` integration - Development info panel (`i`) with git context, detected stack, and suggested commands
- `Makefile` with development, quality, and diagnostic commands - Configurable open-with menu order via `config.toml`:
- `instructions-agent.md` for AI coding agent guidance - `open_with.files`
- `.env.example` for environment-based configuration - `open_with.directories`
- README with setup, workflow, and template reuse instructions - Automatic creation of opener config template when `config.toml` is missing
- MIT license - 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
- Incremental filter workflow (`/`) with contextual Enter behavior:
- Directories navigate in-place
- Files open the app menu
- File visibility toggle (`a`) to switch between directories-only and mixed view
- Open-with app menu (`o`) with arrow-key selection (`↑`/`↓`, `Enter`, `Esc`)
- New opener support for `nano`, `less`, and `bat` for file actions
- Blocking terminal app execution with UI state restore after exit
### Improved
- Open-with options are now context-aware (GUI/headless, file/directory) and only shown when executables are available
- Better handling of paths containing spaces when launching openers
- Extended README with full controls reference and platform compatibility guidance
## [0.2.5] - 2026-04-03
### Improved
- Add explicit repository link in README for PyPI users to access the full demo and documentation
## [0.2.4] - 2026-04-03
### Fixed
- Use absolute demo GIF URL in README so image renders correctly on PyPI
## [0.2.3] - 2026-04-02
### Added
- Add project demo GIF at `docs/demo.gif`
- Show GIF demo in README
## [0.2.2] - 2026-04-02
### Fixed
- Include `cd_browser_post_install.py` in wheel/sdist so `cd_browser_post_install` works after `pip install`
## [0.2.1] - 2026-04-02
### Fixed
- Prevent crashes when scanning directories with restricted permissions
- Handle non-interactive post-install runs without failing on EOF
- Fix shell reload message interpolation in post-install output
### Improved
- Make post-install shell integration idempotent (avoid duplicate `cd_()` entries)
- Add test coverage for permission errors and post-install flows
- Update default application name to `cd-browser`
## [0.2.0] - 2026-04-02
### Added
- Interactive post-install script (`cd_browser_post_install`) for automatic shell integration
- Option to automatically add `cd_()` function to ~/.bashrc or ~/.zshrc during installation
- Improved uninstall instructions in README
### Improved
- Installation UX: clearer setup process with user prompts
- Shell integration: better guidance for enabling `cd_` command
### Added
- Initial release of **cd-browser**
- Terminal-based directory navigation
- Interactive directory tree navigation
- History navigation mode

158
README.md
View File

@@ -1,46 +1,151 @@
# cd-browser # cd-browser
`cd-browser` is a terminal-native directory navigator for fast filesystem browsing from the command line. **Stop typing paths. Browse them.**
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. `cd-browser` is a fast keyboard-driven directory navigator for the terminal.
It lets you explore directory trees visually and jump to any folder instantly.
![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
## Why cd-browser?
Working in the terminal often means:
- typing long directory paths
- navigating deep folder trees
- repeating `cd ..` multiple times
`cd-browser` provides an **interactive terminal UI** that allows you to browse directories with the keyboard and return the selected path directly to your shell.
## Features
- ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
- 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
- 📜 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
- ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
## Controls
Normal mode:
- `↑` / `↓`: Move selection
- `→`: Expand directory, or enter when already expanded
- `←`: Collapse directory, or go to parent
- `Enter`: Confirm selected path and exit
- `Esc`: Cancel and return the original path
- `h`: Toggle history mode
- `b` / `f`: Back / forward in navigation history
- `.`: Toggle hidden entries
- `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
Filter mode:
- Type text: Filter visible entries in real time (case-insensitive)
- `↑` / `↓`: Move inside filtered results
- `Enter`: Apply filter and perform contextual action:
- Directory: navigate into it without exiting `cd_`
- File: open app selection menu
- `Esc`: Exit filter mode and clear query
- `Backspace`: Remove last filter character
History mode:
- `↑` / `↓`: 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`):
- `↑` / `↓`: Move between app options
- `Enter`: Launch selected app
- `Esc`: Cancel
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`, `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
Run:
```bash
cd_
```
Browse directories using the arrow keys and press **Enter** to jump directly to the selected folder.
## Documentation ## Documentation
See the documentation index: See the documentation index:
```
docs/index.md docs/index.md
```
## 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 ## Installation
Install the project in user mode: Install the project in user mode:
```bash ```bash
pip install . pip install cd-browser
``` ```
For editable local development: **Important**: After installation, run this to set up the `cd_` command:
```bash ```bash
pip install -e '.[dev]' cd_browser_post_install
``` ```
After installation, the application command is: This interactive script will guide you through enabling `cd_` in your shell.
```bash
cd_browser
```
When the interactive session exits, the program prints the final selected directory path.
## Shell Integration For `cd_` ## Shell Integration For `cd_`
@@ -77,6 +182,15 @@ Then use:
cd_ cd_
``` ```
- Inside `cd_browser`, press `.` to toggle hidden entries.
## Platform Compatibility
Practical conclusion:
- Best current support: **macOS and Linux**
- **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
## Uninstall ## Uninstall
If the project was installed with `pip`, remove it with: If the project was installed with `pip`, remove it with:
@@ -85,7 +199,7 @@ If the project was installed with `pip`, remove it with:
pip uninstall cd-browser pip uninstall cd-browser
``` ```
If you also added the `cd_` shell wrapper, remove that function from your shell profile and reload the shell configuration. **Important**: After uninstalling, remove the `cd_()` function from your shell profile (~/.bashrc or ~/.zshrc) to clean up completely.
## Developer Setup ## Developer Setup

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

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

@@ -1,229 +0,0 @@
# 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.
## AI Worklog Policy
This repository maintains an AI-assisted development log.
Files:
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
- `docs/ai-execution-log.md`
Rules:
1. After every significant `plan` or `build` task, append a concise worklog entry to `docs/ai-worklog.md`.
2. Each worklog entry must include:
- date
- task type (`plan` or `build`)
- short objective
- files inspected or modified
- key decisions
- validation commands run
- result
- unresolved issues if any
3. When a prompt meaningfully changes architecture, behavior, workflow, debugging direction, or project structure, append the prompt (or a concise cleaned version of it) to `docs/ai-prompts.md`.
4. Do not store private chain-of-thought or internal reasoning.
5. Store only concise, user-facing summaries of what was done.
6. Keep entries chronological and easy to scan.
## Mandatory AI Logging
Every significant AI interaction in this repository must be logged.
This applies to:
- `plan` tasks
- `build` tasks
- debugging tasks
- documentation tasks
- architectural discussions that affect the project
Required files:
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
- `docs/ai-execution-log.md`
Mandatory rules:
1. After every significant `plan` task, append a concise entry to `docs/ai-worklog.md`.
2. After every significant `build` task, append a concise entry to `docs/ai-worklog.md`.
3. If the task was driven by a meaningful prompt, also append an entry to `docs/ai-prompts.md`.
4. The agent must append a full execution entry to `docs/ai-execution-log.md` including:
- the full prompt provided to the agent
- the agent todo list or execution plan
- the final execution report produced by the agent
5. Logging is part of the task itself and must not be skipped.
6. The task is not complete until the logs are updated.
7. Logs must always be written **before creating a commit**.
8. The agent must ensure that the following files are updated and staged before any commit:
- `docs/ai-worklog.md`
- `docs/ai-prompts.md`
- `docs/ai-execution-log.md`
9. If a commit is created without the corresponding log entries, the task is considered incomplete and must be corrected.
10. Log entries must be part of the same commit whenever possible to maintain traceability between the code change and the AI task that produced it.
Each worklog entry must include:
- date
- task type (`plan` or `build`)
- short objective
- files inspected or modified
- key decisions
- validation commands run, if any
- result
- unresolved issues, if any
Validation logging rule:
- If validation commands such as `make fix`, `make quality`, `pytest`, or other checks are executed during the task, the worklog entry must record them explicitly.
- The value `validation: not run` may only be used if no validation commands were executed.
- The worklog entry must reflect the actual commands run during the task.
Each prompt log entry must include:
- date
- task type (`plan` or `build`)
- short prompt summary
- scope
- result summary
Rules:
- Do not store chain-of-thought or private reasoning.
- Store only concise user-facing summaries.
- Keep entries chronological.
- Even small but meaningful tasks must be logged.
Execution log entries must include:
- date
- task ID
- task type (`plan` or `build`)
- full prompt text
- agent todos
- execution report
## Standard AI Log Entry Format
To ensure consistency and readability of long AI-assisted development histories, all log entries must follow a standardized block format.
Agents must write log entries using the following structure.
Example for build tasks:
### AI BUILD ENTRY
Date: YYYY-MM-DD HH:MM
Task ID: BUILD-YYYYMMDD-XXX
Agent: OpenCode
Task: build
Objective: short description of the task
Scope:
- src/app/module.py
- tests/test_module.py
Files Modified:
- file/path/example.py
- another/file.md
Key Decisions:
- short bullet explaining important choices
Validation:
- make fix (passed)
- make quality (passed)
Result:
- short description of what changed or was achieved
Open Issues:
- optional list of unresolved problems
Example for plan tasks:
### AI PLAN ENTRY
Date: YYYY-MM-DD HH:MM
Task ID: PLAN-YYYYMMDD-XXX
Agent: OpenCode
Task: plan
Objective: short description of planning objective
Scope:
- src/app/module.py
- specs/module_spec.md
Files Inspected:
- src/app/example.py
- specs/example_spec.md
Proposed Changes:
- summary of planned changes
Notes:
- optional relevant observations
Rules:
- Always use these headers exactly (`AI BUILD ENTRY` or `AI PLAN ENTRY`).
- Keep entries concise and structured.
- Do not include chain-of-thought reasoning.
- Use bullet points when possible.
- This format must be used when updating `docs/ai-worklog.md`.
- The Date field must include both date and time using the format YYYY-MM-DD HH:MM.
- Every log entry must include a `Task ID`.
- `Task ID` format must be `BUILD-YYYYMMDD-XXX` or `PLAN-YYYYMMDD-XXX`.
- `Agent` must identify the tool or AI system performing the task (e.g., OpenCode).
- `Scope` must describe the main files or project areas affected.
- These fields make the AI worklog suitable for long-term traceability and large histories.

View File

@@ -4,17 +4,31 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "cd-browser" name = "cd-browser"
version = "0.1.0" version = "0.3.0"
description = "A universal and adaptable Python project template for AI-assisted development." description = "A fast keyboard-driven directory navigator for the terminal."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.8"
authors = [ authors = [
{ name = "Saky" } { name = "Saky" }
] ]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Utilities",
]
dependencies = [] dependencies = []
[project.scripts] [project.scripts]
cd_browser = "app.main:main" cd_browser = "app.main:main"
cd_browser_post_install = "cd_browser_post_install:main"
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
@@ -27,6 +41,7 @@ dev = [
[tool.setuptools] [tool.setuptools]
package-dir = {"" = "src"} package-dir = {"" = "src"}
py-modules = ["cd_browser_post_install"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from app.history import SessionHistory
from app.navigator import Navigator from app.navigator import Navigator
from app.ui import TerminalUI 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.""" """Run the interactive directory browser and return the selected path."""
initial_path = (start_path or Path.cwd()).expanduser().resolve() 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() terminal_ui = ui or TerminalUI()
return terminal_ui.run(navigator) return terminal_ui.run(navigator)

View File

@@ -5,5 +5,5 @@ from dataclasses import dataclass
class AppConfig: class AppConfig:
"""Application configuration.""" """Application configuration."""
app_name: str = "Python AI Dev Template" app_name: str = "cd-browser"
debug: bool = False debug: bool = False

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

@@ -13,28 +13,95 @@ class DirectoryEntry:
has_children: bool has_children: bool
def list_directories(path: Path) -> list[DirectoryEntry]: @dataclass(frozen=True, slots=True)
class FileSystemEntry:
"""Filesystem entry metadata for tree rendering."""
name: str
path: Path
is_directory: bool
has_children: bool
def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]:
"""Return direct child directories sorted by name.""" """Return direct child directories sorted by name."""
directory_path = path.expanduser().resolve() entries = list_entries(path, show_hidden=show_hidden, include_files=False)
entries: list[DirectoryEntry] = [] return [
for child in directory_path.iterdir():
if child.is_dir():
entries.append(
DirectoryEntry( DirectoryEntry(
name=entry.name,
path=entry.path,
has_children=entry.has_children,
)
for entry in entries
]
def list_entries(
path: Path, show_hidden: bool = False, include_files: bool = False
) -> list[FileSystemEntry]:
"""Return direct child entries sorted by type and name."""
directory_path = path.expanduser().resolve()
entries: list[FileSystemEntry] = []
try:
children = list(directory_path.iterdir())
except PermissionError:
return []
for child in children:
try:
is_directory = child.is_dir()
except OSError:
continue
if not is_directory and not include_files:
continue
if not show_hidden and child.name.startswith("."):
continue
entries.append(
FileSystemEntry(
name=child.name, name=child.name,
path=child, path=child,
has_children=has_subdirectories(child), is_directory=is_directory,
has_children=(
has_subdirectories(child, show_hidden=show_hidden)
if is_directory
else False
),
) )
) )
return sorted(entries, key=lambda entry: entry.name.casefold()) return sorted(
entries,
key=lambda entry: (not entry.is_directory, entry.name.casefold()),
)
def has_subdirectories(path: Path) -> bool: def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:
"""Return whether the directory contains at least one subdirectory.""" """Return whether the directory contains at least one subdirectory."""
directory_path = path.expanduser().resolve() directory_path = path.expanduser().resolve()
return any(child.is_dir() for child in directory_path.iterdir()) try:
children = list(directory_path.iterdir())
except PermissionError:
return False
for child in children:
try:
is_directory = child.is_dir()
except OSError:
continue
if not is_directory:
continue
if not show_hidden and child.name.startswith("."):
continue
return True
return False

View File

@@ -1,15 +1,46 @@
from __future__ import annotations from __future__ import annotations
import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path 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) @dataclass(slots=True)
class SessionHistory: class SessionHistory:
"""In-memory session history for visited directories.""" """In-memory session history for visited directories."""
_entries: list[Path] = field(default_factory=list) _entries: list[Path] = field(default_factory=list)
_index: int = -1 _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: def visit(self, path: Path) -> Path:
"""Record a directory visit and discard forward history when needed.""" """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 = self._entries[: self._index + 1]
self._entries.append(resolved_path) self._entries.append(resolved_path)
self._trim_to_limit()
self._index = len(self._entries) - 1 self._index = len(self._entries) - 1
self._persist()
return resolved_path return resolved_path
@property @property
@@ -66,3 +99,48 @@ class SessionHistory:
"""Return the full history as an immutable sequence.""" """Return the full history as an immutable sequence."""
return tuple(self._entries) 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

@@ -3,13 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from app.filesystem import list_directories from app.filesystem import list_entries
from app.history import SessionHistory from app.history import SessionHistory
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class VisibleEntry: class VisibleEntry:
"""A directory row visible in the navigator.""" """A row visible in the navigator tree."""
name: str name: str
path: Path path: Path
@@ -17,6 +17,7 @@ class VisibleEntry:
has_children: bool has_children: bool
is_expanded: bool is_expanded: bool
is_parent: bool = False is_parent: bool = False
is_file: bool = False
@dataclass(slots=True) @dataclass(slots=True)
@@ -28,6 +29,8 @@ class Navigator:
current_path: Path = field(init=False) current_path: Path = field(init=False)
selected_index: int = field(init=False, default=0) selected_index: int = field(init=False, default=0)
_expanded_paths: set[Path] = field(init=False, default_factory=set) _expanded_paths: set[Path] = field(init=False, default_factory=set)
show_hidden: bool = False
show_files: bool = False
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.current_path = self.start_path.expanduser().resolve() self.current_path = self.start_path.expanduser().resolve()
@@ -68,6 +71,9 @@ class Navigator:
def enter_selected_directory(self) -> Path: def enter_selected_directory(self) -> Path:
"""Enter the currently selected directory and record it in history.""" """Enter the currently selected directory and record it in history."""
if self.selected_entry.is_file:
return self.current_path
return self._change_directory(self.selected_entry.path) return self._change_directory(self.selected_entry.path)
def go_to_parent_directory(self) -> Path: def go_to_parent_directory(self) -> Path:
@@ -144,6 +150,22 @@ class Navigator:
self._set_current_path(path) self._set_current_path(path)
return path return path
def toggle_hidden(self) -> None:
"""Toggle showing/hiding hidden directory entries."""
self.show_hidden = not self.show_hidden
self._reset_view_state()
def toggle_files(self) -> None:
"""Toggle showing/hiding file entries."""
self.show_files = not self.show_files
self._reset_view_state()
def _reset_view_state(self) -> None:
self.selected_index = 0
self._expanded_paths.clear()
def select_history_entry(self, index: int) -> Path: def select_history_entry(self, index: int) -> Path:
"""Jump to a directory stored in session history.""" """Jump to a directory stored in session history."""
@@ -154,20 +176,25 @@ class Navigator:
def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]: def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
entries: list[VisibleEntry] = [] entries: list[VisibleEntry] = []
for directory in list_directories(path): for item in list_entries(
is_expanded = directory.path in self._expanded_paths path, show_hidden=self.show_hidden, include_files=self.show_files
):
is_expanded = (
item.path in self._expanded_paths if item.is_directory else False
)
entries.append( entries.append(
VisibleEntry( VisibleEntry(
name=directory.name, name=item.name,
path=directory.path, path=item.path,
depth=depth, depth=depth,
has_children=directory.has_children, has_children=item.has_children,
is_expanded=is_expanded, is_expanded=is_expanded,
is_file=not item.is_directory,
) )
) )
if is_expanded: if is_expanded:
entries.extend(self._build_entries(directory.path, depth=depth + 1)) entries.extend(self._build_entries(item.path, depth=depth + 1))
return entries return entries

311
src/app/opener.py Normal file
View File

@@ -0,0 +1,311 @@
from __future__ import annotations
import os
import shlex
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)
class OpenWithOption:
"""Candidate application command for opening a path."""
label: str
command_template: str
executable: str
launch_mode: LaunchMode
requires_gui: bool = False
supports_files: bool = True
supports_directories: bool = True
def available_openers(path: Path) -> list[OpenWithOption]:
"""Return available open-with options for a file or directory path."""
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_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:
continue
if option.requires_gui and not has_gui:
continue
if not shutil.which(option.executable):
continue
options.append(option)
return options
def launch_background_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a non-blocking option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as error:
return False, f"Open failed: {error}"
return True, f"Opened with {option.label}"
def launch_blocking_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
"""Launch a blocking terminal option and return status/message."""
command = _command_parts(option.command_template, path)
if not command:
return False, "Invalid open command"
try:
result = subprocess.run(command, check=False)
except OSError as error:
return False, f"Open failed: {error}"
if result.returncode != 0:
return (
False,
f"Open failed: {option.label} exited with code {result.returncode}",
)
return True, f"Opened with {option.label}"
def is_blocking_option(option: OpenWithOption) -> bool:
"""Return whether the option should block the current terminal session."""
return option.launch_mode == "blocking"
def _options_catalog() -> dict[str, OpenWithOption]:
return {
"code": OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
),
"open": OpenWithOption(
label="Open (system)",
command_template=_system_open_template(),
executable=_system_open_executable(),
launch_mode="background",
requires_gui=True,
supports_directories=False,
),
"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",
),
"nvim": OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
),
"nano": OpenWithOption(
label="Nano",
command_template="nano {path}",
executable="nano",
launch_mode="blocking",
supports_directories=False,
),
"less": OpenWithOption(
label="Less",
command_template="less {path}",
executable="less",
launch_mode="blocking",
supports_directories=False,
),
"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:
if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
return False
if sys.platform == "darwin":
return True
if sys.platform.startswith("linux"):
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
if sys.platform.startswith("win"):
return True
return False
def _system_open_template() -> str:
if sys.platform == "darwin":
return "open {path}"
if sys.platform.startswith("linux"):
return "xdg-open {path}"
if sys.platform.startswith("win"):
return 'cmd /c start "" {path}'
return "open {path}"
def _system_open_executable() -> str:
if sys.platform == "darwin":
return "open"
if sys.platform.startswith("linux"):
return "xdg-open"
if sys.platform.startswith("win"):
return "cmd"
return "open"
def _command_parts(command_template: str, path: Path) -> list[str]:
rendered = command_template.format(path=shlex.quote(str(path)))
return shlex.split(rendered)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Post-install script for cd-browser to guide users on shell integration."""
import os
import sys
def _profile_for_shell(shell: str) -> str:
if shell == "zsh":
return "~/.zshrc"
return "~/.bashrc"
def _is_cd_function_present(profile_path: str) -> bool:
if not os.path.exists(profile_path):
return False
with open(profile_path, encoding="utf-8") as profile_file:
return "cd_() {" in profile_file.read()
def main() -> None:
"""Print shell integration instructions after installation."""
print("\n" + "=" * 60)
print("🎉 cd-browser installed successfully!")
print("=" * 60)
print()
print(
"To enable the 'cd_' command, you need to add a function to your shell profile."
)
print()
shell = os.environ.get("SHELL", "").split("/")[-1]
profile = _profile_for_shell(shell)
cd_function = """cd_() {
local target
target="$(cd_browser)" || return
if [ -n "$target" ] && [ -d "$target" ]; then
cd "$target"
fi
}"""
print(f"Copy this to the end of {profile}:")
print()
print(cd_function)
print()
profile_path = os.path.expanduser(profile)
try:
response = (
input("Do you want me to add it automatically? (y/n): ").strip().lower()
)
if response == "y":
if _is_cd_function_present(profile_path):
print(f"\n cd_() already exists in {profile}, no changes made.")
else:
with open(profile_path, "a", encoding="utf-8") as f:
f.write("\n" + cd_function + "\n")
print(f"\n✅ Added to {profile}")
print(f"Run 'source {profile}' or restart your terminal to activate cd_.")
else:
print(f"\nAdd the function to {profile} manually.")
print(f"Then run 'source {profile}' or restart terminal.")
except EOFError:
print(
f"\nNo interactive input detected. Add the function to {profile} manually."
)
print(f"Then run 'source {profile}' or restart terminal.")
except KeyboardInterrupt:
print("\n\nSetup cancelled. You can add the function manually later.")
sys.exit(0)
print("\n" + "=" * 60)
print(
f"For uninstall: after 'pip uninstall cd-browser', remove cd_() from {profile}."
)
print("=" * 60 + "\n")
if __name__ == "__main__":
main()

View File

@@ -4,9 +4,10 @@ import pytest
from app.cli import run_cli from app.cli import run_cli
from app.navigator import Navigator from app.navigator import Navigator
from app.ui import TerminalUI
class StubUI: class StubUI(TerminalUI):
def __init__(self, result: Path) -> None: def __init__(self, result: Path) -> None:
self.result = result self.result = result
self.navigator: Navigator | None = None self.navigator: Navigator | None = None

View File

@@ -2,7 +2,13 @@ from pathlib import Path
import pytest import pytest
from app.filesystem import DirectoryEntry, has_subdirectories, list_directories from app.filesystem import (
DirectoryEntry,
FileSystemEntry,
has_subdirectories,
list_directories,
list_entries,
)
def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None: def test_has_subdirectories_detects_nested_directory(tmp_path: Path) -> None:
@@ -44,3 +50,55 @@ def test_list_directories_raises_for_missing_directory(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
list_directories(missing_path) list_directories(missing_path)
def test_list_entries_returns_files_when_include_files_enabled(tmp_path: Path) -> None:
folder = tmp_path / "folder"
file_path = tmp_path / "notes.txt"
folder.mkdir()
file_path.write_text("content", encoding="utf-8")
assert list_entries(tmp_path, include_files=True) == [
FileSystemEntry(
name="folder",
path=folder,
is_directory=True,
has_children=False,
),
FileSystemEntry(
name="notes.txt",
path=file_path,
is_directory=False,
has_children=False,
),
]
def test_list_directories_skips_permission_errors_for_children(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
allowed = root / "allowed"
blocked = root / "blocked"
allowed.mkdir()
blocked.mkdir()
blocked.chmod(0)
try:
entries = list_directories(root)
finally:
blocked.chmod(0o700)
assert any(entry.name == "allowed" for entry in entries)
def test_has_subdirectories_returns_false_on_permission_error(tmp_path: Path) -> None:
blocked = tmp_path / "blocked"
blocked.mkdir()
blocked.chmod(0)
try:
result = has_subdirectories(blocked)
finally:
blocked.chmod(0o700)
assert result is False

View File

@@ -2,7 +2,7 @@ from pathlib import Path
import pytest 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: 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): with pytest.raises(IndexError):
history.select(0) 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

@@ -25,6 +25,49 @@ def test_navigator_initial_state_includes_parent_entry(tmp_path: Path) -> None:
] ]
def test_toggle_hidden_directories(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
visible = root / "visible"
visible.mkdir()
hidden = root / ".hidden"
hidden.mkdir()
navigator = Navigator(root)
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
navigator.toggle_hidden()
assert navigator.show_hidden is True
assert [entry.name for entry in navigator.visible_entries] == [
"..",
".hidden",
"visible",
]
navigator.toggle_hidden()
assert navigator.show_hidden is False
assert [entry.name for entry in navigator.visible_entries] == ["..", "visible"]
def test_toggle_files_includes_files_in_visible_entries(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "folder").mkdir()
(root / "notes.txt").write_text("hello", encoding="utf-8")
navigator = Navigator(root)
assert [entry.name for entry in navigator.visible_entries] == ["..", "folder"]
navigator.toggle_files()
assert navigator.show_files is True
assert [entry.name for entry in navigator.visible_entries] == [
"..",
"folder",
"notes.txt",
]
def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None: def test_expand_selected_directory_reveals_nested_entries(tmp_path: Path) -> None:
current = tmp_path / "workspace" current = tmp_path / "workspace"
current.mkdir() current.mkdir()

254
tests/test_opener.py Normal file
View File

@@ -0,0 +1,254 @@
from __future__ import annotations
from pathlib import Path
from subprocess import CompletedProcess
import pytest
from app.opener import (
OpenWithOption,
_reset_openers_config_cache,
available_openers,
is_blocking_option,
launch_background_option,
launch_blocking_option,
)
@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:
file_path = tmp_path / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
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",
"code": "/usr/local/bin/code",
"open": "/usr/bin/open",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
labels = [option.label for option in available_openers(file_path)]
assert "VS Code" not in labels
assert "Open (system)" not in labels
assert labels == ["Antigravity", "Neovim", "Nano", "Less", "Bat"]
def test_available_openers_gui_directory_filters_file_only_tools(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
folder = tmp_path / "project"
folder.mkdir()
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr("app.opener.sys.platform", "darwin")
available = {
"antigravity": "/usr/local/bin/antigravity",
"nvim": "/usr/local/bin/nvim",
"nano": "/usr/bin/nano",
"less": "/usr/bin/less",
"bat": "/usr/local/bin/bat",
"code": "/usr/local/bin/code",
"open": "/usr/bin/open",
}
monkeypatch.setattr("app.opener.shutil.which", lambda name: available.get(name))
labels = [option.label for option in available_openers(folder)]
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:
option = OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
requires_gui=True,
)
monkeypatch.setattr("app.opener.subprocess.Popen", lambda *args, **kwargs: None)
success, message = launch_background_option(tmp_path, option)
assert success is True
assert "VS Code" in message
def test_launch_blocking_option_reports_non_zero_exit(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
option = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
monkeypatch.setattr(
"app.opener.subprocess.run",
lambda *args, **kwargs: CompletedProcess(args=[], returncode=2),
)
success, message = launch_blocking_option(tmp_path, option)
assert success is False
assert "code 2" in message
def test_is_blocking_option_uses_launch_mode() -> None:
blocking = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
background = OpenWithOption(
label="VS Code",
command_template="code {path}",
executable="code",
launch_mode="background",
)
assert is_blocking_option(blocking) is True
assert is_blocking_option(background) is False
def test_launch_blocking_option_handles_paths_with_spaces(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
target_path = tmp_path / "COMPETENCIAS A.LEDO"
option = OpenWithOption(
label="Neovim",
command_template="nvim {path}",
executable="nvim",
launch_mode="blocking",
)
captured: dict[str, list[str]] = {}
def fake_run(command: list[str], check: bool) -> CompletedProcess[object]:
captured["command"] = command
return CompletedProcess(args=[], returncode=0)
monkeypatch.setattr("app.opener.subprocess.run", fake_run)
success, _message = launch_blocking_option(target_path, option)
assert success is True
assert captured["command"] == ["nvim", str(target_path)]

View File

@@ -0,0 +1,47 @@
from pathlib import Path
import pytest
from cd_browser_post_install import main
CD_FUNCTION_SNIPPET = "cd_() {"
def test_post_install_handles_eof_without_crashing(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("SHELL", "/bin/zsh")
monkeypatch.setattr(
"builtins.input", lambda _prompt: (_ for _ in ()).throw(EOFError)
)
main()
output = capsys.readouterr().out
assert "No interactive input detected" in output
assert "source ~/.zshrc" in output
def test_post_install_adds_cd_function_once(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
monkeypatch.setenv("SHELL", "/bin/zsh")
monkeypatch.setenv("HOME", str(tmp_path))
responses = iter(["y", "y"])
monkeypatch.setattr("builtins.input", lambda _prompt: next(responses))
main()
main()
profile_path = tmp_path / ".zshrc"
content = profile_path.read_text(encoding="utf-8")
assert content.count(CD_FUNCTION_SNIPPET) == 1
output = capsys.readouterr().out
assert "already exists" in output
assert "source ~/.zshrc" in output

View File

@@ -5,11 +5,13 @@ import pytest
from app.navigator import Navigator, VisibleEntry from app.navigator import Navigator, VisibleEntry
from app.ui import ( from app.ui import (
ESCAPE_KEY,
HistoryLine, HistoryLine,
RenderLine, RenderLine,
TerminalUI, TerminalUI,
build_history_lines, build_history_lines,
build_render_lines, build_render_lines,
build_status_line,
clamp_scroll_offset, clamp_scroll_offset,
format_entry, format_entry,
move_selection, move_selection,
@@ -52,6 +54,19 @@ def test_format_entry_supports_parent_and_tree_indicators(tmp_path: Path) -> Non
assert format_entry(expanded_child) == " build" assert format_entry(expanded_child) == " build"
def test_format_entry_marks_files_with_file_indicator(tmp_path: Path) -> None:
file_entry = VisibleEntry(
name="notes.txt",
path=tmp_path / "notes.txt",
depth=0,
has_children=False,
is_expanded=False,
is_file=True,
)
assert format_entry(file_entry) == "• notes.txt"
def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None: def test_build_render_lines_marks_selected_entry(tmp_path: Path) -> None:
workspace = tmp_path / "workspace" workspace = tmp_path / "workspace"
workspace.mkdir() workspace.mkdir()
@@ -106,7 +121,7 @@ def test_toggle_history_mode_selects_current_history_entry(tmp_path: Path) -> No
ui._toggle_history_mode(navigator) ui._toggle_history_mode(navigator)
assert ui._history_mode is True 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: def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
@@ -126,7 +141,7 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
ui = TerminalUI() ui = TerminalUI()
ui._toggle_history_mode(navigator) 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) ui._handle_history_key(10, navigator)
assert ui._history_mode is False assert ui._history_mode is False
@@ -134,6 +149,86 @@ def test_history_mode_enter_selects_entry_and_exits(tmp_path: Path) -> None:
assert navigator.selected_index == 0 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: def test_history_mode_toggle_off_keeps_current_directory(tmp_path: Path) -> None:
root = tmp_path / "root" root = tmp_path / "root"
root.mkdir() root.mkdir()
@@ -221,6 +316,160 @@ def test_enter_returns_selected_path_without_navigating(tmp_path: Path) -> None:
assert navigator.current_path == workspace.resolve() assert navigator.current_path == workspace.resolve()
def test_toggle_files_key_updates_navigator_state(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "folder").mkdir()
(root / "notes.txt").write_text("content", encoding="utf-8")
navigator = Navigator(root)
ui = TerminalUI()
assert navigator.show_files is False
assert ui._handle_tree_key(ord("a"), navigator) is None
assert navigator.show_files is True
def test_filter_matching_is_case_insensitive(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
(root / "Alpha").mkdir()
(root / "beta").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "alp"
matches = ui._matching_indices(navigator.visible_entries)
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()
(root / "alpha").mkdir()
(root / "gamma").mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
assert ui._handle_filter_key(ord("g"), navigator) is None
assert ui._handle_filter_key(ord("G"), navigator) is None
assert ui._filter_query == "gG"
def test_filter_enter_on_directory_navigates_and_exits_filter(tmp_path: Path) -> None:
root = tmp_path / "root"
child = root / "child"
root.mkdir()
child.mkdir()
navigator = Navigator(root)
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "child"
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert navigator.current_path == child.resolve()
def test_filter_enter_on_file_opens_menu_and_exits_filter(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "root"
root.mkdir()
file_path = root / "notes.txt"
file_path.write_text("hello", encoding="utf-8")
navigator = Navigator(root)
navigator.toggle_files()
ui = TerminalUI()
ui._filter_mode = True
ui._filter_query = "notes"
opened: dict[str, bool] = {"called": False}
def fake_open_with_selected(
_navigator: Navigator, _stdscr: curses.window | None
) -> None:
opened["called"] = True
monkeypatch.setattr(ui, "_open_with_selected", fake_open_with_selected)
result = ui._handle_filter_key(10, navigator)
assert result is None
assert ui._filter_mode is False
assert opened["called"] is True
assert navigator.selected_entry.path == file_path.resolve()
def test_end_key_jumps_to_last_entry(tmp_path: Path) -> None:
root = tmp_path / "root"
root.mkdir()
for name in ("alpha", "beta", "gamma"):
(root / name).mkdir()
navigator = Navigator(root)
ui = TerminalUI()
assert ui._handle_tree_key(curses.KEY_END, navigator) is 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( def test_open_terminal_streams_raises_without_terminal(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None: