Files
Autoparts-DB/docs/plans/2026-02-14-pick-console-plan.md
consultoria-as 4af3a09b03 docs: add console system documentation and design docs
Console README with usage instructions, keybindings reference, architecture
overview, and test commands. Updated root README with console section, updated
architecture diagram, and installation instructions. Includes approved design
doc and implementation plan.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 02:03:22 +00:00

63 KiB

Pick-Style Console System - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build a keyboard-driven Pick/VT220-inspired console application for the autoparts catalog with two rendering modes (curses and textual).

Architecture: Three-layer architecture — DB layer (SQLite, swappable to PostgreSQL), business logic screens, and pluggable renderers (curses for VT220 aesthetic, textual for modern TUI). All screens define data/actions abstractly; renderers handle drawing.

Tech Stack: Python 3, curses (stdlib), textual/rich (pip), sqlite3 (stdlib), requests (for NHTSA VIN API)


Task 1: Project Scaffold and Config

Files:

  • Create: console/__init__.py
  • Create: console/main.py
  • Create: console/config.py
  • Create: console/core/__init__.py
  • Create: console/screens/__init__.py
  • Create: console/renderers/__init__.py
  • Create: console/utils/__init__.py

Step 1: Create directory structure

mkdir -p /home/Autopartes/console/{core,screens,renderers,utils}
touch /home/Autopartes/console/__init__.py
touch /home/Autopartes/console/core/__init__.py
touch /home/Autopartes/console/screens/__init__.py
touch /home/Autopartes/console/renderers/__init__.py
touch /home/Autopartes/console/utils/__init__.py

Step 2: Write config.py

# console/config.py
import os

VERSION = "1.0.0"
APP_NAME = "AUTOPARTES"
APP_SUBTITLE = "Sistema de Catálogo de Autopartes"

# Database
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'vehicle_database', 'vehicle_database.db')

# VIN API
NHTSA_API_URL = "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVin"
VIN_CACHE_DAYS = 30

# Display
DEFAULT_MODE = "vt220"  # "vt220" or "modern"
PAGE_SIZE = 15  # rows per page in lists/tables

# Color schemes for curses (name: (fg, bg))
COLORS_VT220 = {
    'header': ('green', 'black'),
    'footer': ('black', 'green'),
    'normal': ('green', 'black'),
    'highlight': ('black', 'green'),
    'border': ('green', 'black'),
    'title': ('white', 'black'),
    'error': ('red', 'black'),
    'info': ('cyan', 'black'),
    'field_label': ('green', 'black'),
    'field_value': ('white', 'black'),
    'field_active': ('black', 'cyan'),
}

Step 3: Write main.py entry point

# console/main.py
import argparse
import sys
from console.config import VERSION, DEFAULT_MODE


def parse_args():
    parser = argparse.ArgumentParser(description='Autopartes Console - Pick-Style Catalog System')
    parser.add_argument('--mode', choices=['vt220', 'modern'], default=DEFAULT_MODE,
                        help='Rendering mode: vt220 (curses) or modern (textual)')
    parser.add_argument('--version', action='version', version=f'Autopartes Console v{VERSION}')
    return parser.parse_args()


def main():
    args = parse_args()

    if args.mode == 'vt220':
        from console.renderers.curses_renderer import CursesRenderer
        renderer = CursesRenderer()
    else:
        from console.renderers.textual_renderer import TextualRenderer
        renderer = TextualRenderer()

    from console.db import Database
    from console.core.app import App

    db = Database()
    app = App(renderer, db)
    app.run()


if __name__ == '__main__':
    main()

Step 4: Commit

git add console/
git commit -m "feat(console): scaffold project structure and config"

Task 2: Database Abstraction Layer

Files:

  • Create: console/db.py
  • Create: console/tests/__init__.py
  • Create: console/tests/test_db.py

Step 1: Write test for DB layer

# console/tests/test_db.py
import os
import sys
import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from console.db import Database


@pytest.fixture
def db():
    return Database()


def test_get_brands(db):
    brands = db.get_brands()
    assert isinstance(brands, list)
    assert len(brands) > 0
    assert 'name' in brands[0]


def test_get_models_no_filter(db):
    models = db.get_models()
    assert isinstance(models, list)
    assert len(models) > 0


def test_get_models_by_brand(db):
    models = db.get_models(brand='TOYOTA')
    assert isinstance(models, list)
    assert len(models) > 0


def test_get_categories(db):
    cats = db.get_categories()
    assert isinstance(cats, list)
    assert len(cats) == 12


def test_get_groups(db):
    groups = db.get_groups(category_id=2)
    assert isinstance(groups, list)
    assert len(groups) > 0
    assert 'name' in groups[0]


def test_get_parts_by_group(db):
    parts = db.get_parts(group_id=17)
    assert isinstance(parts, list)


def test_get_part_detail(db):
    part = db.get_part(1)
    assert part is not None
    assert 'oem_part_number' in part


def test_get_alternatives(db):
    alts = db.get_alternatives(1)
    assert isinstance(alts, list)


def test_get_cross_references(db):
    refs = db.get_cross_references(1)
    assert isinstance(refs, list)


def test_search_parts_fts(db):
    results = db.search_parts('brake')
    assert isinstance(results, list)
    assert len(results) > 0


def test_search_part_number(db):
    results = db.search_part_number('04465')
    assert isinstance(results, list)


def test_get_stats(db):
    stats = db.get_stats()
    assert 'brands' in stats
    assert 'models' in stats
    assert 'parts' in stats
    assert stats['brands'] > 0


def test_get_years(db):
    years = db.get_years(brand='TOYOTA', model='CAMRY')
    assert isinstance(years, list)


def test_get_engines(db):
    engines = db.get_engines(brand='TOYOTA', model='CAMRY')
    assert isinstance(engines, list)

Step 2: Run test to verify it fails

cd /home/Autopartes && python -m pytest console/tests/test_db.py -v

Expected: FAIL — ModuleNotFoundError: No module named 'console.db'

Step 3: Implement db.py

# console/db.py
import sqlite3
import os
from console.config import DB_PATH


class Database:
    def __init__(self, db_path=None):
        self.db_path = db_path or DB_PATH

    def _connect(self):
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        return conn

    def _query(self, sql, params=(), one=False):
        conn = self._connect()
        cursor = conn.cursor()
        cursor.execute(sql, params)
        rows = cursor.fetchall()
        conn.close()
        if one:
            return dict(rows[0]) if rows else None
        return [dict(r) for r in rows]

    def _execute(self, sql, params=()):
        conn = self._connect()
        cursor = conn.cursor()
        cursor.execute(sql, params)
        conn.commit()
        last_id = cursor.lastrowid
        conn.close()
        return last_id

    # === Vehicle Navigation ===

    def get_brands(self):
        return self._query("""
            SELECT id, name, country FROM brands ORDER BY name
        """)

    def get_models(self, brand=None):
        if brand:
            return self._query("""
                SELECT DISTINCT m.id, m.name
                FROM models m
                JOIN brands b ON m.brand_id = b.id
                WHERE UPPER(b.name) = UPPER(?)
                ORDER BY m.name
            """, (brand,))
        return self._query("SELECT id, name FROM models ORDER BY name")

    def get_years(self, brand=None, model=None):
        sql = """
            SELECT DISTINCT y.id, y.year
            FROM years y
            JOIN model_year_engine mye ON mye.year_id = y.id
            JOIN models m ON mye.model_id = m.id
            JOIN brands b ON m.brand_id = b.id
            WHERE 1=1
        """
        params = []
        if brand:
            sql += " AND UPPER(b.name) = UPPER(?)"
            params.append(brand)
        if model:
            sql += " AND UPPER(m.name) = UPPER(?)"
            params.append(model)
        sql += " ORDER BY y.year DESC"
        return self._query(sql, params)

    def get_engines(self, brand=None, model=None, year=None):
        sql = """
            SELECT DISTINCT e.id, e.name, e.displacement_cc, e.cylinders,
                   e.fuel_type, e.power_hp, e.torque_nm
            FROM engines e
            JOIN model_year_engine mye ON mye.engine_id = e.id
            JOIN models m ON mye.model_id = m.id
            JOIN brands b ON m.brand_id = b.id
            WHERE 1=1
        """
        params = []
        if brand:
            sql += " AND UPPER(b.name) = UPPER(?)"
            params.append(brand)
        if model:
            sql += " AND UPPER(m.name) = UPPER(?)"
            params.append(model)
        if year:
            sql += " AND mye.year_id IN (SELECT id FROM years WHERE year = ?)"
            params.append(int(year))
        sql += " ORDER BY e.name"
        return self._query(sql, params)

    def get_model_year_engine(self, brand, model, year, engine_id):
        return self._query("""
            SELECT mye.id, mye.trim_level, mye.drivetrain, mye.transmission
            FROM model_year_engine mye
            JOIN models m ON mye.model_id = m.id
            JOIN brands b ON m.brand_id = b.id
            JOIN years y ON mye.year_id = y.id
            WHERE UPPER(b.name) = UPPER(?)
              AND UPPER(m.name) = UPPER(?)
              AND y.year = ?
              AND mye.engine_id = ?
        """, (brand, model, int(year), engine_id))

    # === Parts Catalog ===

    def get_categories(self):
        return self._query("""
            SELECT id, name, name_es, icon_name, slug, display_order
            FROM part_categories ORDER BY display_order
        """)

    def get_groups(self, category_id):
        return self._query("""
            SELECT id, name, name_es, slug, display_order
            FROM part_groups WHERE category_id = ? ORDER BY display_order
        """, (category_id,))

    def get_parts(self, group_id=None, mye_id=None, page=1, per_page=15):
        offset = (page - 1) * per_page
        if mye_id and group_id:
            return self._query("""
                SELECT p.id, p.oem_part_number, p.name, p.name_es,
                       pg.name AS group_name, vp.quantity_required, vp.position,
                       (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
                FROM parts p
                JOIN vehicle_parts vp ON vp.part_id = p.id
                JOIN part_groups pg ON p.group_id = pg.id
                WHERE vp.model_year_engine_id = ? AND p.group_id = ?
                ORDER BY p.name
                LIMIT ? OFFSET ?
            """, (mye_id, group_id, per_page, offset))
        elif mye_id:
            return self._query("""
                SELECT p.id, p.oem_part_number, p.name, p.name_es,
                       pg.name AS group_name, vp.quantity_required, vp.position,
                       (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
                FROM parts p
                JOIN vehicle_parts vp ON vp.part_id = p.id
                JOIN part_groups pg ON p.group_id = pg.id
                WHERE vp.model_year_engine_id = ?
                ORDER BY pg.display_order, p.name
                LIMIT ? OFFSET ?
            """, (mye_id, per_page, offset))
        elif group_id:
            return self._query("""
                SELECT p.id, p.oem_part_number, p.name, p.name_es,
                       pg.name AS group_name,
                       (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
                FROM parts p
                JOIN part_groups pg ON p.group_id = pg.id
                WHERE p.group_id = ?
                ORDER BY p.name
                LIMIT ? OFFSET ?
            """, (group_id, per_page, offset))
        return self._query("""
            SELECT p.id, p.oem_part_number, p.name, p.name_es,
                   pg.name AS group_name,
                   (SELECT COUNT(*) FROM aftermarket_parts ap WHERE ap.oem_part_id = p.id) AS alt_count
            FROM parts p
            JOIN part_groups pg ON p.group_id = pg.id
            ORDER BY p.name
            LIMIT ? OFFSET ?
        """, (per_page, offset))

    def get_part(self, part_id):
        return self._query("""
            SELECT p.*, pg.name AS group_name, pg.category_id,
                   pc.name AS category_name, pc.name_es AS category_name_es
            FROM parts p
            JOIN part_groups pg ON p.group_id = pg.id
            JOIN part_categories pc ON pg.category_id = pc.id
            WHERE p.id = ?
        """, (part_id,), one=True)

    def get_alternatives(self, part_id):
        return self._query("""
            SELECT ap.*, m.name AS manufacturer_name, m.country AS manufacturer_country
            FROM aftermarket_parts ap
            JOIN manufacturers m ON ap.manufacturer_id = m.id
            WHERE ap.oem_part_id = ?
            ORDER BY
                CASE ap.quality_tier
                    WHEN 'premium' THEN 1
                    WHEN 'standard' THEN 2
                    WHEN 'economy' THEN 3
                    ELSE 4
                END
        """, (part_id,))

    def get_cross_references(self, part_id):
        return self._query("""
            SELECT * FROM part_cross_references
            WHERE part_id = ? ORDER BY reference_type
        """, (part_id,))

    # === Search ===

    def search_parts(self, query, page=1, per_page=15):
        offset = (page - 1) * per_page
        return self._query("""
            SELECT p.id, p.oem_part_number, p.name, p.name_es,
                   pg.name AS group_name, pc.name AS category_name
            FROM parts_fts fts
            JOIN parts p ON fts.rowid = p.id
            JOIN part_groups pg ON p.group_id = pg.id
            JOIN part_categories pc ON pg.category_id = pc.id
            WHERE parts_fts MATCH ?
            ORDER BY rank
            LIMIT ? OFFSET ?
        """, (query, per_page, offset))

    def search_part_number(self, number):
        like = f'%{number}%'
        results = []
        # OEM
        oem = self._query("""
            SELECT p.id, p.oem_part_number AS part_number, p.name, p.name_es, 'OEM' AS type,
                   (SELECT b.name FROM brands b JOIN models m ON m.brand_id = b.id
                    JOIN model_year_engine mye ON mye.model_id = m.id
                    JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id
                    WHERE vp.part_id = p.id LIMIT 1) AS source
            FROM parts p WHERE p.oem_part_number LIKE ?
        """, (like,))
        results.extend(oem)
        # Aftermarket
        after = self._query("""
            SELECT ap.id, ap.part_number, ap.name, ap.name_es, 'AFTERMARKET' AS type,
                   m.name AS source
            FROM aftermarket_parts ap
            JOIN manufacturers m ON ap.manufacturer_id = m.id
            WHERE ap.part_number LIKE ?
        """, (like,))
        results.extend(after)
        # Cross-references
        xref = self._query("""
            SELECT pcr.id, pcr.cross_reference_number AS part_number,
                   p.name, p.name_es,
                   'CROSS-REF (' || pcr.reference_type || ')' AS type,
                   pcr.source
            FROM part_cross_references pcr
            JOIN parts p ON pcr.part_id = p.id
            WHERE pcr.cross_reference_number LIKE ?
        """, (like,))
        results.extend(xref)
        return results

    # === VIN ===

    def get_vin_cache(self, vin):
        return self._query("""
            SELECT * FROM vin_cache
            WHERE vin = ? AND expires_at > datetime('now')
        """, (vin,), one=True)

    def save_vin_cache(self, vin, data, make, model, year, engine_info, body_class, drive_type):
        self._execute("""
            INSERT OR REPLACE INTO vin_cache
            (vin, decoded_data, make, model, year, engine_info, body_class, drive_type, expires_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now', '+30 days'))
        """, (vin, data, make, model, year, engine_info, body_class, drive_type))

    # === Stats ===

    def get_stats(self):
        stats = {}
        tables = ['brands', 'models', 'years', 'engines', 'model_year_engine',
                   'part_categories', 'part_groups', 'parts', 'vehicle_parts',
                   'manufacturers', 'aftermarket_parts', 'part_cross_references',
                   'diagrams', 'diagram_hotspots', 'vin_cache']
        conn = self._connect()
        cursor = conn.cursor()
        for table in tables:
            cursor.execute(f"SELECT COUNT(*) FROM [{table}]")
            stats[table] = cursor.fetchone()[0]

        # Top brands with parts
        cursor.execute("""
            SELECT b.name, COUNT(vp.id) AS cnt
            FROM brands b
            JOIN models m ON m.brand_id = b.id
            JOIN model_year_engine mye ON mye.model_id = m.id
            JOIN vehicle_parts vp ON vp.model_year_engine_id = mye.id
            GROUP BY b.name ORDER BY cnt DESC LIMIT 5
        """)
        stats['top_brands'] = [dict(r) for r in cursor.fetchall()]
        conn.close()
        return stats

    # === Admin CRUD ===

    def create_part(self, data):
        return self._execute("""
            INSERT INTO parts (oem_part_number, name, name_es, group_id, description, description_es, weight_kg, material)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'],
              data.get('description'), data.get('description_es'), data.get('weight_kg'), data.get('material')))

    def update_part(self, part_id, data):
        self._execute("""
            UPDATE parts SET oem_part_number=?, name=?, name_es=?, group_id=?,
                   description=?, description_es=?, weight_kg=?, material=?, is_discontinued=?
            WHERE id=?
        """, (data['oem_part_number'], data['name'], data.get('name_es'), data['group_id'],
              data.get('description'), data.get('description_es'), data.get('weight_kg'),
              data.get('material'), data.get('is_discontinued', 0), part_id))

    def delete_part(self, part_id):
        self._execute("DELETE FROM parts WHERE id=?", (part_id,))

    def create_manufacturer(self, data):
        return self._execute("""
            INSERT INTO manufacturers (name, type, quality_tier, country, website)
            VALUES (?, ?, ?, ?, ?)
        """, (data['name'], data.get('type', 'aftermarket'), data.get('quality_tier', 'standard'),
              data.get('country'), data.get('website')))

    def update_manufacturer(self, mfr_id, data):
        self._execute("""
            UPDATE manufacturers SET name=?, type=?, quality_tier=?, country=?, website=?
            WHERE id=?
        """, (data['name'], data.get('type'), data.get('quality_tier'),
              data.get('country'), data.get('website'), mfr_id))

    def delete_manufacturer(self, mfr_id):
        self._execute("DELETE FROM manufacturers WHERE id=?", (mfr_id,))

    def get_manufacturers(self):
        return self._query("SELECT * FROM manufacturers ORDER BY name")

    def create_crossref(self, data):
        return self._execute("""
            INSERT INTO part_cross_references (part_id, cross_reference_number, reference_type, source, notes)
            VALUES (?, ?, ?, ?, ?)
        """, (data['part_id'], data['cross_reference_number'], data.get('reference_type', 'interchange'),
              data.get('source'), data.get('notes')))

    def update_crossref(self, xref_id, data):
        self._execute("""
            UPDATE part_cross_references
            SET part_id=?, cross_reference_number=?, reference_type=?, source=?, notes=?
            WHERE id=?
        """, (data['part_id'], data['cross_reference_number'], data.get('reference_type'),
              data.get('source'), data.get('notes'), xref_id))

    def delete_crossref(self, xref_id):
        self._execute("DELETE FROM part_cross_references WHERE id=?", (xref_id,))

    def get_crossrefs_paginated(self, page=1, per_page=15):
        offset = (page - 1) * per_page
        return self._query("""
            SELECT pcr.*, p.oem_part_number, p.name AS part_name
            FROM part_cross_references pcr
            JOIN parts p ON pcr.part_id = p.id
            ORDER BY pcr.id
            LIMIT ? OFFSET ?
        """, (per_page, offset))

    def get_vehicles_for_part(self, part_id):
        return self._query("""
            SELECT b.name AS brand, m.name AS model, y.year, e.name AS engine
            FROM vehicle_parts vp
            JOIN model_year_engine mye ON vp.model_year_engine_id = mye.id
            JOIN models m ON mye.model_id = m.id
            JOIN brands b ON m.brand_id = b.id
            JOIN years y ON mye.year_id = y.id
            JOIN engines e ON mye.engine_id = e.id
            WHERE vp.part_id = ?
            ORDER BY b.name, m.name, y.year
        """, (part_id,))

Step 4: Run tests

cd /home/Autopartes && python -m pytest console/tests/test_db.py -v

Expected: All PASS

Step 5: Commit

git add console/db.py console/tests/
git commit -m "feat(console): add database abstraction layer with tests"

Task 3: Core Framework — Key Bindings and Navigation

Files:

  • Create: console/core/keybindings.py
  • Create: console/core/navigation.py
  • Create: console/core/screens.py
  • Create: console/tests/test_core.py

Step 1: Write tests

# console/tests/test_core.py
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))

from console.core.keybindings import Key, KeyBindings
from console.core.navigation import Navigation
from console.core.screens import Screen


def test_key_constants():
    assert Key.ESCAPE == 27
    assert Key.ENTER == 10
    assert Key.F1 is not None


def test_keybindings_register():
    kb = KeyBindings()
    called = []
    kb.bind(Key.F1, lambda: called.append('help'))
    kb.handle(Key.F1)
    assert called == ['help']


def test_keybindings_unbound_key():
    kb = KeyBindings()
    result = kb.handle(999)
    assert result is False


def test_navigation_push_pop():
    nav = Navigation()
    nav.push('menu', {})
    nav.push('brands', {'filter': 'T'})
    assert nav.current() == ('brands', {'filter': 'T'})
    nav.pop()
    assert nav.current() == ('menu', {})


def test_navigation_breadcrumb():
    nav = Navigation()
    nav.push('menu', {}, label='Menu')
    nav.push('brands', {}, label='Marcas')
    nav.push('models', {}, label='TOYOTA')
    bc = nav.breadcrumb()
    assert bc == ['Menu', 'Marcas', 'TOYOTA']


def test_navigation_empty():
    nav = Navigation()
    assert nav.current() is None
    nav.pop()  # should not crash


def test_screen_base():
    s = Screen('test', 'Test Screen')
    assert s.name == 'test'
    assert s.title == 'Test Screen'

Step 2: Run test to verify failure

cd /home/Autopartes && python -m pytest console/tests/test_core.py -v

Step 3: Implement keybindings.py

# console/core/keybindings.py
import curses


class Key:
    """Key constants matching curses key codes."""
    ESCAPE = 27
    ENTER = 10
    TAB = 9
    BACKSPACE = 127
    UP = curses.KEY_UP
    DOWN = curses.KEY_DOWN
    LEFT = curses.KEY_LEFT
    RIGHT = curses.KEY_RIGHT
    PGUP = curses.KEY_PPAGE
    PGDN = curses.KEY_NPAGE
    HOME = curses.KEY_HOME
    END = curses.KEY_END
    F1 = curses.KEY_F1
    F2 = curses.KEY_F2
    F3 = curses.KEY_F3
    F4 = curses.KEY_F4
    F5 = curses.KEY_F5
    F6 = curses.KEY_F6
    F7 = curses.KEY_F7
    F8 = curses.KEY_F8
    F9 = curses.KEY_F9
    F10 = curses.KEY_F10


class KeyBindings:
    """Registry of key-to-action bindings."""

    def __init__(self):
        self._bindings = {}

    def bind(self, key, action):
        self._bindings[key] = action

    def handle(self, key):
        if key in self._bindings:
            self._bindings[key]()
            return True
        return False

    def get_footer_labels(self):
        """Return list of (key_label, description) for footer display."""
        return self._footer_labels if hasattr(self, '_footer_labels') else []

    def set_footer(self, labels):
        """Set footer labels: [('F1', 'Ayuda'), ('ESC', 'Atrás'), ...]"""
        self._footer_labels = labels

Step 4: Implement navigation.py

# console/core/navigation.py


class Navigation:
    """Screen navigation stack with breadcrumb support."""

    def __init__(self):
        self._stack = []  # list of (screen_name, context, label)

    def push(self, screen_name, context=None, label=None):
        self._stack.append((screen_name, context or {}, label or screen_name))

    def pop(self):
        if self._stack:
            return self._stack.pop()
        return None

    def current(self):
        if self._stack:
            entry = self._stack[-1]
            return (entry[0], entry[1])
        return None

    def breadcrumb(self):
        return [entry[2] for entry in self._stack]

    def clear(self):
        self._stack.clear()

    def depth(self):
        return len(self._stack)

Step 5: Implement screens.py

# console/core/screens.py


class Screen:
    """Base class for all application screens."""

    def __init__(self, name, title):
        self.name = name
        self.title = title

    def on_enter(self, context, db, renderer):
        """Called when screen becomes active. Override in subclasses."""
        pass

    def on_key(self, key, context, db, renderer, nav):
        """Handle key press. Return screen_name to navigate, None to stay, 'back' to pop."""
        return None

    def render(self, context, db, renderer):
        """Draw the screen. Override in subclasses."""
        pass

Step 6: Run tests

cd /home/Autopartes && python -m pytest console/tests/test_core.py -v

Expected: All PASS

Step 7: Commit

git add console/core/ console/tests/test_core.py
git commit -m "feat(console): add core framework - keybindings, navigation, screen base"

Task 4: Utilities — Formatting and VIN API

Files:

  • Create: console/utils/formatting.py
  • Create: console/utils/vin_api.py
  • Create: console/tests/test_utils.py

Step 1: Write tests

# console/tests/test_utils.py
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))

from console.utils.formatting import format_currency, format_number, truncate, pad_right, format_table_row


def test_format_currency():
    assert format_currency(45.99) == '$45.99'
    assert format_currency(None) == '──'
    assert format_currency(0) == '$0.00'


def test_format_number():
    assert format_number(13685) == '13,685'
    assert format_number(0) == '0'


def test_truncate():
    assert truncate('Hello World', 8) == 'Hello...'
    assert truncate('Short', 10) == 'Short'


def test_pad_right():
    assert pad_right('Hi', 5) == 'Hi   '
    assert pad_right('Hello World', 5) == 'Hello'


def test_format_table_row():
    row = format_table_row(['ID', 'Name', 'Price'], [4, 20, 8])
    assert len(row) > 0
    assert 'ID' in row

Step 2: Run test to verify failure

cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v

Step 3: Implement formatting.py

# console/utils/formatting.py


def format_currency(value):
    if value is None:
        return '──'
    return f'${value:,.2f}'


def format_number(value):
    if value is None:
        return '0'
    return f'{int(value):,}'


def truncate(text, max_len):
    if text is None:
        return ''
    text = str(text)
    if len(text) <= max_len:
        return text
    return text[:max_len - 3] + '...'


def pad_right(text, width):
    if text is None:
        text = ''
    text = str(text)
    if len(text) > width:
        return text[:width]
    return text.ljust(width)


def format_table_row(values, widths, separator=' │ '):
    """Format a row with fixed column widths."""
    parts = []
    for val, width in zip(values, widths):
        parts.append(pad_right(str(val) if val is not None else '', width))
    return separator.join(parts)


def quality_bar(tier):
    """Return visual quality bar for quality tier."""
    bars = {
        'premium': '██████████',
        'oem': '███████████',
        'standard': '███████░░░',
        'economy': '█████░░░░░',
    }
    return bars.get(tier, '░░░░░░░░░░')

Step 4: Implement vin_api.py

# console/utils/vin_api.py
import json
import requests
from console.config import NHTSA_API_URL


def decode_vin_nhtsa(vin):
    """Decode a VIN using NHTSA API. Returns dict with vehicle info."""
    try:
        response = requests.get(
            NHTSA_API_URL,
            params={'format': 'json'},
            timeout=10
        )
        # NHTSA uses path-based VIN
        response = requests.get(
            f"{NHTSA_API_URL}/{vin}",
            params={'format': 'json'},
            timeout=10
        )
        response.raise_for_status()
        data = response.json()

        results = data.get('Results', [])
        info = {}
        for item in results:
            var = item.get('Variable', '')
            val = item.get('Value')
            if val and val.strip():
                if var == 'Make':
                    info['make'] = val.strip()
                elif var == 'Model':
                    info['model'] = val.strip()
                elif var == 'Model Year':
                    info['year'] = int(val.strip())
                elif var == 'Body Class':
                    info['body_class'] = val.strip()
                elif var == 'Drive Type':
                    info['drive_type'] = val.strip()
                elif var == 'Displacement (L)':
                    info.setdefault('engine_info', {})['displacement_l'] = val.strip()
                elif var == 'Engine Number of Cylinders':
                    info.setdefault('engine_info', {})['cylinders'] = val.strip()
                elif var == 'Fuel Type - Primary':
                    info.setdefault('engine_info', {})['fuel_type'] = val.strip()
                elif var == 'Engine Brake (hp) From':
                    info.setdefault('engine_info', {})['power_hp'] = val.strip()

        engine = info.get('engine_info', {})
        if engine:
            parts = []
            if engine.get('displacement_l'):
                parts.append(f"{engine['displacement_l']}L")
            if engine.get('cylinders'):
                parts.append(f"{engine['cylinders']} cil.")
            if engine.get('fuel_type'):
                parts.append(engine['fuel_type'])
            engine['raw'] = ' '.join(parts)

        return info
    except Exception as e:
        return {'error': str(e)}

Step 5: Run tests

cd /home/Autopartes && python -m pytest console/tests/test_utils.py -v

Expected: All PASS

Step 6: Commit

git add console/utils/ console/tests/test_utils.py
git commit -m "feat(console): add formatting utils and VIN API client"

Task 5: Curses Renderer (VT220 Mode)

Files:

  • Create: console/renderers/curses_renderer.py
  • Create: console/renderers/base.py

Step 1: Write base renderer interface

# console/renderers/base.py


class BaseRenderer:
    """Abstract renderer interface. All renderers must implement these methods."""

    def init_screen(self):
        raise NotImplementedError

    def cleanup(self):
        raise NotImplementedError

    def get_size(self):
        """Return (height, width) of terminal."""
        raise NotImplementedError

    def clear(self):
        raise NotImplementedError

    def refresh(self):
        raise NotImplementedError

    def get_key(self):
        """Block and return key code."""
        raise NotImplementedError

    def draw_header(self, title, subtitle=''):
        raise NotImplementedError

    def draw_footer(self, key_labels):
        """key_labels: list of (key, description) tuples."""
        raise NotImplementedError

    def draw_menu(self, items, selected_index=0, title=''):
        """items: list of (number, label) tuples."""
        raise NotImplementedError

    def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1):
        raise NotImplementedError

    def draw_detail(self, fields, title=''):
        """fields: list of (label, value) tuples."""
        raise NotImplementedError

    def draw_form(self, fields, focused_index=0, title=''):
        """fields: list of {label, value, width, type} dicts."""
        raise NotImplementedError

    def draw_filter_list(self, items, filter_text, selected_index, title=''):
        raise NotImplementedError

    def draw_comparison(self, columns, title=''):
        """columns: list of {header, rows} dicts for side-by-side."""
        raise NotImplementedError

    def draw_text(self, row, col, text, style='normal'):
        raise NotImplementedError

    def draw_box(self, top, left, height, width, title=''):
        raise NotImplementedError

    def show_message(self, text, msg_type='info'):
        """msg_type: 'info', 'error', 'confirm'. Returns True/False for confirm."""
        raise NotImplementedError

    def show_input(self, prompt, max_len=40):
        """Show input prompt and return typed string."""
        raise NotImplementedError

Step 2: Implement curses_renderer.py

This is the largest single file. It implements the full VT220 aesthetic with green-on-black, box drawing characters, and Pick-style screen layout.

# console/renderers/curses_renderer.py
import curses
from console.renderers.base import BaseRenderer
from console.config import COLORS_VT220
from console.utils.formatting import pad_right, truncate


class CursesRenderer(BaseRenderer):

    def __init__(self):
        self.stdscr = None
        self.colors = {}

    def init_screen(self):
        self.stdscr = curses.initscr()
        curses.noecho()
        curses.cbreak()
        curses.curs_set(0)
        self.stdscr.keypad(True)

        if curses.has_colors():
            curses.start_color()
            curses.use_default_colors()
            self._init_colors()

    def _init_colors(self):
        color_map = {'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
                     'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7}
        for i, (name, (fg, bg)) in enumerate(COLORS_VT220.items(), 1):
            if i < curses.COLOR_PAIRS:
                curses.init_pair(i, color_map.get(fg, 7), color_map.get(bg, 0))
                self.colors[name] = curses.color_pair(i)
        # Ensure defaults
        self.colors.setdefault('normal', curses.color_pair(0))

    def _style(self, name):
        return self.colors.get(name, self.colors.get('normal', 0))

    def cleanup(self):
        if self.stdscr:
            curses.nocbreak()
            self.stdscr.keypad(False)
            curses.echo()
            curses.endwin()

    def get_size(self):
        h, w = self.stdscr.getmaxyx()
        return h, w

    def clear(self):
        self.stdscr.clear()

    def refresh(self):
        self.stdscr.refresh()

    def get_key(self):
        return self.stdscr.getch()

    def draw_header(self, title, subtitle=''):
        h, w = self.get_size()
        header_text = f'  {title}'
        if subtitle:
            space = w - len(title) - len(subtitle) - 4
            header_text = f'  {title}{" " * max(space, 2)}{subtitle}  '
        header_text = header_text[:w].ljust(w)
        try:
            self.stdscr.addstr(0, 0, header_text, self._style('header') | curses.A_BOLD)
        except curses.error:
            pass
        # Separator line
        try:
            self.stdscr.addstr(1, 0, '─' * w, self._style('border'))
        except curses.error:
            pass

    def draw_footer(self, key_labels):
        h, w = self.get_size()
        # Separator
        try:
            self.stdscr.addstr(h - 2, 0, '─' * w, self._style('border'))
        except curses.error:
            pass
        # Key labels
        footer = '  ' + '  '.join(f'{k}={v}' for k, v in key_labels) + '  '
        footer = footer[:w].ljust(w)
        try:
            self.stdscr.addstr(h - 1, 0, footer[:w-1], self._style('footer'))
        except curses.error:
            pass

    def draw_menu(self, items, selected_index=0, title=''):
        h, w = self.get_size()
        start_row = 3
        if title:
            try:
                self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
            except curses.error:
                pass
            start_row += 2

        for i, (num, label) in enumerate(items):
            row = start_row + i
            if row >= h - 3:
                break
            style = self._style('highlight') if i == selected_index else self._style('normal')
            prefix = '>' if i == selected_index else ' '
            text = f'  {prefix} {num}. {label}'
            try:
                self.stdscr.addstr(row, 0, pad_right(text, w), style)
            except curses.error:
                pass

    def draw_table(self, headers, rows, widths, page_info=None, selected_row=-1):
        h, w = self.get_size()
        start_row = 3

        # Header row
        header_line = '  #  │ '
        for hdr, width in zip(headers, widths):
            header_line += pad_right(hdr, width) + ' │ '
        try:
            self.stdscr.addstr(start_row, 0, header_line[:w], self._style('title') | curses.A_BOLD)
            self.stdscr.addstr(start_row + 1, 0, '─' * w, self._style('border'))
        except curses.error:
            pass

        # Data rows
        for i, row in enumerate(rows):
            y = start_row + 2 + i
            if y >= h - 4:
                break
            style = self._style('highlight') if i == selected_row else self._style('normal')
            line = f'  {i + 1:<3}│ '
            for val, width in zip(row, widths):
                line += pad_right(truncate(str(val) if val is not None else '', width), width) + ' │ '
            try:
                self.stdscr.addstr(y, 0, line[:w], style)
            except curses.error:
                pass

        # Page info
        if page_info:
            info_row = h - 3
            info_text = f'  Página {page_info.get("page", 1)}/{page_info.get("total_pages", 1)}' \
                        f'    {page_info.get("showing", "")} de {page_info.get("total", "")} registros'
            try:
                self.stdscr.addstr(info_row, 0, info_text, self._style('info'))
            except curses.error:
                pass

    def draw_detail(self, fields, title=''):
        h, w = self.get_size()
        start_row = 3
        if title:
            try:
                self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
            except curses.error:
                pass
            start_row += 2

        max_label = max(len(f[0]) for f in fields) if fields else 10
        for i, (label, value) in enumerate(fields):
            row = start_row + i
            if row >= h - 3:
                break
            dots = '.' * (max_label - len(label) + 2)
            line = f'  {label}{dots}: {value}'
            try:
                self.stdscr.addstr(row, 2, label, self._style('field_label'))
                self.stdscr.addstr(row, 2 + len(label), dots + ': ', self._style('border'))
                self.stdscr.addstr(row, 2 + max_label + 4, str(value)[:w - max_label - 8],
                                   self._style('field_value'))
            except curses.error:
                pass

    def draw_form(self, fields, focused_index=0, title=''):
        h, w = self.get_size()
        start_row = 3
        if title:
            try:
                self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
            except curses.error:
                pass
            start_row += 2

        max_label = max(len(f['label']) for f in fields) if fields else 10
        for i, field in enumerate(fields):
            row = start_row + i
            if row >= h - 3:
                break
            label = field['label']
            value = field.get('value', '')
            field_width = field.get('width', 30)
            dots = '.' * (max_label - len(label) + 2)
            is_focused = (i == focused_index)

            try:
                self.stdscr.addstr(row, 2, f'{i + 1}.', self._style('info'))
                self.stdscr.addstr(row, 5, label, self._style('field_label'))
                self.stdscr.addstr(row, 5 + len(label), dots + ': ', self._style('border'))

                val_col = 5 + max_label + 4
                display_val = f'[{pad_right(str(value), field_width)}]'
                style = self._style('field_active') if is_focused else self._style('field_value')
                self.stdscr.addstr(row, val_col, display_val[:w - val_col - 2], style)

                hint = field.get('hint', '')
                if hint:
                    hint_col = val_col + field_width + 3
                    if hint_col < w - 2:
                        self.stdscr.addstr(row, hint_col, hint[:w - hint_col - 1], self._style('info'))
            except curses.error:
                pass

    def draw_filter_list(self, items, filter_text, selected_index, title=''):
        h, w = self.get_size()
        start_row = 3
        if title:
            try:
                self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
            except curses.error:
                pass
            start_row += 1
            self.stdscr.addstr(start_row, 0, '─' * w, self._style('border'))
            start_row += 1

        # Filter input
        try:
            self.stdscr.addstr(start_row, 2, 'Filtro: ', self._style('field_label'))
            self.stdscr.addstr(start_row, 10, filter_text + '_', self._style('field_active'))
        except curses.error:
            pass
        start_row += 1
        try:
            self.stdscr.addstr(start_row, 0, '─' * w, self._style('border'))
        except curses.error:
            pass
        start_row += 1

        # Items
        visible_items = h - start_row - 4
        scroll_offset = max(0, selected_index - visible_items + 1)
        for i, item in enumerate(items[scroll_offset:scroll_offset + visible_items]):
            row = start_row + i
            actual_idx = scroll_offset + i
            is_selected = actual_idx == selected_index
            prefix = '> ' if is_selected else '  '
            num = f'{actual_idx + 1}.'
            style = self._style('highlight') if is_selected else self._style('normal')
            text = f'  {prefix}{num:>4} {item}'
            try:
                self.stdscr.addstr(row, 0, pad_right(text, w), style)
            except curses.error:
                pass

        # Count
        count_text = f'  {len(items)} coincidencias'
        try:
            self.stdscr.addstr(h - 3, w - len(count_text) - 2, count_text, self._style('info'))
        except curses.error:
            pass

    def draw_comparison(self, columns, title=''):
        h, w = self.get_size()
        start_row = 3
        if title:
            try:
                self.stdscr.addstr(start_row, 2, title, self._style('title') | curses.A_BOLD)
            except curses.error:
                pass
            start_row += 2

        n_cols = len(columns)
        if n_cols == 0:
            return
        label_w = 16
        col_w = max(12, (w - label_w - 4) // n_cols - 3)

        # Column headers
        header_line = pad_right('', label_w) + ' │ '
        for col in columns:
            header_line += pad_right(truncate(col['header'], col_w), col_w) + ' │ '
        try:
            self.stdscr.addstr(start_row, 2, header_line[:w - 4], self._style('title') | curses.A_BOLD)
            self.stdscr.addstr(start_row + 1, 2, '─' * (w - 4), self._style('border'))
        except curses.error:
            pass
        start_row += 2

        # Find max rows
        max_rows = max(len(col.get('rows', [])) for col in columns) if columns else 0
        for r in range(max_rows):
            row = start_row + r
            if row >= h - 4:
                break
            # Row label from first column
            row_label = columns[0]['rows'][r][0] if r < len(columns[0].get('rows', [])) else ''
            line = pad_right(row_label, label_w) + ' │ '
            for col in columns:
                val = col['rows'][r][1] if r < len(col.get('rows', [])) else ''
                line += pad_right(truncate(str(val), col_w), col_w) + ' │ '
            try:
                self.stdscr.addstr(row, 2, line[:w - 4], self._style('normal'))
            except curses.error:
                pass

    def draw_text(self, row, col, text, style='normal'):
        h, w = self.get_size()
        if row < h and col < w:
            try:
                self.stdscr.addstr(row, col, text[:w - col], self._style(style))
            except curses.error:
                pass

    def draw_box(self, top, left, height, width, title=''):
        try:
            # Top border
            self.stdscr.addstr(top, left, '┌' + '─' * (width - 2) + '┐', self._style('border'))
            if title:
                self.stdscr.addstr(top, left + 2, f' {title} ', self._style('title') | curses.A_BOLD)
            # Sides
            for i in range(1, height - 1):
                self.stdscr.addstr(top + i, left, '│', self._style('border'))
                self.stdscr.addstr(top + i, left + width - 1, '│', self._style('border'))
            # Bottom border
            self.stdscr.addstr(top + height - 1, left, '└' + '─' * (width - 2) + '┘', self._style('border'))
        except curses.error:
            pass

    def show_message(self, text, msg_type='info'):
        h, w = self.get_size()
        box_w = min(len(text) + 6, w - 4)
        box_h = 5
        top = h // 2 - 2
        left = (w - box_w) // 2

        style = self._style('error') if msg_type == 'error' else self._style('info')
        self.draw_box(top, left, box_h, box_w)

        try:
            self.stdscr.addstr(top + 2, left + 3, truncate(text, box_w - 6), style)
            if msg_type == 'confirm':
                self.stdscr.addstr(top + 3, left + 3, '(S/N)', self._style('field_label'))
            else:
                self.stdscr.addstr(top + 3, left + 3, 'Presione cualquier tecla...', self._style('field_label'))
        except curses.error:
            pass
        self.refresh()

        if msg_type == 'confirm':
            while True:
                key = self.get_key()
                if key in (ord('s'), ord('S'), ord('y'), ord('Y')):
                    return True
                if key in (ord('n'), ord('N'), 27):
                    return False
        else:
            self.get_key()
            return True

    def show_input(self, prompt, max_len=40):
        h, w = self.get_size()
        box_w = max(len(prompt) + max_len + 8, 40)
        box_h = 5
        top = h // 2 - 2
        left = (w - box_w) // 2

        self.draw_box(top, left, box_h, box_w, prompt)

        value = ''
        curses.curs_set(1)
        while True:
            try:
                display = f'[{pad_right(value, max_len)}]'
                self.stdscr.addstr(top + 2, left + 3, display, self._style('field_active'))
                self.stdscr.move(top + 2, left + 4 + len(value))
            except curses.error:
                pass
            self.refresh()

            key = self.get_key()
            if key == 10:  # ENTER
                break
            elif key == 27:  # ESC
                curses.curs_set(0)
                return None
            elif key in (127, curses.KEY_BACKSPACE, 8):
                value = value[:-1]
            elif 32 <= key <= 126 and len(value) < max_len:
                value += chr(key)

        curses.curs_set(0)
        return value

Step 3: Commit

git add console/renderers/
git commit -m "feat(console): add curses VT220 renderer with full widget set"

Task 6: App Controller and Main Menu Screen

Files:

  • Create: console/core/app.py
  • Create: console/screens/menu_principal.py
  • Create: console/screens/estadisticas.py

Step 1: Implement app.py

# console/core/app.py
from console.core.navigation import Navigation
from console.core.keybindings import Key


class App:
    """Main application controller. Manages screen lifecycle and navigation."""

    def __init__(self, renderer, db):
        self.renderer = renderer
        self.db = db
        self.nav = Navigation()
        self.screens = {}
        self.running = False
        self._register_screens()

    def _register_screens(self):
        from console.screens.menu_principal import MenuPrincipal
        from console.screens.estadisticas import EstadisticasScreen
        from console.screens.vehiculo_nav import VehiculoNavScreen
        from console.screens.buscar_parte import BuscarParteScreen
        from console.screens.buscar_texto import BuscarTextoScreen
        from console.screens.vin_decoder import VinDecoderScreen
        from console.screens.catalogo import CatalogoScreen
        from console.screens.parte_detalle import ParteDetalleScreen
        from console.screens.comparador import ComparadorScreen
        from console.screens.admin_partes import AdminPartesScreen
        from console.screens.admin_fabricantes import AdminFabricantesScreen
        from console.screens.admin_crossref import AdminCrossrefScreen
        from console.screens.admin_import import AdminImportScreen

        self.screens = {
            'menu': MenuPrincipal(),
            'estadisticas': EstadisticasScreen(),
            'vehiculo_nav': VehiculoNavScreen(),
            'buscar_parte': BuscarParteScreen(),
            'buscar_texto': BuscarTextoScreen(),
            'vin_decoder': VinDecoderScreen(),
            'catalogo': CatalogoScreen(),
            'parte_detalle': ParteDetalleScreen(),
            'comparador': ComparadorScreen(),
            'admin_partes': AdminPartesScreen(),
            'admin_fabricantes': AdminFabricantesScreen(),
            'admin_crossref': AdminCrossrefScreen(),
            'admin_import': AdminImportScreen(),
        }

    def run(self):
        self.renderer.init_screen()
        self.running = True
        self.nav.push('menu', {}, label='Menú')

        try:
            while self.running:
                current = self.nav.current()
                if current is None:
                    break

                screen_name, context = current
                screen = self.screens.get(screen_name)
                if screen is None:
                    self.renderer.show_message(f'Pantalla "{screen_name}" no encontrada', 'error')
                    self.nav.pop()
                    continue

                # Render
                self.renderer.clear()
                screen.render(context, self.db, self.renderer)
                self.renderer.refresh()

                # Get input
                key = self.renderer.get_key()

                # Global keys
                if key == Key.F10:
                    self.nav.clear()
                    self.nav.push('menu', {}, label='Menú')
                    continue

                # Screen-specific handling
                result = screen.on_key(key, context, self.db, self.renderer, self.nav)

                if result == 'quit':
                    self.running = False
                elif result == 'back':
                    self.nav.pop()
                elif result and isinstance(result, tuple):
                    # (screen_name, context, label)
                    name, ctx, label = result
                    self.nav.push(name, ctx, label=label)
                elif result and isinstance(result, str):
                    self.nav.push(result, {}, label=result)

        except KeyboardInterrupt:
            pass
        finally:
            self.renderer.cleanup()

Step 2: Implement menu_principal.py

# console/screens/menu_principal.py
from console.core.screens import Screen
from console.core.keybindings import Key
from console.config import APP_NAME, APP_SUBTITLE, VERSION


class MenuPrincipal(Screen):

    def __init__(self):
        super().__init__('menu', 'Menú Principal')
        self.selected = 0
        self.items = [
            ('1', 'Consulta por Vehículo'),
            ('2', 'Búsqueda por Número de Parte'),
            ('3', 'Búsqueda por Descripción'),
            ('4', 'Decodificador VIN'),
            ('5', 'Catálogo de Categorías'),
            ('─', '─────────────────────────'),
            ('6', 'Administración de Partes'),
            ('7', 'Administración de Fabricantes'),
            ('8', 'Cross-References'),
            ('9', 'Importar / Exportar Datos'),
            ('─', '─────────────────────────'),
            ('0', 'Estadísticas del Sistema'),
        ]
        self.selectable = [i for i, (num, _) in enumerate(self.items) if num != '─']
        self.actions = {
            '1': ('vehiculo_nav', {}, 'Vehículo'),
            '2': ('buscar_parte', {}, 'Buscar Parte'),
            '3': ('buscar_texto', {}, 'Buscar Texto'),
            '4': ('vin_decoder', {}, 'VIN Decoder'),
            '5': ('catalogo', {}, 'Catálogo'),
            '6': ('admin_partes', {}, 'Admin Partes'),
            '7': ('admin_fabricantes', {}, 'Admin Fabricantes'),
            '8': ('admin_crossref', {}, 'Admin Cross-Ref'),
            '9': ('admin_import', {}, 'Importar/Exportar'),
            '0': ('estadisticas', {}, 'Estadísticas'),
        }

    def render(self, context, db, renderer):
        renderer.draw_header(f'{APP_NAME} v{VERSION}', APP_SUBTITLE)
        renderer.draw_menu(self.items, self.selected)
        renderer.draw_footer([
            ('F1', 'Ayuda'), ('F3', 'Buscar'), ('F10', 'Menú'), ('ESC', 'Salir')
        ])

    def on_key(self, key, context, db, renderer, nav):
        if key == Key.ESCAPE:
            if renderer.show_message('¿Desea salir del sistema?', 'confirm'):
                return 'quit'
            return None

        # Number keys
        if 48 <= key <= 57:  # 0-9
            num = chr(key)
            if num in self.actions:
                return self.actions[num]

        # Arrow navigation
        if key == Key.DOWN:
            idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0
            idx = min(idx + 1, len(self.selectable) - 1)
            self.selected = self.selectable[idx]
        elif key == Key.UP:
            idx = self.selectable.index(self.selected) if self.selected in self.selectable else 0
            idx = max(idx - 1, 0)
            self.selected = self.selectable[idx]
        elif key == Key.ENTER:
            num = self.items[self.selected][0]
            if num in self.actions:
                return self.actions[num]

        return None

Step 3: Implement estadisticas.py

# console/screens/estadisticas.py
from console.core.screens import Screen
from console.core.keybindings import Key
from console.utils.formatting import format_number


class EstadisticasScreen(Screen):

    def __init__(self):
        super().__init__('estadisticas', 'Estadísticas del Sistema')

    def render(self, context, db, renderer):
        import datetime
        now = datetime.datetime.now().strftime('%d/%b/%Y %H:%M')
        renderer.draw_header('ESTADÍSTICAS DEL SISTEMA', now)

        stats = db.get_stats()

        fields = [
            ('', '── BASE DE DATOS ─────────────────────────────────'),
            ('Marcas', format_number(stats.get('brands', 0))),
            ('Modelos', format_number(stats.get('models', 0))),
            ('Motores', format_number(stats.get('engines', 0))),
            ('Configuraciones', format_number(stats.get('model_year_engine', 0))),
            ('Fabricantes', format_number(stats.get('manufacturers', 0))),
            ('Categorías', format_number(stats.get('part_categories', 0))),
            ('Grupos', format_number(stats.get('part_groups', 0))),
            ('Partes OEM', format_number(stats.get('parts', 0))),
            ('Aftermarket', format_number(stats.get('aftermarket_parts', 0))),
            ('Cross-Refs', format_number(stats.get('part_cross_references', 0))),
            ('Diagramas', format_number(stats.get('diagrams', 0))),
            ('', '── COBERTURA ─────────────────────────────────────'),
            ('Fitments', format_number(stats.get('vehicle_parts', 0))),
            ('VINs en caché', format_number(stats.get('vin_cache', 0))),
        ]

        # Add top brands
        top_brands = stats.get('top_brands', [])
        if top_brands:
            brands_str = '  '.join(f"{b['name']}({format_number(b['cnt'])})" for b in top_brands)
            fields.append(('Top marcas', brands_str))

        renderer.draw_detail(fields, 'ESTADÍSTICAS DEL SISTEMA')
        renderer.draw_footer([('F5', 'Actualizar'), ('ESC', 'Menú')])

    def on_key(self, key, context, db, renderer, nav):
        if key == Key.ESCAPE:
            return 'back'
        if key == Key.F5:
            return None  # Re-render
        return None

Step 4: Commit

git add console/core/app.py console/screens/menu_principal.py console/screens/estadisticas.py
git commit -m "feat(console): add app controller, main menu and statistics screen"

Task 7: Vehicle Navigation Screen (Drill-Down)

Files:

  • Create: console/screens/vehiculo_nav.py

Step 1: Implement drill-down navigation

The vehicle navigation screen handles the full drill-down flow: Brand → Model → Year → Engine → Categories → Groups → Parts, each as a filterable list. It uses the renderer's draw_filter_list method and maintains state for each drill-down level within its context dict.

Key behaviors:

  • Typing characters filters the list in real time
  • Arrow keys move selection cursor
  • ENTER advances to next drill-down level
  • ESC goes back one level (or exits to menu if at brand level)
  • After selecting engine, transitions to catalogo screen with the vehicle's mye_id

Step 2: Commit

git add console/screens/vehiculo_nav.py
git commit -m "feat(console): add vehicle drill-down navigation screen"

Task 8: Catalog, Search, and VIN Screens

Files:

  • Create: console/screens/catalogo.py
  • Create: console/screens/buscar_parte.py
  • Create: console/screens/buscar_texto.py
  • Create: console/screens/vin_decoder.py

Step 1: Implement catalogo.py

Categories → Groups → Parts drill-down, similar structure to vehiculo_nav but for the parts hierarchy. Uses draw_filter_list for categories/groups and draw_table for parts list.

Step 2: Implement buscar_parte.py

Single input field (via show_input), calls db.search_part_number(), displays results in draw_table. Selecting a result navigates to parte_detalle.

Step 3: Implement buscar_texto.py

Same pattern as buscar_parte but uses db.search_parts() (FTS5) with a text query. Paginated results via PgUp/PgDn.

Step 4: Implement vin_decoder.py

Input 17-char VIN, check db.get_vin_cache() first, else call decode_vin_nhtsa() and cache result. Display decoded info via draw_detail. Option to navigate to vehicle parts.

Step 5: Commit

git add console/screens/catalogo.py console/screens/buscar_parte.py console/screens/buscar_texto.py console/screens/vin_decoder.py
git commit -m "feat(console): add catalog, search, and VIN decoder screens"

Task 9: Part Detail and Comparator Screens

Files:

  • Create: console/screens/parte_detalle.py
  • Create: console/screens/comparador.py

Step 1: Implement parte_detalle.py

Top section: draw_detail with part fields (OEM number, name, group, category, description, material, weight). Bottom section: draw_table with aftermarket alternatives. F4 navigates to cross-references view. F6 shows vehicles that use this part. Number key selects an alternative to compare.

Step 2: Implement comparador.py

Uses draw_comparison to show OEM part and selected aftermarket alternatives side by side. Shows quality bars, prices, savings percentages, warranty info.

Step 3: Commit

git add console/screens/parte_detalle.py console/screens/comparador.py
git commit -m "feat(console): add part detail and comparator screens"

Task 10: Admin CRUD Screens

Files:

  • Create: console/screens/admin_partes.py
  • Create: console/screens/admin_fabricantes.py
  • Create: console/screens/admin_crossref.py
  • Create: console/screens/admin_import.py

Step 1: Implement admin_partes.py

List view with draw_table (paginated). F2 or ENTER to edit selected part via draw_form. F3 to add new part. F9 saves, ESC cancels. Delete with confirmation dialog.

Step 2: Implement admin_fabricantes.py

Same CRUD pattern as admin_partes but for manufacturers table.

Step 3: Implement admin_crossref.py

Same CRUD pattern for cross-references. F1 on part_id field opens lookup list of parts.

Step 4: Implement admin_import.py

Menu to select data type (categories, groups, parts, manufacturers, aftermarket, crossref, fitment). File path input via show_input. Reads CSV and calls appropriate db methods. Shows progress/results.

Step 5: Commit

git add console/screens/admin_*.py
git commit -m "feat(console): add admin CRUD screens for parts, manufacturers, crossref, import"

Task 11: Textual/Rich Modern Renderer

Files:

  • Create: console/renderers/textual_renderer.py

Step 1: Install textual dependency

pip install textual rich

Step 2: Implement textual_renderer.py

Implements the same BaseRenderer interface but using textual/rich for a modern TUI experience. Uses Rich tables, panels, and styled text instead of curses box drawing. The same screen logic drives both renderers.

Note: textual uses an async event loop, so this renderer wraps textual's App in a synchronous interface matching BaseRenderer. An alternative is a thin adapter that translates draw_* calls into Rich console output for simpler integration.

Step 3: Commit

git add console/renderers/textual_renderer.py
git commit -m "feat(console): add textual/rich modern renderer"

Task 12: Integration Testing and Polish

Files:

  • Create: console/tests/test_integration.py
  • Modify: console/main.py

Step 1: Write integration tests

Test that the app can be instantiated, screens registered, and basic navigation works (using a mock renderer that records draw calls instead of painting to screen).

Step 2: Add error handling to main.py

Wrap app.run() in try/except to always clean up the terminal on crash. Add --db flag to override database path.

Step 3: Test full app manually

cd /home/Autopartes && python -m console.main --mode vt220
cd /home/Autopartes && python -m console.main --mode modern

Step 4: Commit

git add console/
git commit -m "feat(console): add integration tests and polish error handling"

Task 13: Documentation and Final Commit

Files:

  • Create: console/README.md
  • Modify: README.md (root)

Step 1: Write console README

Usage instructions, keybindings reference, screenshots placeholder, mode descriptions.

Step 2: Update root README

Add section about the console system with quickstart command.

Step 3: Final commit

git add console/README.md README.md
git commit -m "docs: add console system documentation"

Summary

Task Description Est. Steps
1 Project scaffold + config 4
2 Database abstraction layer 5
3 Core framework (keys, nav, screens) 7
4 Utilities (formatting, VIN API) 6
5 Curses VT220 renderer 3
6 App controller + main menu + stats 4
7 Vehicle drill-down navigation 2
8 Catalog, search, VIN screens 5
9 Part detail + comparator 3
10 Admin CRUD screens 5
11 Textual modern renderer 3
12 Integration tests + polish 4
13 Documentation 3
Total 54 steps

Dependencies: Tasks 1-5 must be completed first (foundation). Tasks 6-10 can be done in any order after foundation. Task 11 is independent of 6-10. Tasks 12-13 are final.