Skip to content

fix: Stabilize 0.2.3 UX Runtime Edge Cases#118

Merged
SSobol77 merged 1 commit into
mainfrom
hotfix/0.2.3-ux-runtime-stabilization
Jul 7, 2026
Merged

fix: Stabilize 0.2.3 UX Runtime Edge Cases#118
SSobol77 merged 1 commit into
mainfrom
hotfix/0.2.3-ux-runtime-stabilization

Conversation

@SSobol77

@SSobol77 SSobol77 commented Jul 7, 2026

Copy link
Copy Markdown
Owner

This PR implements the ECLI 0.2.3 UX/runtime stabilization hotfix.

Fixes:

  • prevents startup terminal-buffer garbage from being inserted into the editor
  • adds binary and large-file preflight gates before normal open/decode flow
  • fixes --plan-preview log path resolution so installed mode uses ~/.config/ecli/logs instead of package/library paths
  • opens nonexistent files as clean new buffers without touching disk before save
  • makes permission/read errors sticky so they remain visible and preserve the current buffer

Additional regression fixes:

  • open_or_create no longer probes nonexistent method signatures or pre-touches files
  • sticky-status clearing is safe for lightweight/composed test doubles
  • startup paste guard tests use bounded getch side effects to avoid unbounded drain loops

Validation:

  • make clean-logs
  • uv run ruff check src tests
  • uv run ruff format --check src tests
  • uv run python scripts/check_runtime_imports.py
  • uv run pytest -q tests/core
  • uv run pytest -q tests/ui
  • uv run pytest -q tests/characterization
  • uv run pytest -q tests/extensions tests/packaging tests/docs
  • uv run pytest -q tests/utils tests/core tests/extensions tests/packaging tests/docs
  • uv run pytest -q tests/cli

Manual smoke:

  • startup-buffer injection + normal typing + save
  • binary file open
  • oversized file open
  • permission-denied open
  • --plan-preview path inspection

Evidence:

  • tests/core: 152 passed
  • tests/ui: 162 passed
  • tests/characterization: 15 passed
  • tests/extensions tests/packaging tests/docs: 701 passed, 4 expected skips
  • tests/utils tests/core tests/extensions tests/packaging tests/docs: 871 passed, 4 expected skips
  • tests/cli: 12 passed
  • fresh logs contain no Traceback, logging error, CRITICAL, or ERROR markers

Non-goals:

  • no F7 AI work
  • no F11 PySH / terminal-console work
  • no F10 File Browser changes
  • no TextMate tokenizer changes
  • no extension asset changes
  • no packaging contract changes
  • no agent-workspace file cleanup in this PR

Summary by CodeRabbit

  • New Features

    • Improved file opening to handle new, unreadable, large, and binary files more gracefully.
    • Added smarter default log-location handling for read-only diagnostics.
    • Prevented leftover terminal input from being mistaken for editor commands on startup.
  • Bug Fixes

    • Error messages can now stay visible until the next user action.
    • Opening missing files now creates a clean buffer without premature file creation.
    • Read-only CLI log paths now avoid invalid system locations.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR updates CLI logs-root default resolution and validation to support both repo-local and user-owned logs directories, adds file-open safety gates (binary/large-file detection) with sticky error status in the core editor, updates status bar precedence, and introduces a startup input-buffer guard in KeyBinder. Corresponding tests are added for each area.

Changes

CLI Logs Root Resolution

Layer / File(s) Summary
Effective logs root and validation
src/ecli/cli.py
Adds _effective_logs_root()/_user_logs_root() helpers, updates --logs-root default/help text, and expands _assert_under_repo_logs to allow both repo-local and user-owned logs paths via _safe_is_relative_to().
Logs root regression tests
tests/cli/test_phase1_service_cli.py
Adds tests verifying the resolved default logs root avoids site-packages, resolves to repo or user config logs, and that --plan-preview succeeds with the default logs root.

File-Open Safety Gates and Sticky Status

Layer / File(s) Summary
Safety constants and sticky status state
src/ecli/core/Ecli.py
Adds constants for large-file size, binary-sample size, and control-byte ratio thresholds; initializes _sticky_status editor state.
Binary detection and sticky status helpers
src/ecli/core/Ecli.py
Introduces _is_likely_binary(), _set_sticky_error_message(), and _clear_sticky_status().
open_file/open_or_create behavior changes
src/ecli/core/Ecli.py
open_or_create() delegates buffer creation to open_file(); nonexistent paths now create a clean buffer with a "new file" status; permission failures use sticky status; adds large-file and binary preflight checks; clears sticky status on key dispatch.
Status bar sticky precedence
src/ecli/ui/DrawScreen.py
Status bar now prefers _sticky_status, then status_message, then "Ready".
File-open safety tests
tests/core/test_file_open_safety.py
New test module covering binary detection, large-file/binary rejection, clean buffer creation for nonexistent paths, and sticky-status preservation on permission/read errors.

Startup Paste Guard

Layer / File(s) Summary
Startup guard implementation
src/ecli/ui/KeyBinder.py
Adds _startup_guard_active state and _flush_startup_input_buffer() helper; get_key_input() flushes buffered terminal bytes and returns curses.ERR on its first call.
Startup guard tests
tests/ui/test_startup_paste_guard.py
New test module with FakeEditor/FakeWindow doubles verifying guard activation, deactivation, byte-draining behavior, and normal key processing on subsequent calls.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KeyBinder
  participant Ecli
  participant DrawScreen

  KeyBinder->>Ecli: get_key_input first call flushes buffer, returns curses.ERR
  Ecli->>Ecli: open_file(path) checks size, binary sample
  alt permission error
    Ecli->>Ecli: _set_sticky_error_message()
  end
  Ecli->>Ecli: _handle_input_dispatch clears sticky status on key action
  Ecli->>DrawScreen: draw status bar using _sticky_status, status_message, or "Ready"
Loading

Compact metadata:

  • Related issues: Not specified
  • Related PRs: Not specified
  • Suggested labels: Not specified
  • Suggested reviewers: Not specified

A guard at the gate, a sticky note held,
Binary shadows and large files repelled,
Logs find their home, be it repo or user,
Status bars whisper what errors obscure,
Tests stand watch as the rabbit hops through. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the PR’s main theme: a hotfix to stabilize UX and runtime edge cases in 0.2.3.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/0.2.3-ux-runtime-stabilization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

sonarqubecloud Bot commented Jul 7, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ecli/cli.py`:
- Around line 329-335: The _safe_is_relative_to helper in cli.py duplicates
stdlib behavior and should be removed in favor of Path.is_relative_to, which is
available for the project’s Python 3.11 target. Update the call sites that use
_safe_is_relative_to to call the built-in Path.is_relative_to directly, and
delete the helper plus its outdated docstring note about Python 3.9+. Ensure any
related path checks in the CLI code continue to behave the same after the
replacement.

In `@src/ecli/core/Ecli.py`:
- Around line 5363-5388: The nonexistent-path buffer branch in Ecli.open should
reset syntax highlighting state instead of inheriting the previous file’s lexer.
In the block that sets up the new clean buffer, clear self._lexer and then call
self.detect_language() after self.filename is assigned so the new buffer gets
the correct language/highlighting rules before returning.
- Around line 5363-5388: Reset the file-origin state when creating a fresh
buffer for a nonexistent path in Ecli.open_file: this branch currently
initializes text, filename, modified, and history but leaves
_file_loaded_from_disk and _file_had_final_newline unchanged from the prior
file. Update that buffer setup so those flags are explicitly reset to the
correct defaults for a new unsaved file, alongside the existing new_buffer
initialization and logging.

In `@src/ecli/ui/KeyBinder.py`:
- Around line 169-191: The startup drain loop in _flush_startup_input_buffer can
block the UI thread indefinitely because it reads until curses.ERR with no upper
bound. Add a small iteration/byte cap to the while loop, following the bounded
patterns already used by _read_bracketed_paste and _complete_trailing_utf8, and
stop draining once that cap is reached even if input keeps arriving. Keep the
fix scoped to KeyBinder._flush_startup_input_buffer and preserve the existing
nodelay reset and debug logging behavior.
- Around line 169-191: The startup buffer drain in _flush_startup_input_buffer
currently discards every queued input, which can lose a KEY_RESIZE event
arriving during startup. Update the loop in
KeyBinder._flush_startup_input_buffer so it preserves KEY_RESIZE by requeueing
it for later handling instead of counting it as discarded, while still draining
and dropping all other pre-existing buffered bytes.

In `@tests/cli/test_phase1_service_cli.py`:
- Around line 270-279: The test is recomputing the repository logs path from
Path.cwd() instead of using the same repo-root source as _effective_logs_root(),
which can diverge depending on the working directory. Update
tests/cli/test_phase1_service_cli.py to anchor repo_logs by importing and using
_repo_logs_root() (the same helper used by _effective_logs_root() in
src/ecli/cli.py) and keep the existing assertions against in_repo and in_user
unchanged.

In `@tests/ui/test_startup_paste_guard.py`:
- Around line 219-230: The test in
test_startup_guard_deactivates_after_first_flush is tautological because it
manually sets _startup_guard_active to False after calling
_flush_startup_input_buffer and then asserts the same value. Rewrite it to
validate the actual effect of _flush_startup_input_buffer on the keybinder state
using make_keybinder and the _startup_guard_active flag, or remove this test if
get_key_input already covers the guard deactivation contract.
- Around line 300-324: The final assertion in
test_get_key_input_processes_normal_key_after_guard_fires is tautological
because kb._startup_guard_active is already False, so rewrite it to verify the
actual returned key behavior from kb.get_key_input. Use the concrete result from
the second call to assert that it matches the expected normal input path (or the
specific burst-drained outcome if that is what the test is meant to cover), and
remove the always-true fallback. Keep the focus on the behavior exercised by
get_key_input and _drain_text_burst rather than rechecking the guard flag.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fa0f71dc-bc60-430c-bd2e-9334f1d4fced

📥 Commits

Reviewing files that changed from the base of the PR and between eabf913 and b03a75e.

📒 Files selected for processing (7)
  • src/ecli/cli.py
  • src/ecli/core/Ecli.py
  • src/ecli/ui/DrawScreen.py
  • src/ecli/ui/KeyBinder.py
  • tests/cli/test_phase1_service_cli.py
  • tests/core/test_file_open_safety.py
  • tests/ui/test_startup_paste_guard.py

Comment thread src/ecli/cli.py
Comment on lines +329 to +335
def _safe_is_relative_to(path: Path, parent: Path) -> bool:
"""Return True when ``path`` is relative to ``parent`` (Python 3.9+)."""
try:
path.relative_to(parent)
return True
except ValueError:
return False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer the built-in Path.is_relative_to over a hand-rolled helper.

The project targets Python ≥ 3.11, where Path.is_relative_to() is available natively (the new tests in this PR already call it directly). This helper duplicates stdlib behavior, and the docstring's "Python 3.9+" note is misleading for a 3.11 target. Consider dropping the helper and calling the built-in.

♻️ Proposed simplification
     resolved = path.resolve(strict=False)
     allowed = [_repo_logs_root(), _user_logs_root()]
-    if not any(_safe_is_relative_to(resolved, root) for root in allowed):
+    if not any(resolved.is_relative_to(root) for root in allowed):
         raise ValueError(
             "logs root must be under the repository logs/ or ~/.config/ecli/logs/"
         )
-
-
-def _safe_is_relative_to(path: Path, parent: Path) -> bool:
-    """Return True when ``path`` is relative to ``parent`` (Python 3.9+)."""
-    try:
-        path.relative_to(parent)
-        return True
-    except ValueError:
-        return False

As per coding guidelines: "Use Python >= 3.11" and "prefer existing patterns and helpers in Python code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/cli.py` around lines 329 - 335, The _safe_is_relative_to helper in
cli.py duplicates stdlib behavior and should be removed in favor of
Path.is_relative_to, which is available for the project’s Python 3.11 target.
Update the call sites that use _safe_is_relative_to to call the built-in
Path.is_relative_to directly, and delete the helper plus its outdated docstring
note about Python 3.9+. Ensure any related path checks in the CLI code continue
to behave the same after the replacement.

Source: Coding guidelines

Comment thread src/ecli/core/Ecli.py
Comment on lines +5363 to 5388
# Nonexistent path → open a new clean buffer at that path.
# This matches vim-style behaviour: the file will be created
# on the first save. The buffer is clean (not modified).
self.text = [""]
self.filename = None
self.filename = actual_filename_to_open
self.clear_diagnostic_line_highlight()
self.modified = False
self.encoding = "utf-8"
self.history.clear()
self.history.add_action(
{
"type": "open_file_missing",
"attempted_path": actual_filename_to_open,
"type": "new_buffer",
"filename": actual_filename_to_open,
"content": [""],
"encoding": "utf-8",
}
)
self.set_initial_cursor_position()
self._set_status_message(
f"Error: File not found '{os.path.basename(actual_filename_to_open)}'"
f"New file: '{os.path.basename(actual_filename_to_open)}' (not yet saved)"
)
logging.warning(
f"Open file failed: file not found at '{actual_filename_to_open}'"
logging.info(
"New buffer created for nonexistent path: '%s'",
actual_filename_to_open,
)
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map relevant symbols in the file
ast-grep outline src/ecli/core/Ecli.py --view expanded || true

echo '--- SEARCH ---'
rg -n "apply_syntax_highlighting_with_pygments|detect_language|_lexer|new_buffer|nonexistent path|successful-open" src/ecli/core/Ecli.py

echo '--- TARGETED LINES AROUND CLAIM ---'
sed -n '5340,5415p' src/ecli/core/Ecli.py

echo '--- TARGETED LINES FOR HIGHLIGHTING LOGIC ---'
sed -n '1,220p' src/ecli/core/Ecli.py | sed -n '1,220p'

Repository: SSobol77/ecli

Length of output: 23741


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

path = Path("src/ecli/core/Ecli.py")
lines = path.read_text().splitlines()

def show(start, end):
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:5d}: {lines[i-1]}")

# Show the open-buffer branches around the cited lines.
show(5335, 5415)
print("\n--- detect_language references ---")
for i, line in enumerate(lines, 1):
    if "def detect_language" in line or "detect_language(" in line or "_lexer" in line:
        print(f"{i:5d}: {line}")
print("\n--- apply_syntax_highlighting_with_pygments ---")
for i, line in enumerate(lines, 1):
    if "def apply_syntax_highlighting_with_pygments" in line:
        show(i, i + 140)
        break
PY

Repository: SSobol77/ecli

Length of output: 14441


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

lines = Path("src/ecli/core/Ecli.py").read_text().splitlines()

def show(start, end):
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:5d}: {lines[i-1]}")

print("--- success-path reset ---")
show(5488, 5510)

print("\n--- new_file reset ---")
show(6398, 6432)
PY

Repository: SSobol77/ecli

Length of output: 2513


Reset syntax state for nonexistent-path buffers.
This branch should clear self._lexer and call self.detect_language() so a newly created buffer picks up the correct highlighting rules instead of reusing the previous file’s lexer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/core/Ecli.py` around lines 5363 - 5388, The nonexistent-path buffer
branch in Ecli.open should reset syntax highlighting state instead of inheriting
the previous file’s lexer. In the block that sets up the new clean buffer, clear
self._lexer and then call self.detect_language() after self.filename is assigned
so the new buffer gets the correct language/highlighting rules before returning.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and relevant symbols before reading slices.
git ls-files src/ecli/core/Ecli.py
rg -n "_file_loaded_from_disk|_file_had_final_newline|def _write_file|nonexistent path|New buffer created" src/ecli/core/Ecli.py

# Show the relevant region around the reported lines.
sed -n '5300,5425p' src/ecli/core/Ecli.py

# Show the write path and any initialization in nearby code.
sed -n '5480,5615p' src/ecli/core/Ecli.py

Repository: SSobol77/ecli

Length of output: 12098


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '5904,5985p' src/ecli/core/Ecli.py
printf '\n---\n'
sed -n '5455,5505p' src/ecli/core/Ecli.py
printf '\n---\n'
rg -n "new_buffer|_file_loaded_from_disk = False|_file_had_final_newline = False" src/ecli/core/Ecli.py

Repository: SSobol77/ecli

Length of output: 6267


Reset the file-origin flags for a fresh buffer. This branch still leaves _file_loaded_from_disk / _file_had_final_newline carrying over from the previous file, so the first save of a newly created nonexistent-path buffer can use the wrong newline behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/core/Ecli.py` around lines 5363 - 5388, Reset the file-origin state
when creating a fresh buffer for a nonexistent path in Ecli.open_file: this
branch currently initializes text, filename, modified, and history but leaves
_file_loaded_from_disk and _file_had_final_newline unchanged from the prior
file. Update that buffer setup so those flags are explicitly reset to the
correct defaults for a new unsaved file, alongside the existing new_buffer
initialization and logging.

Comment thread src/ecli/ui/KeyBinder.py
Comment on lines +169 to +191
def _flush_startup_input_buffer(self, target: Any) -> None:
"""Drain and discard any bytes already buffered in the terminal before ECLI opened.

Called exactly once (on the first get_key_input invocation) so that
residual shell output, type-ahead, or control sequences present in the
terminal input queue at startup are never replayed as user text.
"""
target.nodelay(True)
drained = 0
try:
while True:
ch = target.getch()
if ch == curses.ERR:
break
drained += 1
finally:
target.nodelay(False)
if drained:
logging.debug(
"startup guard: discarded %d pre-existing buffered terminal byte(s)",
drained,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unbounded drain loop can hang the UI thread on continuous input.

_flush_startup_input_buffer loops until getch() returns curses.ERR with no iteration cap. Every other buffered-read loop in this file bounds itself: _read_bracketed_paste caps at max_bytes, _complete_trailing_utf8 bounds by needed. If stdin/the terminal keeps delivering bytes continuously (e.g., piped input, a misbehaving terminal, or a CI harness), this loop never observes curses.ERR and spins indefinitely at startup, blocking the UI thread before the editor renders.

As per coding guidelines, **/*.py: "Do not introduce UI-thread blocking I/O in Python code" and "Keep changes scoped; prefer existing patterns and helpers in Python code".

🛠️ Proposed fix: cap the drain iterations
     def _flush_startup_input_buffer(self, target: Any) -> None:
         ...
         target.nodelay(True)
         drained = 0
+        max_drain = 65536  # hard cap to avoid spinning on a continuous stream
         try:
-            while True:
+            while drained < max_drain:
                 ch = target.getch()
                 if ch == curses.ERR:
                     break
                 drained += 1
         finally:
             target.nodelay(False)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _flush_startup_input_buffer(self, target: Any) -> None:
"""Drain and discard any bytes already buffered in the terminal before ECLI opened.
Called exactly once (on the first get_key_input invocation) so that
residual shell output, type-ahead, or control sequences present in the
terminal input queue at startup are never replayed as user text.
"""
target.nodelay(True)
drained = 0
try:
while True:
ch = target.getch()
if ch == curses.ERR:
break
drained += 1
finally:
target.nodelay(False)
if drained:
logging.debug(
"startup guard: discarded %d pre-existing buffered terminal byte(s)",
drained,
)
def _flush_startup_input_buffer(self, target: Any) -> None:
"""Drain and discard any bytes already buffered in the terminal before ECLI opened.
Called exactly once (on the first get_key_input invocation) so that
residual shell output, type-ahead, or control sequences present in the
terminal input queue at startup are never replayed as user text.
"""
target.nodelay(True)
drained = 0
max_drain = 65536 # hard cap to avoid spinning on a continuous stream
try:
while drained < max_drain:
ch = target.getch()
if ch == curses.ERR:
break
drained += 1
finally:
target.nodelay(False)
if drained:
logging.debug(
"startup guard: discarded %d pre-existing buffered terminal byte(s)",
drained,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/KeyBinder.py` around lines 169 - 191, The startup drain loop in
_flush_startup_input_buffer can block the UI thread indefinitely because it
reads until curses.ERR with no upper bound. Add a small iteration/byte cap to
the while loop, following the bounded patterns already used by
_read_bracketed_paste and _complete_trailing_utf8, and stop draining once that
cap is reached even if input keeps arriving. Keep the fix scoped to
KeyBinder._flush_startup_input_buffer and preserve the existing nodelay reset
and debug logging behavior.

Source: Coding guidelines


🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the file and inspect the relevant methods.
ast-grep outline src/ecli/ui/KeyBinder.py --view expanded || true

printf '\n--- relevant lines ---\n'
sed -n '1,260p' src/ecli/ui/KeyBinder.py | nl -ba | sed -n '120,240p'

printf '\n--- resize handling references ---\n'
rg -n "KEY_RESIZE|ungetch|_drain_text_burst|_flush_startup_input_buffer" src/ecli/ui/KeyBinder.py

Repository: SSobol77/ecli

Length of output: 1438


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the surrounding input-handling code paths that may consume KEY_RESIZE.
rg -n "def get_key_input|def _drain_text_burst|def _flush_startup_input_buffer|KEY_RESIZE|ungetch|getch\(" src/ecli/ui/KeyBinder.py

Repository: SSobol77/ecli

Length of output: 786


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the startup flush, burst drain, and input loop around the relevant lines.
sed -n '169,260p' src/ecli/ui/KeyBinder.py | cat -n
printf '\n---\n'
sed -n '958,1015p' src/ecli/ui/KeyBinder.py | cat -n
printf '\n---\n'
sed -n '900,930p' src/ecli/ui/KeyBinder.py | cat -n

Repository: SSobol77/ecli

Length of output: 9125


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the helper and call-site relationship, focusing on whether the flush
# window can re-enter the main input path before resize handling exists.
python3 - <<'PY'
from pathlib import Path

path = Path("src/ecli/ui/KeyBinder.py")
lines = path.read_text().splitlines()

for start, end in [(169, 260), (900, 930), (958, 1015)]:
    print(f"\n--- {start}-{end} ---")
    for i in range(start, min(end, len(lines)) + 1):
        print(f"{i:4d}: {lines[i-1]}")
PY

Repository: SSobol77/ecli

Length of output: 8986


Startup flush can drop KEY_RESIZE. _flush_startup_input_buffer() consumes every queued code on the first get_key_input() call, so a resize that lands during startup is lost before the normal KEY_RESIZE handler runs. Requeue that event instead of discarding it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ecli/ui/KeyBinder.py` around lines 169 - 191, The startup buffer drain in
_flush_startup_input_buffer currently discards every queued input, which can
lose a KEY_RESIZE event arriving during startup. Update the loop in
KeyBinder._flush_startup_input_buffer so it preserves KEY_RESIZE by requeueing
it for later handling instead of counting it as discarded, while still draining
and dropping all other pre-existing buffered bytes.

Comment on lines +270 to +279
repo_logs = (Path.cwd() / "logs").resolve(strict=False)
user_logs = home / ".config" / "ecli" / "logs"

root = _effective_logs_root().resolve(strict=False)

in_repo = root.is_relative_to(repo_logs) or str(root).startswith(str(repo_logs))
in_user = root.is_relative_to(user_logs) or str(root).startswith(str(user_logs))

assert in_repo or in_user, (
f"_effective_logs_root() is neither under repo logs nor ~/.config/ecli/logs: {root}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant test and implementation symbols.
rg -n "_effective_logs_root|_repo_logs_root|repo_logs|Path.cwd\(\)|resolve\(strict=False\)" tests/cli/test_phase1_service_cli.py ecli 2>/dev/null || true

# Show the relevant sections with line numbers.
python3 - <<'PY'
from pathlib import Path

paths = [
    Path("tests/cli/test_phase1_service_cli.py"),
]
for path in paths:
    if path.exists():
        print(f"\n### {path} ###")
        lines = path.read_text().splitlines()
        for start, end in [(240, 320)]:
            for i in range(start, min(end, len(lines)) + 1):
                print(f"{i:4d}: {lines[i-1]}")
PY

Repository: SSobol77/ecli

Length of output: 5317


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation of the log-root helpers.
rg -n "def _effective_logs_root|def _repo_logs_root|logs_root|user_config|site-packages|Path\\(__file__\\)" ecli tests/cli/test_phase1_service_cli.py

python3 - <<'PY'
from pathlib import Path

path = Path("ecli/cli.py")
if path.exists():
    lines = path.read_text().splitlines()
    for start, end in [(1, 220)]:
        for i in range(start, min(end, len(lines)) + 1):
            print(f"{i:4d}: {lines[i-1]}")
PY

Repository: SSobol77/ecli

Length of output: 2036


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether the repo root helper is file-based or cwd-based.
python3 - <<'PY'
from pathlib import Path
import re

for p in [Path("ecli/cli.py"), Path("tests/cli/test_phase1_service_cli.py")]:
    if p.exists():
        text = p.read_text()
        for name in ["_effective_logs_root", "_repo_logs_root"]:
            m = re.search(rf"def {name}\(\).*?(?=^def |\Z)", text, re.S | re.M)
            if m:
                print(f"\n### {p}::{name} ###")
                print(m.group(0))
PY

Repository: SSobol77/ecli

Length of output: 151


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the implementation of the helper under test.
python3 - <<'PY'
from pathlib import Path

path = Path("ecli/cli.py")
if not path.exists():
    print("ecli/cli.py not found")
    raise SystemExit(0)

lines = path.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if "_effective_logs_root" in line or "_repo_logs_root" in line:
        start = max(1, i - 25)
        end = min(len(lines), i + 80)
        print(f"\n### around line {i} ###")
        for j in range(start, end + 1):
            print(f"{j:4d}: {lines[j-1]}")
PY

Repository: SSobol77/ecli

Length of output: 173


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for any repo-root helper used by tests or CLI.
rg -n "_repo_root|_repo_logs_root|_effective_logs_root|Path.cwd\(\).*logs" ecli tests -g '!**/__pycache__/**'

Repository: SSobol77/ecli

Length of output: 4284


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

path = Path("ecli/cli.py")
if not path.exists():
    print("ecli/cli.py not found")
    raise SystemExit(0)

text = path.read_text()
for name in ["_effective_logs_root", "_repo_logs_root"]:
    m = re.search(rf"def {name}\(.*?(?=^def |\Z)", text, re.S | re.M)
    if m:
        print(f"\n### {name} ###")
        print(m.group(0))
PY

Repository: SSobol77/ecli

Length of output: 173


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find the package and the helper implementation file.
git ls-files | sed -n '1,200p'
printf '\n---\n'
fd -a 'cli.py' .
printf '\n---\n'
fd -a '_effective_logs_root' .

Repository: SSobol77/ecli

Length of output: 6489


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for path in [Path("src/ecli/cli.py"), Path("tests/cli/test_phase1_service_cli.py")]:
    print(f"\n### {path} ###")
    if not path.exists():
        print("missing")
        continue
    lines = path.read_text().splitlines()
    for i, line in enumerate(lines, 1):
        if "_effective_logs_root" in line or "_repo_logs_root" in line or "Path.cwd()" in line:
            start = max(1, i - 20)
            end = min(len(lines), i + 60)
            for j in range(start, end + 1):
                print(f"{j:4d}: {lines[j-1]}")
            print()
PY

Repository: SSobol77/ecli

Length of output: 50369


Anchor repo_logs to the same repo-root source as _effective_logs_root()
tests/cli/test_phase1_service_cli.py:270-279 uses Path.cwd(), but the implementation resolves the repo root from src/ecli/cli.py via Path(__file__).resolve().parents[2]. Import _repo_logs_root() here instead of recomputing the path from the current working directory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cli/test_phase1_service_cli.py` around lines 270 - 279, The test is
recomputing the repository logs path from Path.cwd() instead of using the same
repo-root source as _effective_logs_root(), which can diverge depending on the
working directory. Update tests/cli/test_phase1_service_cli.py to anchor
repo_logs by importing and using _repo_logs_root() (the same helper used by
_effective_logs_root() in src/ecli/cli.py) and keep the existing assertions
against in_repo and in_user unchanged.

Comment on lines +219 to +230
def test_startup_guard_deactivates_after_first_flush() -> None:
kb = make_keybinder()

window = MagicMock()
window.getch.return_value = curses.ERR # no buffered bytes

kb._flush_startup_input_buffer(window)

# Guard must be deactivated by the caller (get_key_input) after flushing.
# Here we deactivate manually to mirror that contract.
kb._startup_guard_active = False
assert kb._startup_guard_active is False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test doesn't validate real guard-deactivation behavior.

This test calls _flush_startup_input_buffer but never asserts its actual effect on _startup_guard_active. Instead, it manually sets kb._startup_guard_active = False (line 229) and then asserts that same value (line 230) — the assertion is tautologically true no matter what _flush_startup_input_buffer does, so the test cannot fail even if the implementation regresses. The real contract (guard deactivation is get_key_input's responsibility) is already covered by test_get_key_input_returns_err_on_first_call_when_guard_active.

As per coding guidelines, "A mock-based test is invalid if it replaces the behavior being claimed as fixed" and "Tests that only assert mock calls, duplicate old behavior, or do not represent real ECLI behavior must be removed or rewritten."

🧪 Proposed fix: assert the pre-override state instead
 def test_startup_guard_deactivates_after_first_flush() -> None:
     kb = make_keybinder()

     window = MagicMock()
     window.getch.return_value = curses.ERR  # no buffered bytes

     kb._flush_startup_input_buffer(window)

-    # Guard must be deactivated by the caller (get_key_input) after flushing.
-    # Here we deactivate manually to mirror that contract.
-    kb._startup_guard_active = False
-    assert kb._startup_guard_active is False
+    # _flush_startup_input_buffer itself must NOT toggle the guard;
+    # deactivation is get_key_input's responsibility.
+    assert kb._startup_guard_active is True
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_startup_guard_deactivates_after_first_flush() -> None:
kb = make_keybinder()
window = MagicMock()
window.getch.return_value = curses.ERR # no buffered bytes
kb._flush_startup_input_buffer(window)
# Guard must be deactivated by the caller (get_key_input) after flushing.
# Here we deactivate manually to mirror that contract.
kb._startup_guard_active = False
assert kb._startup_guard_active is False
def test_startup_guard_deactivates_after_first_flush() -> None:
kb = make_keybinder()
window = MagicMock()
window.getch.return_value = curses.ERR # no buffered bytes
kb._flush_startup_input_buffer(window)
# _flush_startup_input_buffer itself must NOT toggle the guard;
# deactivation is get_key_input's responsibility.
assert kb._startup_guard_active is True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ui/test_startup_paste_guard.py` around lines 219 - 230, The test in
test_startup_guard_deactivates_after_first_flush is tautological because it
manually sets _startup_guard_active to False after calling
_flush_startup_input_buffer and then asserts the same value. Rewrite it to
validate the actual effect of _flush_startup_input_buffer on the keybinder state
using make_keybinder and the _startup_guard_active flag, or remove this test if
get_key_input already covers the guard deactivation contract.

Source: Coding guidelines

Comment on lines +300 to +324
def test_get_key_input_processes_normal_key_after_guard_fires() -> None:
"""After the guard fires once, subsequent calls behave normally."""
kb = make_keybinder()

window = MagicMock()
# First call: guard fires, nothing in buffer → ERR
window.getch.return_value = curses.ERR
first = kb.get_key_input(window)
assert first == curses.ERR
assert kb._startup_guard_active is False

# Second call: guard is gone; a real keypress arrives. The single
# keystroke is followed by ERR so _drain_text_burst's non-blocking
# look-ahead terminates instead of looping forever on a fixed
# return_value.
window.getch.return_value = None
window.getch.side_effect = [ord("x"), curses.ERR]
# _drain_text_burst will be called; make nodelay a no-op
window.nodelay = MagicMock()
window.timeout = MagicMock()

second = kb.get_key_input(window)
# The result should be something other than the guard's ERR sentinel;
# it may be the character or a paste event if burst-drained.
assert second != curses.ERR or kb._startup_guard_active is False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tautological final assertion doesn't verify normal-key behavior.

kb._startup_guard_active is already False after the first call (asserted at line 309) and nothing re-activates it, so ... or kb._startup_guard_active is False is always True — the assertion passes regardless of what second is, defeating the stated purpose ("subsequent calls behave normally").

As per coding guidelines, "Tests that only assert mock calls, duplicate old behavior, or do not represent real ECLI behavior must be removed or rewritten."

🧪 Proposed fix: assert the concrete expected value
     second = kb.get_key_input(window)
-    # The result should be something other than the guard's ERR sentinel;
-    # it may be the character or a paste event if burst-drained.
-    assert second != curses.ERR or kb._startup_guard_active is False
+    # A single keystroke followed by ERR should be returned as-is (fast path),
+    # not swallowed as the guard's ERR sentinel.
+    assert second == ord("x")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_get_key_input_processes_normal_key_after_guard_fires() -> None:
"""After the guard fires once, subsequent calls behave normally."""
kb = make_keybinder()
window = MagicMock()
# First call: guard fires, nothing in buffer → ERR
window.getch.return_value = curses.ERR
first = kb.get_key_input(window)
assert first == curses.ERR
assert kb._startup_guard_active is False
# Second call: guard is gone; a real keypress arrives. The single
# keystroke is followed by ERR so _drain_text_burst's non-blocking
# look-ahead terminates instead of looping forever on a fixed
# return_value.
window.getch.return_value = None
window.getch.side_effect = [ord("x"), curses.ERR]
# _drain_text_burst will be called; make nodelay a no-op
window.nodelay = MagicMock()
window.timeout = MagicMock()
second = kb.get_key_input(window)
# The result should be something other than the guard's ERR sentinel;
# it may be the character or a paste event if burst-drained.
assert second != curses.ERR or kb._startup_guard_active is False
def test_get_key_input_processes_normal_key_after_guard_fires() -> None:
"""After the guard fires once, subsequent calls behave normally."""
kb = make_keybinder()
window = MagicMock()
# First call: guard fires, nothing in buffer → ERR
window.getch.return_value = curses.ERR
first = kb.get_key_input(window)
assert first == curses.ERR
assert kb._startup_guard_active is False
# Second call: guard is gone; a real keypress arrives. The single
# keystroke is followed by ERR so _drain_text_burst's non-blocking
# look-ahead terminates instead of looping forever on a fixed
# return_value.
window.getch.return_value = None
window.getch.side_effect = [ord("x"), curses.ERR]
# _drain_text_burst will be called; make nodelay a no-op
window.nodelay = MagicMock()
window.timeout = MagicMock()
second = kb.get_key_input(window)
# A single keystroke followed by ERR should be returned as-is (fast path),
# not swallowed as the guard's ERR sentinel.
assert second == ord("x")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ui/test_startup_paste_guard.py` around lines 300 - 324, The final
assertion in test_get_key_input_processes_normal_key_after_guard_fires is
tautological because kb._startup_guard_active is already False, so rewrite it to
verify the actual returned key behavior from kb.get_key_input. Use the concrete
result from the second call to assert that it matches the expected normal input
path (or the specific burst-drained outcome if that is what the test is meant to
cover), and remove the always-true fallback. Keep the focus on the behavior
exercised by get_key_input and _drain_text_burst rather than rechecking the
guard flag.

Source: Coding guidelines

@SSobol77
SSobol77 merged commit 7c4dadc into main Jul 7, 2026
7 checks passed
@SSobol77
SSobol77 deleted the hotfix/0.2.3-ux-runtime-stabilization branch July 7, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant