diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index f9675782..8aa0c5ce 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -365,7 +365,7 @@ Strix Cloud: except ValueError as error: parser.error(f"--workspace-file: {error}") - args.user_explicit_instruction = args.instruction if args.resume else None + args.user_explicit_instruction = args.instruction if args.resume is not None else None # What the user actually asked for, kept apart from args.instruction because # prepare_run prepends the diff-scope preamble to that. This is the text the # transcript shows as their opening message. diff --git a/strix/interface/resume_picker.py b/strix/interface/resume_picker.py index 8a4e2d1f..47e797e3 100644 --- a/strix/interface/resume_picker.py +++ b/strix/interface/resume_picker.py @@ -82,6 +82,18 @@ _FINDINGS_WIDTH = 8 _MIN_TARGET_WIDTH = 12 _MAX_RUN_WIDTH = 40 _CHROME_LINES = 8 +_MAX_VISIBLE = 8 + + +def _utf8_length(lead: bytes) -> int: + byte = lead[0] if lead else 0 + if byte >= 0xF0: + return 4 + if byte >= 0xE0: + return 3 + if byte >= 0xC0: + return 2 + return 1 class PickerUnavailableError(RuntimeError): @@ -106,8 +118,9 @@ else: fd = stream.fileno() saved = termios.tcgetattr(fd) try: - tty.setcbreak(fd) - char = os.read(fd, 1).decode("utf-8", "replace") + tty.setcbreak(fd, termios.TCSANOW) + first = os.read(fd, 1) + char = (first + os.read(fd, _utf8_length(first) - 1)).decode("utf-8", "replace") if char != "\x1b": return char sequence = "" @@ -200,7 +213,7 @@ class ResumePicker: return filter_runs(self.runs, self.filter) def _visible(self) -> int: - return max(3, min(len(self.rows), self.console.height - _CHROME_LINES)) + return max(3, min(len(self.rows), _MAX_VISIBLE, self.console.height - _CHROME_LINES)) def _columns(self) -> tuple[int, int, int]: width = max(40, self.console.width - 1) @@ -256,6 +269,8 @@ class ResumePicker: "\u2191\u2193 move enter resume type to search esc cancel", style="dim" ) lines.extend([Text(), footer]) + for line in lines: + line.truncate(self.console.width - 1) return lines @staticmethod diff --git a/strix/report/runs.py b/strix/report/runs.py index c2e82046..1c46115c 100644 --- a/strix/report/runs.py +++ b/strix/report/runs.py @@ -72,8 +72,8 @@ def _describe_target(record: dict[str, Any]) -> str: mount = record.get("workspace_mount") if isinstance(mount, str) and mount: return f"{mount} (workspace)" - instruction = record.get("user_instruction") or record.get("instruction") - return str(instruction or "").strip().splitlines()[0] if instruction else "" + instruction = str(record.get("user_instruction") or record.get("instruction") or "").strip() + return instruction.splitlines()[0] if instruction else "" def _load_json(path: Path, *, default: Any) -> Any: diff --git a/tests/test_cli_resume_picker.py b/tests/test_cli_resume_picker.py index 56096187..ed0aeb05 100644 --- a/tests/test_cli_resume_picker.py +++ b/tests/test_cli_resume_picker.py @@ -51,10 +51,22 @@ def test_bare_resume_defers_to_the_picker(tmp_path: Path, monkeypatch: pytest.Mo assert args.resume is None assert args.resume_picker is True assert args.needs_setup is False - assert args.resume is None assert args.targets_info == [] +def test_bare_resume_keeps_the_new_instruction_as_resume_guidance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + _write_run(tmp_path / "strix_runs", "example-com_1111") + monkeypatch.setattr(sys, "argv", ["strix", "--resume", "--instruction", "focus on auth"]) + + args = cli_main.parse_arguments() + + assert args.resume_picker is True + assert args.user_explicit_instruction == "focus on auth" + + def test_bare_resume_with_no_runs_is_an_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/tests/test_resume_picker.py b/tests/test_resume_picker.py index bbf640ce..e3082e73 100644 --- a/tests/test_resume_picker.py +++ b/tests/test_resume_picker.py @@ -1,6 +1,8 @@ from __future__ import annotations import io +import os +import sys from datetime import UTC, datetime, timedelta import pytest @@ -18,6 +20,7 @@ from strix.interface.resume_picker import ( ResumePicker, filter_runs, pick_run, + read_raw_key, relative_time, translate_key, ) @@ -219,3 +222,39 @@ def test_pick_run_needs_a_terminal() -> None: console, _ = _console() with pytest.raises(PickerUnavailableError): pick_run(RUNS, runs_dir="strix_runs", console=console, stdin=io.StringIO()) + + +def test_narrow_terminals_never_wrap_a_row() -> None: + console, _ = _console(width=48) + picker = ResumePicker(RUNS, console=console, runs_dir="strix_runs", now=NOW) + for line in picker.render(): + assert len(line.plain) <= 47, line.plain + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX key reader") +def test_posix_key_reader_handles_multibyte_and_arrows() -> None: + master, slave = os.openpty() + try: + with os.fdopen(slave, "r+b", buffering=0) as stream: + os.write(master, "\u00e9".encode()) + assert read_raw_key(stream) == "\u00e9" # type: ignore[arg-type] + os.write(master, b"\x1b[B") + assert read_raw_key(stream) == KEY_DOWN # type: ignore[arg-type] + os.write(master, b"\x1b[6~") + assert read_raw_key(stream) == "pagedown" # type: ignore[arg-type] + os.write(master, b"\r") + assert translate_key(read_raw_key(stream)) == KEY_ENTER # type: ignore[arg-type] + finally: + os.close(master) + + +def test_tall_terminals_still_show_a_short_window() -> None: + console, _ = _console(height=60) + runs = [ + _run(f"run_{index:02d}", f"https://host{index}.example", minutes_ago=index) + for index in range(40) + ] + picker = ResumePicker(runs, console=console, runs_dir="strix_runs", now=NOW) + plain = [line.plain for line in picker.render()] + assert sum(1 for line in plain if "run_" in line) == 8 + assert any("more below" in line for line in plain) diff --git a/tests/test_run_summaries.py b/tests/test_run_summaries.py index c7c1ee1f..30e6db29 100644 --- a/tests/test_run_summaries.py +++ b/tests/test_run_summaries.py @@ -12,6 +12,8 @@ from strix.report.runs import list_run_summaries if TYPE_CHECKING: from pathlib import Path + import pytest + def _write_run( base: Path, @@ -122,3 +124,18 @@ def test_runs_without_agent_state_or_with_a_broken_record_are_listed_not_resumab def test_missing_runs_dir_lists_nothing(tmp_path: Path) -> None: assert list_run_summaries(cwd=tmp_path) == [] + + +def test_blank_instruction_does_not_break_the_listing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + run_dir = tmp_path / "strix_runs" / "blank_0001" + run_dir.mkdir(parents=True) + (run_dir / "run.json").write_text( + json.dumps({"status": "completed", "instruction": " \n "}), encoding="utf-8" + ) + + [summary] = list_run_summaries() + + assert summary.target == ""