                        compiled_test2,
                        kernel_diffs,
                    )
                )

        for testname, compiled_test2 in self.run2.kernel_map.items():
            if testname not in self.run1.kernel_map:
                compiled_test2.kernels = [
                    self.run2.get_kernel(testname, i)
                    for i in range(len(compiled_test2.kernels))
                ]
                self.new_tests.append(compiled_test2)

    def hide_env(self):
        """Remove private information like env vars and lib versions"""
        self.run1.env = None
        self.run2.env = None
        self.run1.nvcc_version = None
        self.run2.nvcc_version = None

    def generate_html(self, omit_preamble: bool, max_diffs: bool) -> str:
        """Return a self-contained HTML string summarizing the codegen comparison"""
        import jinja2

        tools_dir = os.path.dirname(__file__)
        template_dir = os.path.join(tools_dir, "templates")
        env = jinja2.Environment(
            loader=jinja2.FileSystemLoader(searchpath=template_dir)
        )
        template = env.get_template("codediff.html")
        # dict_factory lets us provide custom serializations for classes like Enums
        # https://stackoverflow.com/questions/61338539/how-to-use-enum-value-in-asdict-function-from-dataclasses-module
        context = asdict(
            self,
            dict_factory=lambda data: {
                # Serialize CommandType as string so that jinja can recognize it
                field: value.name if isinstance(value, CommandType) else value
                for field, value in data
            },
        )
        context["omit_preamble"] = omit_preamble
        context["max_diffs"] = max_diffs
        head_hash = (
            subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True)
            .stdout.strip()
            .decode("utf-8")
        )
        context["tool_git"] = GitRev(head_hash)
        context["explain_api_url"] = os.environ.get(
            "CODEDIFF_EXPLAIN_API_URL", "/api/explain-diff"
        )

        return template.render(context)

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    subparsers = parser.add_subparsers(title="verb")

    parse_parser = subparsers.add_parser(
        "parse", help="Parse an output directory of run_command.sh into a JSON file"
    )
    parse_parser.add_argument(
        "dir",
        help="Directory containing 'stdout' and 'cuda/' resulting from run_command.sh",
    )
    parse_parser.add_argument("output_json", help="Location to write JSON file")

    def parse_dir(args: dict):


        return template.render(context)

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    subparsers = parser.add_subparsers(title="verb")

    parse_parser = subparsers.add_parser(
        "parse", help="Parse an output directory of run_command.sh into a JSON file"
    )
    parse_parser.add_argument(
        "dir",
        help="Directory containing 'stdout' and 'cuda/' resulting from run_command.sh",
    )
    parse_parser.add_argument("output_json", help="Location to write JSON file")

    def parse_dir(args: dict):
        tr = TestRun(args.dir)

        # load the code for each kernel
        for test_name, compiled_kernel in tr.kernel_map.items():
            for kernel_number in range(len(compiled_kernel.kernels)):
                tr.get_kernel(test_name, kernel_number, strip_preamble=True)

        with open(args.output_json, "w") as f:
            f.write(tr.to_json())

    parse_parser.set_defaults(func=parse_dir)

    join_parser = subparsers.add_parser(
        "join",
        help='Concatenate multiple command JSONs as if they were from a single command. This is useful for "unsharding" jobs that are computed using run_command.sh on multiple shards',
    )
    join_parser.add_argument(
        "-o", "--output", help="Location to write concatenated JSON file"
    )
    join_parser.add_argument(
        "input_jsons", nargs="+", help="Location of incoming JSON files"
    )

    def join_jsons(args: dict):
        assert len(args.input_jsons) > 0

        with open(args.input_jsons[0], "r") as f:
            td = TestRun.from_json(f.read())


        for test_name, compiled_kernel in tr.kernel_map.items():
            for kernel_number in range(len(compiled_kernel.kernels)):
                tr.get_kernel(test_name, kernel_number, strip_preamble=True)

        with open(args.output_json, "w") as f:
            f.write(tr.to_json())

    parse_parser.set_defaults(func=parse_dir)

    join_parser = subparsers.add_parser(
        "join",
        help='Concatenate multiple command JSONs as if they were from a single command. This is useful for "unsharding" jobs that are computed using run_command.sh on multiple shards',
    )
    join_parser.add_argument(
        "-o", "--output", help="Location to write concatenated JSON file"
    )
    join_parser.add_argument(
        "input_jsons", nargs="+", help="Location of incoming JSON files"
    )

    def join_jsons(args: dict):
        assert len(args.input_jsons) > 0

        with open(args.input_jsons[0], "r") as f:
            td = TestRun.from_json(f.read())

        for filename in args.input_jsons[1:]:
            with open(filename, "r") as f:
                td_other = TestRun.from_json(f.read())
            td.join(td_other)

        with open(args.output, "w") as f:
            f.write(td.to_json())

    join_parser.set_defaults(func=join_jsons)

    diff_parser = subparsers.add_parser(
        "diff", help="Compute the difference between two parsed command outputs"
    )
    diff_parser.add_argument(
        "--kernel-inclusion-criterion",
        "-i",
        choices=("mismatched_cuda_or_ptx", "mismatched_ptx", "all"),
        default="mismatched_cuda_or_ptx",
        help="Which kernels should we include?",
    )
    diff_parser.add_argument(
        "--hide-diffs",
        "--no-print-diff",
        action="store_true",
        help="Print diffs to STDOUT?",
    )
    diff_parser.add_argument("input_json1", help="Location of first JSON file")


    diff_parser = subparsers.add_parser(
        "diff", help="Compute the difference between two parsed command outputs"
    )
    diff_parser.add_argument(
        "--kernel-inclusion-criterion",
        "-i",
        choices=("mismatched_cuda_or_ptx", "mismatched_ptx", "all"),
        default="mismatched_cuda_or_ptx",
        help="Which kernels should we include?",
    )
    diff_parser.add_argument(
        "--hide-diffs",
        "--no-print-diff",
        action="store_true",
        help="Print diffs to STDOUT?",
    )
    diff_parser.add_argument("input_json1", help="Location of first JSON file")
    diff_parser.add_argument("input_json2", help="Location of second JSON file")
    diff_parser.add_argument("output_json", help="Location to write output JSON file")

    def diff_jsons(args: dict):
        with open(args.input_json1, "r") as f:
            tr1 = TestRun.from_json(f.read())
        with open(args.input_json2, "r") as f:
            tr2 = TestRun.from_json(f.read())
        td = TestDifferences(
            tr1,
            tr2,
            show_diffs=not args.hide_diffs,
            inclusion_criterion=args.kernel_inclusion_criterion,
        )

        if len(td.test_diffs) == 0:
            print("No differences found in overlapping tests!")
        else:
            print(
                td.total_num_diffs,
                "kernel differences from",
                len(td.test_diffs),
                "tests found",
            )
        if len(td.new_tests) > 0:
            print(len(td.new_tests), "new tests found")
        if len(td.removed_tests) > 0:
            print(len(td.removed_tests), "removed tests found")

        with open(args.output_json, "w") as f:
            f.write(td.to_json())

        # Return 1 if preamble or any kernels are changed, else 0
        exit(1 if len(td.test_diffs) > 0 or len(td.preamble_diff) > 0 else 0)

    diff_parser.set_defaults(func=diff_jsons)

    report_parser = subparsers.add_parser(
        "diff_report", help="Compute an HTML report from a diff JSON"
    )
    report_parser.add_argument(
        "--hide-env",
        action="store_true",
        help="Hide environment variables and nvcc versions in output?",
    )
    report_parser.add_argument(
        "--max-diffs",
        default=200,
        type=int,
        help="Limit number of included kernel diffs in HTML output to this many (does not affect exit code).",
    )
    report_parser.add_argument(
        "--omit-preamble",
        action="store_true",


    )
    report_parser.add_argument(
        "--hide-env",
        action="store_true",
        help="Hide environment variables and nvcc versions in output?",
    )
    report_parser.add_argument(
        "--max-diffs",
        default=200,
        type=int,
        help="Limit number of included kernel diffs in HTML output to this many (does not affect exit code).",
    )
    report_parser.add_argument(
        "--omit-preamble",
        action="store_true",
        help="Omit the preamble in HTML output?",
    )
    report_parser.add_argument("input_json", help="Location of diff JSON file")
    report_parser.add_argument("output_html", help="Location to write output HTML file")

    def diff_report(args: dict):
        with open(args.input_json, "r") as f:
            td = TestDifferences.from_json(f.read())

        if args.hide_env:
            td.hide_env()

        with open(args.output_html, "w") as f:
            f.write(
                td.generate_html(
                    omit_preamble=args.omit_preamble, max_diffs=args.max_diffs
                )
            )

    report_parser.set_defaults(func=diff_report)

    args = parser.parse_args()

    args.func(args)


        for line in (
            subprocess.run(
                ["git", "branch", "--quiet", "--color=never", self.full_hash],
                capture_output=True,
            )
            .stdout.strip()
            .decode("utf-8")
            .splitlines()
        ):
            # Possible output:
            #
            #     main
            #     * scalar_seg_edges
            #
            # In this case, we have checked out the HEAD of the
            # scalar_seg_edges branch. Here we just strip the *.
            if line[0] == "*":
                line = line[2:]
                in_branches.append(line)

        def git_show(fmt) -> str:
            return (
                subprocess.run(
                    [
                        "git",
                        "show",
                        "--no-patch",
                        f"--format={fmt}",
                        self.full_hash,
                    ],
                    capture_output=True,
                )
                .stdout.strip()
                .decode("utf-8")
            )

        self.title = git_show("%s")
        self.author_name = git_show("%an")
        self.author_email = git_show("%ae")
        self.author_time = git_show("%ad")
        self.commit_time = git_show("%cd")

@dataclass_json
@dataclass
class LaunchParams:
    blockDim: tuple[int]
    gridDim: tuple[int]
    dynamic_smem_bytes: int

@dataclass_json
@dataclass
class CompiledKernel:
    filename: str


        #   ptxas info    : 307 bytes gmem
        #   ptxas info    : Compiling entry function
        #   '_ZN76_GLOBAL__N__00000000_37___tmp_kernel_pointwise_f0_c1_r0_g0_cu_8995cef2_3255329nvfuser_pointwise_f0_c1_r0_g0ENS_6TensorIfLi2ELi2EEES1_S1_'
        #   for 'sm_86'
        #   ptxas info    : Function properties for
        #   _ZN76_GLOBAL__N__00000000_37___tmp_kernel_pointwise_f0_c1_r0_g0_cu_8995cef2_3255329nvfuser_pointwise_f0_c1_r0_g0ENS_6TensorIfLi2ELi2EEES1_S1_
        #   ptxas         .     0 bytes stack frame, 0 bytes spill stores, 0 bytes spill loads
        #   ptxas info    : Used 203 registers, 16 bytes smem, 472 bytes cmem[0], 8 bytes cmem[2]
        #
        # Here we parse this into the fields presented, and we replace the
        # mangled kernel name since it includes the kernel number and is
        # useless for the purposes of diffing since the kernel signature is
        # already included.
        if self.ptxas_info is None:
            return

        m = re.search(r"Compiling entry function '(.*)' for '(.*)'", self.ptxas_info)
        if m is not None:
            self.mangled_name, self.arch = m.groups()

        def find_unique_int(pattern) -> int | None:
            assert self.ptxas_info is not None
            m = re.search(pattern, self.ptxas_info)
            return 0 if m is None else int(m.groups()[0])

        self.stack_frame_bytes = find_unique_int(r"(\d+) bytes stack frame")
        self.spill_store_bytes = find_unique_int(r"(\d+) bytes spill stores")
        self.spill_load_bytes = find_unique_int(r"(\d+) bytes spill loads")
        self.registers = find_unique_int(r"(\d+) registers")
        self.gmem_bytes = find_unique_int(r"(\d+) bytes gmem")
        self.smem_bytes = find_unique_int(r"(\d+) bytes smem")

        self.cmem_bank_bytes = []
        cmem_banks = 0
        for m in re.finditer(r"(\d+) bytes cmem\[(\d+)\]", self.ptxas_info):
            nbytes_str, bank_str = m.groups()
            bank = int(bank_str)
            if len(self.cmem_bank_bytes) <= bank:
                self.cmem_bank_bytes += [0] * (bank + 1 - len(self.cmem_bank_bytes))
            self.cmem_bank_bytes[bank] = int(nbytes_str)
            cmem_banks += 1

    def parse_launch_params(self):
        # If NVFUSER_DUMP=launch_param is given we will get a line like this for every launch:


# SPDX-FileCopyrightText: Copyright (c) 2023-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
"""
Find corresponding .cu files for matching tests, even when new tests are
introduced between two commits. Diffs are displayed and the return value is the
number of mismatched corresponding tests.

Tests are skipped if they produce different numbers of .cu files, or if they
exist in only one of the given runs.

Example usage:
    python tools/diff_codegen_nvfuser_tests.py \
            codegen_comparison/{$commit1,$commit2}/binary_tests
"""

import os

from codediff import TestRun, TestDifferences

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(
        epilog="This command must be run from within a git checkout of the NVFuser repo.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument("dir1", help="Directory containing 'stdout' and 'cuda/'")
    parser.add_argument("dir2", help="Directory containing 'stdout' and 'cuda/'")
    parser.add_argument(
        "--hide-env",
        action="store_true",
        help="Hide environment variables and nvcc versions in output?",
    )
    parser.add_argument("--html", action="store_true", help="Write HTML file?")
    parser.add_argument(
        "--hide-diffs",
        "--no-print-diff",
        action="store_true",
        help="Print diffs to STDOUT?",
    )
    parser.add_argument(
        "--kernel-inclusion-criterion",
        "-i",
        choices=("mismatched_cuda_or_ptx", "mismatched_ptx", "all"),
        default="mismatched_cuda_or_ptx",
        help="Which kernels should we include?",
    )
    parser.add_argument(
        "--html-max-diffs",
        default=200,
        type=int,
        help="Limit number of included kernel diffs in HTML output to this many (does not affect exit code).",
    )
    parser.add_argument(
        "--html-omit-preamble",
        action="store_true",
        help="Omit the preamble in HTML output?",
    )
    parser.add_argument(
        "-o", "--output-file", help="Location of HTML file output if -h is given."
    )
    parser.add_argument(
        "--json",
        help="Location to write JSON output, if given",
    )
    args = parser.parse_args()

    td = TestDifferences(
        TestRun(args.dir1),
        TestRun(args.dir2),
        show_diffs=not args.hide_diffs,
        inclusion_criterion=args.kernel_inclusion_criterion,
    )

    if args.hide_env:
        td.hide_env()

    if args.html:
        output_file = args.output_file
        if output_file is None:
            # determine default output file
            def get_abbrev(d):
                return os.path.basename(os.path.dirname(os.path.abspath(d)))

            abbrev1 = get_abbrev(args.dir1)
            abbrev2 = get_abbrev(args.dir2)
            run_name = os.path.basename(os.path.abspath(args.dir1))
            output_file = f"codediff_{abbrev1}_{abbrev2}_{run_name}.html"
        with open(output_file, "w") as f:
            f.write(
                td.generate_html(
                    omit_preamble=args.html_omit_preamble, max_diffs=args.html_max_diffs
                )
            )

    if args.json is not None:
        import json

        d = asdict(td)
        # clean up the dict a bit by removing temporary data structures
        del d["run1"]["kernel_map"]
        del d["run2"]["kernel_map"]
        json.dump(d, open(args.json, "w"), indent=2)

    if len(td.test_diffs) == 0:
        print("No differences found in overlapping tests!")
    else:
        print(
            td.total_num_diffs,
            "kernel differences from",
            len(td.test_diffs),
            "tests found",
        )
    if len(td.new_tests) > 0:
        print(len(td.new_tests), "new tests found")
    if len(td.removed_tests) > 0:
        print(len(td.removed_tests), "removed tests found")

    # Return 1 if preamble or any kernels are changed, else 0
    exit(1 if len(td.test_diffs) > 0 or len(td.preamble_diff) > 0 else 0)


    parser.add_argument(
        "--json",
        help="Location to write JSON output, if given",
    )
    args = parser.parse_args()

    td = TestDifferences(
        TestRun(args.dir1),
        TestRun(args.dir2),
        show_diffs=not args.hide_diffs,
        inclusion_criterion=args.kernel_inclusion_criterion,
    )

    if args.hide_env:
        td.hide_env()

    if args.html:
        output_file = args.output_file
        if output_file is None:
            # determine default output file
            def get_abbrev(d):
                return os.path.basename(os.path.dirname(os.path.abspath(d)))

            abbrev1 = get_abbrev(args.dir1)
            abbrev2 = get_abbrev(args.dir2)
            run_name = os.path.basename(os.path.abspath(args.dir1))
            output_file = f"codediff_{abbrev1}_{abbrev2}_{run_name}.html"
        with open(output_file, "w") as f:
            f.write(
                td.generate_html(
                    omit_preamble=args.html_omit_preamble, max_diffs=args.html_max_diffs
                )
            )

    if args.json is not None:
        import json

        d = asdict(td)
        # clean up the dict a bit by removing temporary data structures
        del d["run1"]["kernel_map"]
        del d["run2"]["kernel_map"]
        json.dump(d, open(args.json, "w"), indent=2)


tools.examples.repro

# SPDX-FileCopyrightText: Copyright (c) 2024-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause
T0 = fd.define_tensor(symbolic_sizes=[-1], contiguous=[True], dtype=DataType.Float)
T1 = fd.define_tensor(symbolic_sizes=[-1], contiguous=[True], dtype=DataType.Float)
T2 = fd.define_tensor(symbolic_sizes=[-1, -1], contiguous=[True, True], dtype=DataType.Half)
T3 = fd.ops.broadcast_in_dim(T0, output_shape=[1, 1024, 768], broadcast_dims=[2])
T4 = fd.ops.broadcast_in_dim(T1, output_shape=[1, 1024, 768], broadcast_dims=[2])
T5 = fd.ops.view(T2, original_shape=[1024, 768], new_shape=[1, 1024, 768])
T6 = fd.ops.cast(T5, dtype=DataType.Float)
S7 = fd.define_scalar(0.500000)
T8 = fd.ops.mul(T6, S7)
S9 = fd.define_scalar(0.707107)
T10 = fd.ops.mul(T6, S9)
T11 = fd.ops.erf(T10)
S12 = fd.define_scalar(1.00000)
T13 = fd.ops.add(T11, S12)
T14 = fd.ops.mul(T8, T13)
T15 = fd.ops.cast(T14, dtype=DataType.Half)
T16 = fd.ops.cast(T15, dtype=DataType.Float)
T17, T18 = fd.ops.var_mean(T16, dims=[2], correction=0, keepdim=False)
T19 = fd.ops.broadcast_in_dim(T17, output_shape=[1, 1024, 1], broadcast_dims=[0, 1])
T20 = fd.ops.broadcast_in_dim(T18, output_shape=[1, 1024, 1], broadcast_dims=[0, 1])
S21 = fd.define_scalar(1.00000e-05)
T22 = fd.ops.add(T19, S21)
T23 = fd.ops.broadcast_in_dim(T20, output_shape=[1, 1024, 768], broadcast_dims=[0, 1, 2])
T24 = fd.ops.rsqrt(T22)
T25 = fd.ops.sub(T16, T23)
T26 = fd.ops.broadcast_in_dim(T24, output_shape=[1, 1024, 768], broadcast_dims=[0, 1, 2])
T27 = fd.ops.mul(T25, T26)
T28 = fd.ops.mul(T27, T3)
T29 = fd.ops.add(T28, T4)
T30 = fd.ops.cast(T29, dtype=DataType.Float)
T31 = fd.ops.cast(T30, dtype=DataType.Half)
T32 = fd.ops.view(T31, original_shape=[1, 1024, 768], new_shape=[1024, 768])
fd.add_output(T5)
fd.add_output(T16)
fd.add_output(T20)
fd.add_output(T24)
fd.add_output(T32)


#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause

"""
Load environment options from YAML configuration.

This module provides a loader for env_options.yaml that replaces the
hardcoded Python definitions in configure_env.py.
"""

from __future__ import annotations

import yaml
from pathlib import Path
from dataclasses import dataclass, field
from typing import Literal

@dataclass
class EnvVarOption:
    """Represents a single environment variable option."""

    name: str
    description: str
    var_type: Literal["bool", "string", "int", "multi"]
    category: str
    env_var: str | None = None
    default: str = ""
    choices: list[str] = field(default_factory=list)
    current_value: str | None = None
    source: str = ""  # Optional: tracks where in options.h this came from

    def get_display_name(self) -> str:
        """Get the display name for this option."""
        return self.name

    def get_env_var_name(self) -> str:
        """Get the actual environment variable name."""
        if self.env_var is not None:
            return self.env_var
        return self.name

def load_options_from_yaml(
    yaml_path: str | Path,
) -> tuple[dict[str, str], list[EnvVarOption]]:
    """Load environment options from YAML file.

    Returns:
        Tuple of (CATEGORY_NAMES dict, ENV_VAR_DEFINITIONS list)
    """
    yaml_path = Path(yaml_path)

    if not yaml_path.exists():
        raise FileNotFoundError(f"Options YAML file not found: {yaml_path}")

    with open(yaml_path, "r") as f:
        config = yaml.safe_load(f)

    # Extract category names
    category_names = {}
    for cat_key, cat_info in config.get("categories", {}).items():
        category_names[cat_key] = cat_info["display_name"]

    # Extract options
    options = []
    for opt_data in config.get("options", []):
        option = EnvVarOption(
            name=opt_data["name"],
            description=opt_data["description"],
            var_type=opt_data["type"],
            category=opt_data["category"],
            env_var=opt_data.get("env_var"),
            default=opt_data.get("default", ""),
            choices=opt_data.get("choices", []),
            source=opt_data.get("source", ""),
        )
        options.append(option)

    return category_names, options

# For testing
if __name__ == "__main__":
    import sys

    yaml_file = sys.argv[1] if len(sys.argv) > 1 else "env_options.yaml"

    try:
        category_names, options = load_options_from_yaml(yaml_file)

        print(f"Loaded from {yaml_file}:")
        print(f"  Categories: {len(category_names)}")
        print(f"  Options: {len(options)}")
        print()

        # Count by category
        cat_counts = {}
        for opt in options:
            cat = opt.category
            if cat not in cat_counts:
                cat_counts[cat] = 0
            cat_counts[cat] += 1

        print("Options by category:")
        for cat, count in sorted(cat_counts.items()):
            cat_name = category_names.get(cat, cat)
            print(f"  {cat_name[:50]:50s}: {count:3d}")

    except FileNotFoundError as e:
        print(f"Error: {e}")
        sys.exit(1)
    except Exception as e:
        print(f"Error loading YAML: {e}")
        import traceback

        traceback.print_exc()
        sys.exit(1)


EnvVarOption

# SPDX-FileCopyrightText: Copyright (c) 2025-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause

"""
Load environment options from YAML configuration.

This module provides a loader for env_options.yaml that replaces the
hardcoded Python definitions in configure_env.py.
"""

from __future__ import annotations

import yaml
from pathlib import Path
from dataclasses import dataclass, field
from typing import Literal

@dataclass
class EnvVarOption:
    """Represents a single environment variable option."""

    name: str
    description: str
    var_type: Literal["bool", "string", "int", "multi"]
    category: str
    env_var: str | None = None
    default: str = ""
    choices: list[str] = field(default_factory=list)
    current_value: str | None = None
    source: str = ""  # Optional: tracks where in options.h this came from

    def get_display_name(self) -> str:
        """Get the display name for this option."""
        return self.name

    def get_env_var_name(self) -> str:
        """Get the actual environment variable name."""
        if self.env_var is not None:
            return self.env_var
        return self.name

def load_options_from_yaml(
    yaml_path: str | Path,
) -> tuple[dict[str, str], list[EnvVarOption]]:
    """Load environment options from YAML file.

    Returns:
        Tuple of (CATEGORY_NAMES dict, ENV_VAR_DEFINITIONS list)
    """
    yaml_path = Path(yaml_path)

    if not yaml_path.exists():
        raise FileNotFoundError(f"Options YAML file not found: {yaml_path}")

    with open(yaml_path, "r") as f:
        config = yaml.safe_load(f)

    # Extract category names
    category_names = {}


    description: str
    var_type: Literal["bool", "string", "int", "multi"]
    category: str
    env_var: str | None = None
    default: str = ""
    choices: list[str] = field(default_factory=list)
    current_value: str | None = None
    source: str = ""  # Optional: tracks where in options.h this came from

    def get_display_name(self) -> str:
        """Get the display name for this option."""
        return self.name

    def get_env_var_name(self) -> str:
        """Get the actual environment variable name."""
        if self.env_var is not None:
            return self.env_var
        return self.name

def load_options_from_yaml(
    yaml_path: str | Path,
) -> tuple[dict[str, str], list[EnvVarOption]]:
    """Load environment options from YAML file.

    Returns:
        Tuple of (CATEGORY_NAMES dict, ENV_VAR_DEFINITIONS list)
    """
    yaml_path = Path(yaml_path)

    if not yaml_path.exists():
        raise FileNotFoundError(f"Options YAML file not found: {yaml_path}")

    with open(yaml_path, "r") as f:
        config = yaml.safe_load(f)

    # Extract category names
    category_names = {}
    for cat_key, cat_info in config.get("categories", {}).items():
        category_names[cat_key] = cat_info["display_name"]

    # Extract options
    options = []
    for opt_data in config.get("options", []):
        option = EnvVarOption(
            name=opt_data["name"],
            description=opt_data["description"],
            var_type=opt_data["type"],
            category=opt_data["category"],
            env_var=opt_data.get("env_var"),
            default=opt_data.get("default", ""),
            choices=opt_data.get("choices", []),
            source=opt_data.get("source", ""),
        )
        options.append(option)

    return category_names, options

# For testing
if __name__ == "__main__":
    import sys

    yaml_file = sys.argv[1] if len(sys.argv) > 1 else "env_options.yaml"

    try:
        category_names, options = load_options_from_yaml(yaml_file)

        print(f"Loaded from {yaml_file}:")
        print(f"  Categories: {len(category_names)}")
        print(f"  Options: {len(options)}")
        print()

        # Count by category
        cat_counts = {}
        for opt in options:
            cat = opt.category


import yaml
from pathlib import Path
from dataclasses import dataclass, field
from typing import Literal

@dataclass
class EnvVarOption:
    """Represents a single environment variable option."""

    name: str
    description: str
    var_type: Literal["bool", "string", "int", "multi"]
    category: str
    env_var: str | None = None
    default: str = ""
    choices: list[str] = field(default_factory=list)
    current_value: str | None = None
    source: str = ""  # Optional: tracks where in options.h this came from

    def get_display_name(self) -> str:
        """Get the display name for this option."""
        return self.name

    def get_env_var_name(self) -> str:
        """Get the actual environment variable name."""
        if self.env_var is not None:
            return self.env_var
        return self.name

def load_options_from_yaml(
    yaml_path: str | Path,
) -> tuple[dict[str, str], list[EnvVarOption]]:
    """Load environment options from YAML file.

    Returns:
        Tuple of (CATEGORY_NAMES dict, ENV_VAR_DEFINITIONS list)
    """
    yaml_path = Path(yaml_path)

    if not yaml_path.exists():
        raise FileNotFoundError(f"Options YAML file not found: {yaml_path}")


@dataclass
class EnvVarOption:
    """Represents a single environment variable option."""

    name: str
    description: str
    var_type: Literal["bool", "string", "int", "multi"]
    category: str
    env_var: str | None = None
    default: str = ""
    choices: list[str] = field(default_factory=list)
    current_value: str | None = None
    source: str = ""  # Optional: tracks where in options.h this came from

    def get_display_name(self) -> str:
        """Get the display name for this option."""
        return self.name

    def get_env_var_name(self) -> str:
        """Get the actual environment variable name."""
        if self.env_var is not None:
            return self.env_var
        return self.name

def load_options_from_yaml(
    yaml_path: str | Path,
) -> tuple[dict[str, str], list[EnvVarOption]]:
    """Load environment options from YAML file.

    Returns:
        Tuple of (CATEGORY_NAMES dict, ENV_VAR_DEFINITIONS list)
    """
    yaml_path = Path(yaml_path)

    if not yaml_path.exists():
        raise FileNotFoundError(f"Options YAML file not found: {yaml_path}")

    with open(yaml_path, "r") as f:
        config = yaml.safe_load(f)

    # Extract category names
    category_names = {}


#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause

"""
Curses-based TUI for nvFuser environment configuration.
Provides a ccmake-like interface for navigating and configuring options.
"""

from __future__ import annotations

import curses
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from configure_env import EnvVarConfig

from configure_env import CATEGORY_NAMES

class CursesUI:
    """Curses-based UI for environment configuration."""

    def __init__(self, stdscr, config: EnvVarConfig) -> None:
        self.stdscr = stdscr
        self.config = config
        self.current_row: int = 0
        self.top_row: int = 0
        self.modified: bool = False
        self.should_exit: bool = False
        self.search_mode: bool = False
        self.search_query: str = ""
        self.search_matches: list[int] = []  # Indices of matching items
        self.search_match_index: int = 0  # Current match we're at

        # Build flat list of all options with category headers
        self.display_items: list[dict[str, str | object]] = []

        # Display categories in the order defined in CATEGORY_NAMES
        for category in CATEGORY_NAMES.keys():
            if category not in config.categories:
                continue
            opts = config.categories[category]
            # Add category header
            self.display_items.append(
                {
                    "type": "header",
                    "text": CATEGORY_NAMES[category],
                }
            )
            # Add options
            for opt in opts:
                self.display_items.append(
                    {
                        "type": "option",
                        "option": opt,
                    }
                )

        # Initialize curses
        curses.curs_set(0)  # Hide cursor
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Highlight
        curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Header
        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Enabled/Set
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Multi tag

    def draw_header(self) -> None:
        """Draw the top header."""
        height, width = self.stdscr.getmaxyx()
        self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
        header = "nvFuser Environment Configuration"
        self.stdscr.addstr(0, (width - len(header)) // 2, header)
        self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)

    def draw_footer(self) -> None:
        """Draw the bottom help text."""
        height, width = self.stdscr.getmaxyx()

        # Show search mode or normal help
        if self.search_mode:
            help_text = f"/{self.search_query}"
            if self.search_matches:
                help_text += (
                    f" [{self.search_match_index + 1}/{len(self.search_matches)}]"
                )
            help_text = help_text.ljust(width - 1)
        else:
            # Show context-sensitive help based on current selection
            item = (
                self.display_items[self.current_row]
                if self.current_row < len(self.display_items)
                else None
            )

            if item and item["type"] == "option":
                opt = item["option"]
                match opt.var_type:
                    case "bool":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case "multi":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Cycle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case _:
                        # String/Int - Enter to edit
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Edit  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
            else:
                help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"

            if self.modified:
                help_text = "[MODIFIED] " + help_text

        self.stdscr.attron(curses.color_pair(2))
        self.stdscr.addstr(height - 1, 0, help_text.ljust(width - 1))
        self.stdscr.attroff(curses.color_pair(2))

    def draw_option(
        self, y: int, item: dict[str, str | object], is_selected: bool
    ) -> None:
        """Draw a single option or header."""
        height, width = self.stdscr.getmaxyx()

        if y < 2 or y >= height - 1:
            return  # Skip if outside visible area

        if item["type"] == "header":
            # Draw category header
            attr = curses.color_pair(2) | curses.A_BOLD
            if is_selected:
                attr |= curses.A_REVERSE
            self.stdscr.attron(attr)
            self.stdscr.addstr(y, 0, ("  " + item["text"]).ljust(width - 1))
            self.stdscr.attroff(attr)

        elif item["type"] == "option":
            opt = item["option"]
            base_attr = 0

            if is_selected:
                base_attr |= curses.A_REVERSE

            # Format the option display
            # All options get a checkbox at the beginning showing if they're set
            prefix = "   "  # 3 spaces for better visual separation

            if opt.var_type == "bool":
                # Boolean option - checkbox shows enabled/disabled status
                name_part = opt.get_display_name()

                if opt.current_value == "1":
                    checkbox = "[X] "
                    attr = base_attr | curses.color_pair(3)  # Green for enabled
                else:
                    checkbox = "[ ] "
                    attr = base_attr

                line = f"{prefix}{checkbox}{name_part}"
                max_len = width - 1
                if len(line) > max_len:
                    line = line[: max_len - 3] + "..."

                self.stdscr.attron(attr)
                self.stdscr.addstr(y, 0, line.ljust(width - 1))
                self.stdscr.attroff(attr)

            else:
                # String/int/multi option with value display and checkbox
                display_name = opt.get_display_name()

                # Determine if this option has a value (affects checkbox and color)
                has_value = opt.current_value is not None
                line_attr = base_attr | curses.color_pair(3) if has_value else base_attr

                # Add checkbox at the beginning - checked if value is set
                if has_value:
                    checkbox = "[X] "
                else:
                    checkbox = "[ ] "

                name_part = f"{prefix}{checkbox}{display_name}"

                # Draw name part with checkbox (green if value is set)
                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, 0, name_part)
                self.stdscr.attroff(line_attr)

                # Add [multi] tag in yellow for multi-choice options
                multi_tag_len = 0
                if opt.var_type == "multi":
                    multi_tag = " [multi]"
                    multi_tag_len = len(multi_tag)
                    multi_attr = base_attr | curses.color_pair(4)  # Yellow
                    self.stdscr.attron(multi_attr)
                    self.stdscr.addstr(y, len(name_part), multi_tag)
                    self.stdscr.attroff(multi_attr)

                # Draw value part (green if set)
                if opt.current_value:
                    value_part = f" = {opt.current_value}"
                else:
                    # Show = "" for empty string/int/multi to indicate they expect values
                    value_part = ' = ""'

                # Calculate remaining space
                total_prefix = len(name_part) + multi_tag_len
                remaining_space = width - 1 - total_prefix
                if len(value_part) > remaining_space:
                    value_part = value_part[: remaining_space - 3] + "..."

                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, total_prefix, value_part)

                # Pad the rest of the line
                total_len = total_prefix + len(value_part)
                if total_len < width - 1:
                    self.stdscr.addstr(y, total_len, " " * (width - 1 - total_len))
                self.stdscr.attroff(line_attr)

    def draw_description(self) -> None:
        """Draw description of currently selected item."""
        height, width = self.stdscr.getmaxyx()

        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        # Draw description area separator
        desc_start = height - 5
        self.stdscr.hline(desc_start, 0, curses.ACS_HLINE, width)

        if item["type"] == "option":
            opt = item["option"]
            desc_y = desc_start + 1

            # Wrap description text
            desc_text = f"Description: {opt.description}"

            # Add choices for multi options
            if opt.var_type == "multi" and opt.choices:
                desc_text += f" | Choices: {', '.join(repr(c) for c in opt.choices)}"

            words = desc_text.split()
            lines = []
            current_line = ""

            for word in words:
                if len(current_line) + len(word) + 1 <= width - 2:
                    current_line += word + " "
                else:
                    lines.append(current_line.strip())
                    current_line = word + " "
            if current_line:
                lines.append(current_line.strip())

            # Draw description lines
            for i, line in enumerate(lines[:3]):  # Max 3 lines
                if desc_y + i < height - 1:
                    self.stdscr.addstr(desc_y + i, 1, line)

    def draw(self) -> None:
        """Draw the entire UI."""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        self.draw_header()

        # Draw options list
        visible_rows = height - 7  # Leave space for header, description, footer
        for i, item in enumerate(
            self.display_items[self.top_row : self.top_row + visible_rows]
        ):
            self.draw_option(i + 2, item, i + self.top_row == self.current_row)

        self.draw_description()
        self.draw_footer()

        self.stdscr.refresh()

    def handle_toggle(self) -> None:
        """Handle toggling/editing current item."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            match opt.var_type:
                case "bool":
                    # Toggle boolean
                    opt.current_value = "1" if opt.current_value != "1" else None
                    self.modified = True

                case "multi":
                    # Cycle through choices, including unset at the end
                    # Note: Some choices may include "" (empty string) as a valid choice
                    if opt.current_value is None:
                        # Currently unset, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    elif opt.current_value not in opt.choices:
                        # Invalid value, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    else:
                        # Valid value, cycle to next (or back to None after last)
                        idx = opt.choices.index(opt.current_value)
                        if idx == len(opt.choices) - 1:
                            # Last choice, cycle back to unset (None)
                            opt.current_value = None
                        else:
                            # Go to next choice
                            opt.current_value = opt.choices[idx + 1]
                    self.modified = True

                case "int" | "string":
                    # For string/int, Enter opens the editor
                    self.handle_edit()

    def handle_edit(self) -> None:
        """Handle editing current item value."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            if opt.var_type in ["int", "string", "multi"]:
                height, width = self.stdscr.getmaxyx()

                # Calculate the Y position of the current row on screen
                # Rows start at y=2 (after header), and we need to account for scroll
                screen_y = 2 + (self.current_row - self.top_row)

                # Make sure we're within visible area
                if screen_y < 2 or screen_y >= height - 5:
                    # Fall back to center if somehow out of range
                    screen_y = height // 2

                # Build the display name with [multi] tag if applicable
                display_name = opt.get_display_name()
                if opt.var_type == "multi":
                    display_name += " [multi]"

                # Get current value to pre-fill
                current_val = opt.current_value if opt.current_value else ""

                # Create prompt: "name = |cursor here|"
                prefix = "    "
                prompt_text = f"{prefix}{display_name} = "

                # Clear the line and show the prompt
                self.stdscr.move(screen_y, 0)
                self.stdscr.clrtoeol()
                self.stdscr.addstr(screen_y, 0, prompt_text, curses.A_BOLD)

                # Pre-fill with current value
                if current_val:
                    self.stdscr.addstr(screen_y, len(prompt_text), current_val)

                self.stdscr.refresh()

                # Position cursor at the end of the current value
                cursor_pos = len(prompt_text) + len(current_val)
                self.stdscr.move(screen_y, cursor_pos)

                # Enable cursor and echo
                curses.curs_set(1)
                curses.echo()

                try:
                    # Manual text editing with basic line editing support
                    buffer = list(current_val)  # Start with current value
                    cursor_offset = len(buffer)  # Cursor at end
                    max_len = width - len(prompt_text) - 2

                    while True:
                        # Display current buffer
                        self.stdscr.move(screen_y, len(prompt_text))
                        self.stdscr.clrtoeol()
                        display_text = "".join(buffer)
                        if len(display_text) > max_len:
                            display_text = display_text[:max_len]
                        self.stdscr.addstr(screen_y, len(prompt_text), display_text)
                        self.stdscr.move(screen_y, len(prompt_text) + cursor_offset)
                        self.stdscr.refresh()

                        # Get key
                        key = self.stdscr.getch()

                        if key == ord("\n") or key == curses.KEY_ENTER:
                            # Accept input
                            value = "".join(buffer).strip()
                            if value or opt.current_value:  # Allow clearing
                                opt.current_value = value if value else None
                                self.modified = True
                            break
                        elif key == 27:  # Escape
                            # Cancel editing
                            break
                        elif key == curses.KEY_BACKSPACE or key == 127 or key == 8:
                            # Backspace
                            if cursor_offset > 0:
                                buffer.pop(cursor_offset - 1)
                                cursor_offset -= 1
                        elif key == curses.KEY_DC:
                            # Delete key
                            if cursor_offset < len(buffer):
                                buffer.pop(cursor_offset)
                        elif key == curses.KEY_LEFT:
                            # Move cursor left
                            if cursor_offset > 0:
                                cursor_offset -= 1
                        elif key == curses.KEY_RIGHT:
                            # Move cursor right
                            if cursor_offset < len(buffer):
                                cursor_offset += 1
                        elif key == curses.KEY_HOME or key == 1:  # Ctrl-A
                            # Move to start
                            cursor_offset = 0
                        elif key == curses.KEY_END or key == 5:  # Ctrl-E
                            # Move to end
                            cursor_offset = len(buffer)
                        elif key == 21:  # Ctrl-U
                            # Clear line
                            buffer = []
                            cursor_offset = 0
                        elif 32 <= key <= 126:
                            # Printable character
                            if len(buffer) < max_len:
                                buffer.insert(cursor_offset, chr(key))
                                cursor_offset += 1

                except Exception:
                    pass
                finally:
                    curses.noecho()
                    curses.curs_set(0)

    def show_confirmation(self, message: str, prompt: str) -> str:
        """Show a simple y/n confirmation dialog.

        Returns the character pressed by the user (y/n/etc).
        """
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        # Center the message
        y = height // 2
        x = max(0, (width - len(message)) // 2)
        self.stdscr.addstr(y, x, message, curses.A_BOLD)

        # Show prompt below
        y += 2
        x = max(0, (width - len(prompt)) // 2)
        self.stdscr.addstr(y, x, prompt)

        self.stdscr.refresh()

        # Get single character response
        response = self.stdscr.getch()
        if response == 27:  # Escape
            return "n"
        return chr(response) if response < 256 else "n"

    def show_message(
        self, message: str, submessage: str = "", wait_for_key: bool = True
    ) -> None:
        """Show a simple message dialog"""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        # Center the message
        y = height // 2
        x = max(0, (width - len(message)) // 2)
        self.stdscr.addstr(y, x, message, curses.A_BOLD)

        # Show submessage below
        if submessage:
            y += 2
            x = max(0, (width - len(submessage)) // 2)
            self.stdscr.addstr(y, x, submessage)

        if wait_for_key:
            y += 2
            prompt = "Press any key to continue..."
            x = max(0, (width - len(prompt)) // 2)
            self.stdscr.addstr(y, x, prompt)

        self.stdscr.refresh()

        if wait_for_key:
            self.stdscr.getch()

    def handle_apply_now(self) -> None:
        """Show confirmation, generate apply script, and signal to exit"""
        # Import save_config here to avoid circular import
        from configure_env import save_config
        import os
        import sys
        import tempfile

        # Show confirmation dialog
        response = self.show_confirmation("Apply configuration and exit?", "[y/N]")

        if response not in ["y", "Y"]:
            return  # User cancel
# ... truncated ...


CursesUI

# SPDX-FileCopyrightText: Copyright (c) 2025-present NVIDIA CORPORATION & AFFILIATES.
# All rights reserved.
# SPDX-License-Identifier: BSD-3-Clause

"""
Curses-based TUI for nvFuser environment configuration.
Provides a ccmake-like interface for navigating and configuring options.
"""

from __future__ import annotations

import curses
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from configure_env import EnvVarConfig

from configure_env import CATEGORY_NAMES

class CursesUI:
    """Curses-based UI for environment configuration."""

    def __init__(self, stdscr, config: EnvVarConfig) -> None:
        self.stdscr = stdscr
        self.config = config
        self.current_row: int = 0
        self.top_row: int = 0
        self.modified: bool = False
        self.should_exit: bool = False
        self.search_mode: bool = False
        self.search_query: str = ""
        self.search_matches: list[int] = []  # Indices of matching items
        self.search_match_index: int = 0  # Current match we're at

        # Build flat list of all options with category headers
        self.display_items: list[dict[str, str | object]] = []

        # Display categories in the order defined in CATEGORY_NAMES
        for category in CATEGORY_NAMES.keys():
            if category not in config.categories:
                continue
            opts = config.categories[category]
            # Add category header
            self.display_items.append(
                {
                    "type": "header",
                    "text": CATEGORY_NAMES[category],
                }
            )
            # Add options
            for opt in opts:
                self.display_items.append(
                    {
                        "type": "option",
                        "option": opt,
                    }
                )

        # Initialize curses
        curses.curs_set(0)  # Hide cursor
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Highlight
        curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Header
        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Enabled/Set
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Multi tag

    def draw_header(self) -> None:
        """Draw the top header."""
        height, width = self.stdscr.getmaxyx()
        self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
        header = "nvFuser Environment Configuration"
        self.stdscr.addstr(0, (width - len(header)) // 2, header)
        self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)

    def draw_footer(self) -> None:
        """Draw the bottom help text."""
        height, width = self.stdscr.getmaxyx()

        # Show search mode or normal help
        if self.search_mode:
            help_text = f"/{self.search_query}"
            if self.search_matches:
                help_text += (
                    f" [{self.search_match_index + 1}/{len(self.search_matches)}]"
                )
            help_text = help_text.ljust(width - 1)
        else:
            # Show context-sensitive help based on current selection
            item = (
                self.display_items[self.current_row]
                if self.current_row < len(self.display_items)
                else None
            )

            if item and item["type"] == "option":
                opt = item["option"]
                match opt.var_type:
                    case "bool":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case "multi":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Cycle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case _:
                        # String/Int - Enter to edit
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Edit  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
            else:
                help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"

            if self.modified:
                help_text = "[MODIFIED] " + help_text

        self.stdscr.attron(curses.color_pair(2))
        self.stdscr.addstr(height - 1, 0, help_text.ljust(width - 1))
        self.stdscr.attroff(curses.color_pair(2))

    def draw_option(
        self, y: int, item: dict[str, str | object], is_selected: bool
    ) -> None:
        """Draw a single option or header."""
        height, width = self.stdscr.getmaxyx()

        if y < 2 or y >= height - 1:
            return  # Skip if outside visible area

        if item["type"] == "header":
            # Draw category header
            attr = curses.color_pair(2) | curses.A_BOLD
            if is_selected:
                attr |= curses.A_REVERSE
            self.stdscr.attron(attr)
            self.stdscr.addstr(y, 0, ("  " + item["text"]).ljust(width - 1))
            self.stdscr.attroff(attr)

        elif item["type"] == "option":
            opt = item["option"]
            base_attr = 0

            if is_selected:
                base_attr |= curses.A_REVERSE

            # Format the option display
            # All options get a checkbox at the beginning showing if they're set
            prefix = "   "  # 3 spaces for better visual separation

            if opt.var_type == "bool":
                # Boolean option - checkbox shows enabled/disabled status
                name_part = opt.get_display_name()

                if opt.current_value == "1":
                    checkbox = "[X] "
                    attr = base_attr | curses.color_pair(3)  # Green for enabled
                else:
                    checkbox = "[ ] "
                    attr = base_attr

                line = f"{prefix}{checkbox}{name_part}"
                max_len = width - 1
                if len(line) > max_len:
                    line = line[: max_len - 3] + "..."

                self.stdscr.attron(attr)
                self.stdscr.addstr(y, 0, line.ljust(width - 1))
                self.stdscr.attroff(attr)

            else:
                # String/int/multi option with value display and checkbox
                display_name = opt.get_display_name()

                # Determine if this option has a value (affects checkbox and color)
                has_value = opt.current_value is not None
                line_attr = base_attr | curses.color_pair(3) if has_value else base_attr

                # Add checkbox at the beginning - checked if value is set
                if has_value:
                    checkbox = "[X] "
                else:
                    checkbox = "[ ] "

                name_part = f"{prefix}{checkbox}{display_name}"

                # Draw name part with checkbox (green if value is set)
                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, 0, name_part)
                self.stdscr.attroff(line_attr)

                # Add [multi] tag in yellow for multi-choice options
                multi_tag_len = 0
                if opt.var_type == "multi":
                    multi_tag = " [multi]"
                    multi_tag_len = len(multi_tag)
                    multi_attr = base_attr | curses.color_pair(4)  # Yellow
                    self.stdscr.attron(multi_attr)
                    self.stdscr.addstr(y, len(name_part), multi_tag)
                    self.stdscr.attroff(multi_attr)

                # Draw value part (green if set)
                if opt.current_value:
                    value_part = f" = {opt.current_value}"
                else:
                    # Show = "" for empty string/int/multi to indicate they expect values
                    value_part = ' = ""'

                # Calculate remaining space
                total_prefix = len(name_part) + multi_tag_len
                remaining_space = width - 1 - total_prefix
                if len(value_part) > remaining_space:
                    value_part = value_part[: remaining_space - 3] + "..."

                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, total_prefix, value_part)

                # Pad the rest of the line
                total_len = total_prefix + len(value_part)
                if total_len < width - 1:
                    self.stdscr.addstr(y, total_len, " " * (width - 1 - total_len))
                self.stdscr.attroff(line_attr)

    def draw_description(self) -> None:
        """Draw description of currently selected item."""
        height, width = self.stdscr.getmaxyx()

        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        # Draw description area separator
        desc_start = height - 5
        self.stdscr.hline(desc_start, 0, curses.ACS_HLINE, width)

        if item["type"] == "option":
            opt = item["option"]
            desc_y = desc_start + 1

            # Wrap description text
            desc_text = f"Description: {opt.description}"

            # Add choices for multi options
            if opt.var_type == "multi" and opt.choices:
                desc_text += f" | Choices: {', '.join(repr(c) for c in opt.choices)}"

            words = desc_text.split()
            lines = []
            current_line = ""

            for word in words:
                if len(current_line) + len(word) + 1 <= width - 2:
                    current_line += word + " "
                else:
                    lines.append(current_line.strip())
                    current_line = word + " "
            if current_line:
                lines.append(current_line.strip())

            # Draw description lines
            for i, line in enumerate(lines[:3]):  # Max 3 lines
                if desc_y + i < height - 1:
                    self.stdscr.addstr(desc_y + i, 1, line)

    def draw(self) -> None:
        """Draw the entire UI."""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        self.draw_header()

        # Draw options list
        visible_rows = height - 7  # Leave space for header, description, footer
        for i, item in enumerate(
            self.display_items[self.top_row : self.top_row + visible_rows]
        ):
            self.draw_option(i + 2, item, i + self.top_row == self.current_row)

        self.draw_description()
        self.draw_footer()

        self.stdscr.refresh()

    def handle_toggle(self) -> None:
        """Handle toggling/editing current item."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            match opt.var_type:
                case "bool":
                    # Toggle boolean
                    opt.current_value = "1" if opt.current_value != "1" else None
                    self.modified = True

                case "multi":
                    # Cycle through choices, including unset at the end
                    # Note: Some choices may include "" (empty string) as a valid choice
                    if opt.current_value is None:
                        # Currently unset, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    elif opt.current_value not in opt.choices:
                        # Invalid value, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    else:
                        # Valid value, cycle to next (or back to None after last)
                        idx = opt.choices.index(opt.current_value)
                        if idx == len(opt.choices) - 1:
                            # Last choice, cycle back to unset (None)
                            opt.current_value = None
                        else:
                            # Go to next choice
                            opt.current_value = opt.choices[idx + 1]
                    self.modified = True

                case "int" | "string":
                    # For string/int, Enter opens the editor
                    self.handle_edit()

    def handle_edit(self) -> None:
        """Handle editing current item value."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            if opt.var_type in ["int", "string", "multi"]:
                height, width = self.stdscr.getmaxyx()

                # Calculate the Y position of the current row on screen
                # Rows start at y=2 (after header), and we need to account for scroll
                screen_y = 2 + (self.current_row - self.top_row)

                # Make sure we're within visible area
                if screen_y < 2 or screen_y >= height - 5:
                    # Fall back to center if somehow out of range
                    screen_y = height // 2

                # Build the display name with [multi] tag if applicable
                display_name = opt.get_display_name()
                if opt.var_type == "multi":
                    display_name += " [multi]"

                # Get current value to pre-fill
                current_val = opt.current_value if opt.current_value else ""

                # Create prompt: "name = |cursor here|"
                prefix = "    "
                prompt_text = f"{prefix}{display_name} = "

                # Clear the line and show the prompt
                self.stdscr.move(screen_y, 0)
                self.stdscr.clrtoeol()
                self.stdscr.addstr(screen_y, 0, prompt_text, curses.A_BOLD)

                # Pre-fill with current value
                if current_val:
                    self.stdscr.addstr(screen_y, len(prompt_text), current_val)

                self.stdscr.refresh()

                # Position cursor at the end of the current value
                cursor_pos = len(prompt_text) + len(current_val)
                self.stdscr.move(screen_y, cursor_pos)

                # Enable cursor and echo
                curses.curs_set(1)
                curses.echo()

                try:
                    # Manual text editing with basic line editing support
                    buffer = list(current_val)  # Start with current value
                    cursor_offset = len(buffer)  # Cursor at end
                    max_len = width - len(prompt_text) - 2

                    while True:
                        # Display current buffer
                        self.stdscr.move(screen_y, len(prompt_text))
                        self.stdscr.clrtoeol()
                        display_text = "".join(buffer)
                        if len(display_text) > max_len:
                            display_text = display_text[:max_len]
                        self.stdscr.addstr(screen_y, len(prompt_text), display_text)
                        self.stdscr.move(screen_y, len(prompt_text) + cursor_offset)
                        self.stdscr.refresh()

                        # Get key
                        key = self.stdscr.getch()

                        if key == ord("\n") or key == curses.KEY_ENTER:
                            # Accept input
                            value = "".join(buffer).strip()
                            if value or opt.current_value:  # Allow clearing
                                opt.current_value = value if value else None
                                self.modified = True
                            break
                        elif key == 27:  # Escape
                            # Cancel editing
                            break
                        elif key == curses.KEY_BACKSPACE or key == 127 or key == 8:
                            # Backspace
                            if cursor_offset > 0:
                                buffer.pop(cursor_offset - 1)
                                cursor_offset -= 1
                        elif key == curses.KEY_DC:
                            # Delete key
                            if cursor_offset < len(buffer):
                                buffer.pop(cursor_offset)
                        elif key == curses.KEY_LEFT:
                            # Move cursor left
                            if cursor_offset > 0:
                                cursor_offset -= 1
                        elif key == curses.KEY_RIGHT:
                            # Move cursor right
                            if cursor_offset < len(buffer):
                                cursor_offset += 1
                        elif key == curses.KEY_HOME or key == 1:  # Ctrl-A
                            # Move to start
                            cursor_offset = 0
                        elif key == curses.KEY_END or key == 5:  # Ctrl-E
                            # Move to end
                            cursor_offset = len(buffer)
                        elif key == 21:  # Ctrl-U
                            # Clear line
                            buffer = []
                            cursor_offset = 0
                        elif 32 <= key <= 126:
                            # Printable character
                            if len(buffer) < max_len:
                                buffer.insert(cursor_offset, chr(key))
                                cursor_offset += 1

                except Exception:
                    pass
                finally:
                    curses.noecho()
                    curses.curs_set(0)

    def show_confirmation(self, message: str, prompt: str) -> str:
        """Show a simple y/n confirmation dialog.

        Returns the character pressed by the user (y/n/etc).
        """
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        # Center the message
        y = height // 2
        x = max(0, (width - len(message)) // 2)
        self.stdscr.addstr(y, x, message, curses.A_BOLD)

        # Show prompt below
        y += 2
        x = max(0, (width - len(prompt)) // 2)
        self.stdscr.addstr(y, x, prompt)

        self.stdscr.refresh()

        # Get single character response
        response = self.stdscr.getch()
        if response == 27:  # Escape
            return "n"
        return chr(response) if response < 256 else "n"

    def show_message(
        self, message: str, submessage: str = "", wait_for_key: bool = True
    ) -> None:
        """Show a simple message dialog"""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        # Center the message
        y = height // 2
        x = max(0, (width - len(message)) // 2)
        self.stdscr.addstr(y, x, message, curses.A_BOLD)

        # Show submessage below
        if submessage:
            y += 2
            x = max(0, (width - len(submessage)) // 2)
            self.stdscr.addstr(y, x, submessage)

        if wait_for_key:
            y += 2
            prompt = "Press any key to continue..."
            x = max(0, (width - len(prompt)) // 2)
            self.stdscr.addstr(y, x, prompt)

        self.stdscr.refresh()

        if wait_for_key:
            self.stdscr.getch()

    def handle_apply_now(self) -> None:
        """Show confirmation, generate apply script, and signal to exit"""
        # Import save_config here to avoid circular import
        from configure_env import save_config
        import os
        import sys
        import tempfile

        # Show confirmation dialog
        response = self.show_confirmation("Apply configuration and exit?", "[y/N]")

        if response not in ["y", "Y"]:
            return  # User cancelled

        # Get expo
# ... truncated ...


                    break  # Exit immediately after apply

            elif key == ord("g") or key == ord("G"):
                self.handle_generate()
                if self.should_exit:
                    break  # Exit immediately after generate

            elif key == ord("/"):
                # Enter search mode
                self.handle_search()

            elif key == ord("n"):
                # Jump to next search match
                self.jump_to_next_match()

            elif key == ord("N"):
                # Jump to previous search match
                self.jump_to_prev_match()

def run_curses_ui(stdscr, config: EnvVarConfig) -> None:
    """Entry point for curses UI."""
    ui = CursesUI(stdscr, config)
    ui.run()


"""
Curses-based TUI for nvFuser environment configuration.
Provides a ccmake-like interface for navigating and configuring options.
"""

from __future__ import annotations

import curses
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from configure_env import EnvVarConfig

from configure_env import CATEGORY_NAMES

class CursesUI:
    """Curses-based UI for environment configuration."""

    def __init__(self, stdscr, config: EnvVarConfig) -> None:
        self.stdscr = stdscr
        self.config = config
        self.current_row: int = 0
        self.top_row: int = 0
        self.modified: bool = False
        self.should_exit: bool = False
        self.search_mode: bool = False
        self.search_query: str = ""
        self.search_matches: list[int] = []  # Indices of matching items
        self.search_match_index: int = 0  # Current match we're at

        # Build flat list of all options with category headers
        self.display_items: list[dict[str, str | object]] = []

        # Display categories in the order defined in CATEGORY_NAMES
        for category in CATEGORY_NAMES.keys():
            if category not in config.categories:
                continue
            opts = config.categories[category]
            # Add category header
            self.display_items.append(
                {
                    "type": "header",
                    "text": CATEGORY_NAMES[category],
                }
            )
            # Add options
            for opt in opts:
                self.display_items.append(
                    {
                        "type": "option",
                        "option": opt,
                    }
                )

        # Initialize curses
        curses.curs_set(0)  # Hide cursor
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Highlight
        curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Header
        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Enabled/Set
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Multi tag

    def draw_header(self) -> None:
        """Draw the top header."""
        height, width = self.stdscr.getmaxyx()
        self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
        header = "nvFuser Environment Configuration"
        self.stdscr.addstr(0, (width - len(header)) // 2, header)
        self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)

    def draw_footer(self) -> None:
        """Draw the bottom help text."""
        height, width = self.stdscr.getmaxyx()

        # Show search mode or normal help
        if self.search_mode:
            help_text = f"/{self.search_query}"
            if self.search_matches:
                help_text += (
                    f" [{self.search_match_index + 1}/{len(self.search_matches)}]"
                )


                    "type": "header",
                    "text": CATEGORY_NAMES[category],
                }
            )
            # Add options
            for opt in opts:
                self.display_items.append(
                    {
                        "type": "option",
                        "option": opt,
                    }
                )

        # Initialize curses
        curses.curs_set(0)  # Hide cursor
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Highlight
        curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Header
        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Enabled/Set
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Multi tag

    def draw_header(self) -> None:
        """Draw the top header."""
        height, width = self.stdscr.getmaxyx()
        self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
        header = "nvFuser Environment Configuration"
        self.stdscr.addstr(0, (width - len(header)) // 2, header)
        self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)

    def draw_footer(self) -> None:
        """Draw the bottom help text."""
        height, width = self.stdscr.getmaxyx()

        # Show search mode or normal help
        if self.search_mode:
            help_text = f"/{self.search_query}"
            if self.search_matches:
                help_text += (
                    f" [{self.search_match_index + 1}/{len(self.search_matches)}]"
                )
            help_text = help_text.ljust(width - 1)
        else:
            # Show context-sensitive help based on current selection
            item = (
                self.display_items[self.current_row]
                if self.current_row < len(self.display_items)
                else None
            )


                        "type": "option",
                        "option": opt,
                    }
                )

        # Initialize curses
        curses.curs_set(0)  # Hide cursor
        curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)  # Highlight
        curses.init_pair(2, curses.COLOR_CYAN, curses.COLOR_BLACK)  # Header
        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)  # Enabled/Set
        curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)  # Multi tag

    def draw_header(self) -> None:
        """Draw the top header."""
        height, width = self.stdscr.getmaxyx()
        self.stdscr.attron(curses.color_pair(2) | curses.A_BOLD)
        header = "nvFuser Environment Configuration"
        self.stdscr.addstr(0, (width - len(header)) // 2, header)
        self.stdscr.attroff(curses.color_pair(2) | curses.A_BOLD)

    def draw_footer(self) -> None:
        """Draw the bottom help text."""
        height, width = self.stdscr.getmaxyx()

        # Show search mode or normal help
        if self.search_mode:
            help_text = f"/{self.search_query}"
            if self.search_matches:
                help_text += (
                    f" [{self.search_match_index + 1}/{len(self.search_matches)}]"
                )
            help_text = help_text.ljust(width - 1)
        else:
            # Show context-sensitive help based on current selection
            item = (
                self.display_items[self.current_row]
                if self.current_row < len(self.display_items)
                else None
            )

            if item and item["type"] == "option":
                opt = item["option"]
                match opt.var_type:
                    case "bool":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case "multi":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Cycle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case _:
                        # String/Int - Enter to edit
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Edit  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
            else:
                help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"

            if self.modified:
                help_text = "[MODIFIED] " + help_text

        self.stdscr.attron(curses.color_pair(2))
        self.stdscr.addstr(height - 1, 0, help_text.ljust(width - 1))
        self.stdscr.attroff(curses.color_pair(2))

    def draw_option(
        self, y: int, item: dict[str, str | object], is_selected: bool
    ) -> None:
        """Draw a single option or header."""
        height, width = self.stdscr.getmaxyx()

        if y < 2 or y >= height - 1:
            return  # Skip if outside visible area

        if item["type"] == "header":
            # Draw category header
            attr = curses.color_pair(2) | curses.A_BOLD
            if is_selected:
                attr |= curses.A_REVERSE
            self.stdscr.attron(attr)
            self.stdscr.addstr(y, 0, ("  " + item["text"]).ljust(width - 1))
            self.stdscr.attroff(attr)

        elif item["type"] == "option":


            if item and item["type"] == "option":
                opt = item["option"]
                match opt.var_type:
                    case "bool":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case "multi":
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Cycle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
                    case _:
                        # String/Int - Enter to edit
                        help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Edit  [r] Reload  [a] Apply  [g] Gen  [q] Quit"
            else:
                help_text = "[↑↓/jk] Nav  [/] Search  [n/N] Next/Prev  [{}] Sect  [PgUp/PgDn] Top/Bot  [Enter] Toggle  [r] Reload  [a] Apply  [g] Gen  [q] Quit"

            if self.modified:
                help_text = "[MODIFIED] " + help_text

        self.stdscr.attron(curses.color_pair(2))
        self.stdscr.addstr(height - 1, 0, help_text.ljust(width - 1))
        self.stdscr.attroff(curses.color_pair(2))

    def draw_option(
        self, y: int, item: dict[str, str | object], is_selected: bool
    ) -> None:
        """Draw a single option or header."""
        height, width = self.stdscr.getmaxyx()

        if y < 2 or y >= height - 1:
            return  # Skip if outside visible area

        if item["type"] == "header":
            # Draw category header
            attr = curses.color_pair(2) | curses.A_BOLD
            if is_selected:
                attr |= curses.A_REVERSE
            self.stdscr.attron(attr)
            self.stdscr.addstr(y, 0, ("  " + item["text"]).ljust(width - 1))
            self.stdscr.attroff(attr)

        elif item["type"] == "option":
            opt = item["option"]
            base_attr = 0

            if is_selected:
                base_attr |= curses.A_REVERSE

            # Format the option display
            # All options get a checkbox at the beginning showing if they're set
            prefix = "   "  # 3 spaces for better visual separation

            if opt.var_type == "bool":
                # Boolean option - checkbox shows enabled/disabled status
                name_part = opt.get_display_name()

                if opt.current_value == "1":
                    checkbox = "[X] "
                    attr = base_attr | curses.color_pair(3)  # Green for enabled
                else:
                    checkbox = "[ ] "
                    attr = base_attr

                line = f"{prefix}{checkbox}{name_part}"
                max_len = width - 1
                if len(line) > max_len:
                    line = line[: max_len - 3] + "..."

                self.stdscr.attron(attr)
                self.stdscr.addstr(y, 0, line.ljust(width - 1))
                self.stdscr.attroff(attr)

            else:
                # String/int/multi option with value display and checkbox
                display_name = opt.get_display_name()

                # Determine if this option has a value (affects checkbox and color)
                has_value = opt.current_value is not None
                line_attr = base_attr | curses.color_pair(3) if has_value else base_attr

                # Add checkbox at the beginning - checked if value is set
                if has_value:
                    checkbox = "[X] "
                else:
                    checkbox = "[ ] "

                name_part = f"{prefix}{checkbox}{display_name}"

                # Draw name part with checkbox (green if value is set)
                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, 0, name_part)
                self.stdscr.attroff(line_attr)

                # Add [multi] tag in yellow for multi-choice options
                multi_tag_len = 0
                if opt.var_type == "multi":
                    multi_tag = " [multi]"
                    multi_tag_len = len(multi_tag)
                    multi_attr = base_attr | curses.color_pair(4)  # Yellow
                    self.stdscr.attron(multi_attr)
                    self.stdscr.addstr(y, len(name_part), multi_tag)
                    self.stdscr.attroff(multi_attr)

                # Draw value part (green if set)
                if opt.current_value:
                    value_part = f" = {opt.current_value}"
                else:
                    # Show = "" for empty string/int/multi to indicate they expect values
                    value_part = ' = ""'

                # Calculate remaining space
                total_prefix = len(name_part) + multi_tag_len
                remaining_space = width - 1 - total_prefix
                if len(value_part) > remaining_space:
                    value_part = value_part[: remaining_space - 3] + "..."

                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, total_prefix, value_part)

                # Pad the rest of the line
                total_len = total_prefix + len(value_part)
                if total_len < width - 1:
                    self.stdscr.addstr(y, total_len, " " * (width - 1 - total_len))
                self.stdscr.attroff(line_attr)

    def draw_description(self) -> None:
        """Draw description of currently selected item."""
        height, width = self.stdscr.getmaxyx()

        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        # Draw description area separator
        desc_start = height - 5
        self.stdscr.hline(desc_start, 0, curses.ACS_HLINE, width)

        if item["type"] == "option":
            opt = item["option"]
            desc_y = desc_start + 1

            # Wrap description text
            desc_text = f"Description: {opt.description}"


                    value_part = f" = {opt.current_value}"
                else:
                    # Show = "" for empty string/int/multi to indicate they expect values
                    value_part = ' = ""'

                # Calculate remaining space
                total_prefix = len(name_part) + multi_tag_len
                remaining_space = width - 1 - total_prefix
                if len(value_part) > remaining_space:
                    value_part = value_part[: remaining_space - 3] + "..."

                self.stdscr.attron(line_attr)
                self.stdscr.addstr(y, total_prefix, value_part)

                # Pad the rest of the line
                total_len = total_prefix + len(value_part)
                if total_len < width - 1:
                    self.stdscr.addstr(y, total_len, " " * (width - 1 - total_len))
                self.stdscr.attroff(line_attr)

    def draw_description(self) -> None:
        """Draw description of currently selected item."""
        height, width = self.stdscr.getmaxyx()

        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        # Draw description area separator
        desc_start = height - 5
        self.stdscr.hline(desc_start, 0, curses.ACS_HLINE, width)

        if item["type"] == "option":
            opt = item["option"]
            desc_y = desc_start + 1

            # Wrap description text
            desc_text = f"Description: {opt.description}"

            # Add choices for multi options
            if opt.var_type == "multi" and opt.choices:
                desc_text += f" | Choices: {', '.join(repr(c) for c in opt.choices)}"

            words = desc_text.split()
            lines = []
            current_line = ""

            for word in words:
                if len(current_line) + len(word) + 1 <= width - 2:
                    current_line += word + " "
                else:
                    lines.append(current_line.strip())
                    current_line = word + " "
            if current_line:
                lines.append(current_line.strip())

            # Draw description lines
            for i, line in enumerate(lines[:3]):  # Max 3 lines
                if desc_y + i < height - 1:
                    self.stdscr.addstr(desc_y + i, 1, line)

    def draw(self) -> None:
        """Draw the entire UI."""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        self.draw_header()

        # Draw options list
        visible_rows = height - 7  # Leave space for header, description, footer
        for i, item in enumerate(
            self.display_items[self.top_row : self.top_row + visible_rows]
        ):
            self.draw_option(i + 2, item, i + self.top_row == self.current_row)

        self.draw_description()
        self.draw_footer()

        self.stdscr.refresh()


draw

                desc_text += f" | Choices: {', '.join(repr(c) for c in opt.choices)}"

            words = desc_text.split()
            lines = []
            current_line = ""

            for word in words:
                if len(current_line) + len(word) + 1 <= width - 2:
                    current_line += word + " "
                else:
                    lines.append(current_line.strip())
                    current_line = word + " "
            if current_line:
                lines.append(current_line.strip())

            # Draw description lines
            for i, line in enumerate(lines[:3]):  # Max 3 lines
                if desc_y + i < height - 1:
                    self.stdscr.addstr(desc_y + i, 1, line)

    def draw(self) -> None:
        """Draw the entire UI."""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        self.draw_header()

        # Draw options list
        visible_rows = height - 7  # Leave space for header, description, footer
        for i, item in enumerate(
            self.display_items[self.top_row : self.top_row + visible_rows]
        ):
            self.draw_option(i + 2, item, i + self.top_row == self.current_row)

        self.draw_description()
        self.draw_footer()

        self.stdscr.refresh()

    def handle_toggle(self) -> None:
        """Handle toggling/editing current item."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            match opt.var_type:
                case "bool":
                    # Toggle boolean
                    opt.current_value = "1" if opt.current_value != "1" else None
                    self.modified = True

                case "multi":
                    # Cycle through choices, including unset at the end
                    # Note: Some choices may include "" (empty string) as a valid choice


    def draw(self) -> None:
        """Draw the entire UI."""
        self.stdscr.clear()
        height, width = self.stdscr.getmaxyx()

        self.draw_header()

        # Draw options list
        visible_rows = height - 7  # Leave space for header, description, footer
        for i, item in enumerate(
            self.display_items[self.top_row : self.top_row + visible_rows]
        ):
            self.draw_option(i + 2, item, i + self.top_row == self.current_row)

        self.draw_description()
        self.draw_footer()

        self.stdscr.refresh()

    def handle_toggle(self) -> None:
        """Handle toggling/editing current item."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            match opt.var_type:
                case "bool":
                    # Toggle boolean
                    opt.current_value = "1" if opt.current_value != "1" else None
                    self.modified = True

                case "multi":
                    # Cycle through choices, including unset at the end
                    # Note: Some choices may include "" (empty string) as a valid choice
                    if opt.current_value is None:
                        # Currently unset, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    elif opt.current_value not in opt.choices:
                        # Invalid value, go to first choice
                        opt.current_value = opt.choices[0] if opt.choices else ""
                    else:
                        # Valid value, cycle to next (or back to None after last)
                        idx = opt.choices.index(opt.current_value)
                        if idx == len(opt.choices) - 1:
                            # Last choice, cycle back to unset (None)
                            opt.current_value = None
                        else:
                            # Go to next choice
                            opt.current_value = opt.choices[idx + 1]
                    self.modified = True

                case "int" | "string":
                    # For string/int, Enter opens the editor
                    self.handle_edit()

    def handle_edit(self) -> None:
        """Handle editing current item value."""
        if self.current_row >= len(self.display_items):
            return

        item = self.display_items[self.current_row]

        if item["type"] == "option":
            opt = item["option"]

            if opt.var_type in ["int", "string", "multi"]:
                height, width = self.stdscr.getmaxyx()

                # Calculate the Y position of the current row on screen
                # Rows start at y=2 (after header), and we need to account for scroll
                screen_y = 2 + (self.current_row - self.top_row)

                # Make sure we're within visible area
                if screen_y < 2 or screen_y >= height - 5: