voidx-cli 3.5.2__tar.gz → 3.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/PKG-INFO +2 -2
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/pyproject.toml +3 -1
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_stream.py +40 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_status_activity.py +2 -2
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_status_layout.py +29 -1
- voidx_cli-3.6.0/tests/test_status_simplify.py +222 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_terminal_input.py +3 -7
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/__init__.py +1 -1
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/overlays.py +10 -7
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_status.py +115 -28
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/PKG-INFO +2 -2
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/SOURCES.txt +1 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/requires.txt +1 -1
- voidx_cli-3.6.0/voidx_cli.egg-info/top_level.txt +1 -0
- voidx_cli-3.5.2/voidx_cli.egg-info/top_level.txt +0 -4
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/setup.cfg +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/conftest.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_frame_advanced.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_frame_rendering.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_headless.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_input_advanced.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_input_handling.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_paste.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_tools.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_tree.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_paste_handling.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_startup.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_terminal_panels.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_integration.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_regression.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_unit.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/tui_helpers.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/activity.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/app.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/choice_mixin.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/clipboard_mixin.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/helpers.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/input.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/panels.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/parser.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_activity.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_frame.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_input.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_todo.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/renderer.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/state.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/terminal_mixin.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/text_prompt_mixin.py +0 -0
- {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/dependency_links.txt +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voidx-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.6.0
|
|
4
4
|
Summary: CLI / terminal UI frontend for voidx.
|
|
5
5
|
Author: chikhamx
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
|
-
Requires-Dist: voidx==3.
|
|
8
|
+
Requires-Dist: voidx==3.6.0
|
|
9
9
|
Requires-Dist: rich>=13.9.0
|
|
@@ -6,7 +6,7 @@ license = {text = "MIT"}
|
|
|
6
6
|
authors = [{name = "chikhamx"}]
|
|
7
7
|
requires-python = ">=3.11"
|
|
8
8
|
dependencies = [
|
|
9
|
-
"voidx==3.
|
|
9
|
+
"voidx==3.6.0",
|
|
10
10
|
"rich>=13.9.0",
|
|
11
11
|
]
|
|
12
12
|
|
|
@@ -19,3 +19,5 @@ version = {attr = "voidx_cli.__version__"}
|
|
|
19
19
|
|
|
20
20
|
[tool.setuptools.packages.find]
|
|
21
21
|
where = ["."]
|
|
22
|
+
include = ["voidx_cli*"]
|
|
23
|
+
exclude = ["build*", "tests*", "dist*"]
|
|
@@ -8,6 +8,7 @@ from rich.text import Text
|
|
|
8
8
|
|
|
9
9
|
from voidx.ui.output.dock import dock
|
|
10
10
|
from voidx.ui.output.dock.formatting import _text_from_line
|
|
11
|
+
from voidx.ui.output.console import StreamingRenderer
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def test_stream_reply_aligns_with_user_turn_start():
|
|
@@ -128,3 +129,42 @@ def test_thinking_stream_wraps_long_content_to_visual_lines():
|
|
|
128
129
|
test_dock.reset()
|
|
129
130
|
|
|
130
131
|
|
|
132
|
+
def test_streaming_renderer_commits_thinking_only_output():
|
|
133
|
+
test_dock = dock
|
|
134
|
+
test_dock.begin_capture()
|
|
135
|
+
try:
|
|
136
|
+
renderer = StreamingRenderer(Console(), stream_to_dock=True)
|
|
137
|
+
renderer.feed_thinking("checking tool permissions")
|
|
138
|
+
renderer.done()
|
|
139
|
+
|
|
140
|
+
rendered = "\n".join(_rich_plain(line) for line in test_dock.tree.render(100))
|
|
141
|
+
assert "checking tool permissions" not in rendered
|
|
142
|
+
thinking_nodes = [
|
|
143
|
+
node
|
|
144
|
+
for parent in test_dock.tree.root.children
|
|
145
|
+
for node in [parent, *parent.children]
|
|
146
|
+
if node.node_type == "assistant" and node.payload.get("phase") == "thinking"
|
|
147
|
+
]
|
|
148
|
+
assert thinking_nodes == []
|
|
149
|
+
finally:
|
|
150
|
+
test_dock.deactivate()
|
|
151
|
+
test_dock.reset()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_thinking_only_stream_not_flushed_to_scrollback():
|
|
155
|
+
test_dock = dock
|
|
156
|
+
test_dock.begin_capture()
|
|
157
|
+
try:
|
|
158
|
+
test_dock.start_turn("问题")
|
|
159
|
+
long_thinking = "\n".join(f"thinking line {i}" for i in range(10))
|
|
160
|
+
test_dock.set_stream(long_thinking, phase="thinking")
|
|
161
|
+
test_dock.commit_stream()
|
|
162
|
+
|
|
163
|
+
flush_limit = test_dock.safe_flush_line_count(100, 0)
|
|
164
|
+
lines = test_dock.tree.render(100)
|
|
165
|
+
flushable = "\n".join(_rich_plain(line) for line in lines[:flush_limit])
|
|
166
|
+
|
|
167
|
+
assert "thinking line" not in flushable
|
|
168
|
+
finally:
|
|
169
|
+
test_dock.deactivate()
|
|
170
|
+
test_dock.reset()
|
|
@@ -249,8 +249,8 @@ def test_busy_activity_compacts_many_permission_details(tmp_path, monkeypatch):
|
|
|
249
249
|
"3. replace",
|
|
250
250
|
" target: docs/specs/llm-error-optimization-2026-07-05.md",
|
|
251
251
|
" file_path: docs/specs/llm-error-optimization-2026-07-05.md",
|
|
252
|
-
" bounds: [{'line_no': 334, 'anchor': './python.
|
|
253
|
-
" new_string: ./python.
|
|
252
|
+
" bounds: [{'line_no': 334, 'anchor': './python.py -m pytest tests/test_tools/test_plan_checkpoint.py'}]",
|
|
253
|
+
" new_string: ./python.py -m pytest tests/test_tools/test_plan_checkpoint.py tests/test_tools/test_basic.py",
|
|
254
254
|
]
|
|
255
255
|
)
|
|
256
256
|
dock.record_status("permission:request", "Requesting", detail, stage="permission")
|
|
@@ -48,6 +48,35 @@ def test_todo_busy_and_choice_panel_render_once_in_full_frame(tmp_path, monkeypa
|
|
|
48
48
|
assert rendered.index("Todo:") < rendered.index("Working") < rendered.index("Intent?")
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def test_permission_choice_overlay_does_not_repeat_tool_details(tmp_path, monkeypatch):
|
|
52
|
+
monkeypatch.setattr("voidx_cli.render_activity.time.monotonic", lambda: 105.0)
|
|
53
|
+
tui = _tui(tmp_path)
|
|
54
|
+
tui._console = Console(file=None, force_terminal=True, width=100, height=24, _environ={})
|
|
55
|
+
tui._busy = True
|
|
56
|
+
tui._busy_started_at = 100.0
|
|
57
|
+
tui._active_choice = [
|
|
58
|
+
("Yes, always", "a", "Allow these tools for this session"),
|
|
59
|
+
("Yes", "y", "Allow this tool use once"),
|
|
60
|
+
("No", "n", "Deny these tools"),
|
|
61
|
+
]
|
|
62
|
+
tui._choice_prompt = "Allow tool use?"
|
|
63
|
+
tui._choice_selected = 0
|
|
64
|
+
tui._choice_details = [{"name": "bash", "pattern": "npm install lodash"}]
|
|
65
|
+
dock.begin_capture()
|
|
66
|
+
dock.record_status(
|
|
67
|
+
"permission:request",
|
|
68
|
+
"Requesting",
|
|
69
|
+
"1. bash\n command: npm install lodash",
|
|
70
|
+
stage="permission",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
rendered = "\n".join(_rich_plain(line) for line in _render_lines(tui, width=100))
|
|
74
|
+
|
|
75
|
+
assert rendered.count("npm install lodash") == 1
|
|
76
|
+
assert "bash: npm install lodash" not in rendered
|
|
77
|
+
assert "Yes, always" in rendered
|
|
78
|
+
|
|
79
|
+
|
|
51
80
|
def test_choice_selection_only_render_skips_todo_and_busy_lines(tmp_path, monkeypatch):
|
|
52
81
|
monkeypatch.setattr("voidx_cli.render_activity.time.monotonic", lambda: 105.0)
|
|
53
82
|
fake_stdout = _FakeStdout()
|
|
@@ -142,4 +171,3 @@ def test_busy_activity_tick_noops_without_rendered_frame(tmp_path, monkeypatch):
|
|
|
142
171
|
assert tui._render_busy_activity_tick() is False
|
|
143
172
|
assert "Cogitating" not in status.plain
|
|
144
173
|
|
|
145
|
-
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Tests for simplified status bar field formatting."""
|
|
2
|
+
|
|
3
|
+
from tui_helpers import * # noqa: F403
|
|
4
|
+
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from voidx.llm.usage import UsageStats
|
|
11
|
+
from voidx.ui.commands import COMMANDS
|
|
12
|
+
from voidx.ui.output.types import UiStatus
|
|
13
|
+
from voidx_cli import PureTui
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_status(tmp_path, **overrides):
|
|
17
|
+
defaults = dict(
|
|
18
|
+
provider="anthropic",
|
|
19
|
+
model="claude-sonnet-4-20250514",
|
|
20
|
+
workspace=str(tmp_path),
|
|
21
|
+
reasoning_effort="xhigh",
|
|
22
|
+
sandbox_label=lambda: "w-write",
|
|
23
|
+
approval_label=lambda: "on-fail",
|
|
24
|
+
approval_reviewer_label=lambda: "user",
|
|
25
|
+
interaction_mode=lambda: "auto",
|
|
26
|
+
debug=lambda: False,
|
|
27
|
+
goal_label=lambda: "",
|
|
28
|
+
active_workflows=lambda: [],
|
|
29
|
+
usage_stats=None,
|
|
30
|
+
context_limit=200000,
|
|
31
|
+
)
|
|
32
|
+
defaults.update(overrides)
|
|
33
|
+
return SimpleNamespace(**defaults)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _status_plain(tui, width=100):
|
|
37
|
+
text = tui._status_summary_text(width)
|
|
38
|
+
return text.plain
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _usage_stats() -> UsageStats:
|
|
42
|
+
stats = UsageStats()
|
|
43
|
+
stats.context_limit = 200_000
|
|
44
|
+
stats.total_input_tokens = 10_000
|
|
45
|
+
stats.total_output_tokens = 1_000
|
|
46
|
+
stats.begin_turn()
|
|
47
|
+
stats.update_context(46_300)
|
|
48
|
+
stats.total_input_tokens = 1_899_000
|
|
49
|
+
stats.total_output_tokens = 1_000
|
|
50
|
+
stats.total_cache_read_tokens = 399_000
|
|
51
|
+
stats.total_cache_metric_calls = 1
|
|
52
|
+
return stats
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── model segment: no provider prefix ──────────────────────────────
|
|
56
|
+
|
|
57
|
+
def test_model_segment_excludes_provider(tmp_path):
|
|
58
|
+
tui = PureTui(_make_status(tmp_path), COMMANDS)
|
|
59
|
+
plain = _status_plain(tui)
|
|
60
|
+
assert "anthropic/" not in plain
|
|
61
|
+
assert "claude-sonnet-4-20250514" in plain
|
|
62
|
+
assert "xhigh" in plain
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── policy segment: no permission_label ────────────────────────────
|
|
66
|
+
|
|
67
|
+
def test_policy_segment_excludes_permission_label(tmp_path):
|
|
68
|
+
status = _make_status(tmp_path)
|
|
69
|
+
tui = PureTui(status, COMMANDS)
|
|
70
|
+
plain = _status_plain(tui)
|
|
71
|
+
assert "default" not in plain.lower()
|
|
72
|
+
assert "w-write" in plain
|
|
73
|
+
assert "on-fail" in plain
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_policy_segment_includes_reviewer_when_not_user(tmp_path):
|
|
77
|
+
status = _make_status(
|
|
78
|
+
tmp_path,
|
|
79
|
+
approval_reviewer_label=lambda: "reviewer",
|
|
80
|
+
)
|
|
81
|
+
tui = PureTui(status, COMMANDS)
|
|
82
|
+
plain = _status_plain(tui)
|
|
83
|
+
assert "reviewer" in plain
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── state segment: no plan_mode, auto hidden ───────────────────────
|
|
87
|
+
|
|
88
|
+
def test_state_segment_hidden_when_auto(tmp_path):
|
|
89
|
+
tui = PureTui(_make_status(tmp_path, interaction_mode=lambda: "auto"), COMMANDS)
|
|
90
|
+
plain = _status_plain(tui)
|
|
91
|
+
# auto should not appear as a state segment
|
|
92
|
+
assert "| auto" not in plain
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_state_segment_shows_plan_without_duplication(tmp_path):
|
|
96
|
+
tui = PureTui(_make_status(tmp_path, interaction_mode=lambda: "plan"), COMMANDS)
|
|
97
|
+
plain = _status_plain(tui)
|
|
98
|
+
assert "plan" in plain
|
|
99
|
+
# Should not duplicate: "plan plan" is a bug
|
|
100
|
+
assert "plan plan" not in plain
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_state_segment_shows_debug(tmp_path):
|
|
104
|
+
tui = PureTui(_make_status(tmp_path, debug=lambda: True), COMMANDS)
|
|
105
|
+
plain = _status_plain(tui)
|
|
106
|
+
assert "debug" in plain
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── usage segment: compact format with total ───────────────────────
|
|
110
|
+
|
|
111
|
+
def test_usage_segment_compact_format(tmp_path):
|
|
112
|
+
stats = UsageStats()
|
|
113
|
+
stats.context_limit = 200_000
|
|
114
|
+
stats.total_input_tokens = 10_000
|
|
115
|
+
stats.total_output_tokens = 1_000
|
|
116
|
+
stats.begin_turn()
|
|
117
|
+
stats.update_context(10_000)
|
|
118
|
+
stats.total_input_tokens = 126_100
|
|
119
|
+
stats.total_output_tokens = 1_043
|
|
120
|
+
status = _make_status(tmp_path, usage_stats=stats)
|
|
121
|
+
tui = PureTui(status, COMMANDS)
|
|
122
|
+
plain = _status_plain(tui)
|
|
123
|
+
assert "10k/200k" in plain
|
|
124
|
+
assert plain.endswith("10k/200k -- 127.1k")
|
|
125
|
+
# No verbose labels
|
|
126
|
+
assert "ctx " not in plain
|
|
127
|
+
assert "cache " not in plain
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── goal_type / goal_awaiting: dead fields removed ─────────────────
|
|
131
|
+
|
|
132
|
+
def test_status_works_without_goal_type_field(tmp_path):
|
|
133
|
+
"""UiStatus should no longer have goal_type or goal_awaiting_approval fields."""
|
|
134
|
+
status = UiStatus(
|
|
135
|
+
model="m",
|
|
136
|
+
provider="p",
|
|
137
|
+
workspace=str(tmp_path),
|
|
138
|
+
session_title="t",
|
|
139
|
+
context_limit=200000,
|
|
140
|
+
debug=lambda: False,
|
|
141
|
+
plan_mode=lambda: False,
|
|
142
|
+
)
|
|
143
|
+
assert not hasattr(status, "goal_type")
|
|
144
|
+
assert not hasattr(status, "goal_awaiting_approval")
|
|
145
|
+
assert not hasattr(status, "permission_label")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── variant priority: workflow before usage at narrow widths ───────
|
|
149
|
+
|
|
150
|
+
def test_workflow_shown_at_narrow_width_with_long_model(tmp_path):
|
|
151
|
+
"""At 80 cols with a long model name, workflow should still appear."""
|
|
152
|
+
status = _make_status(
|
|
153
|
+
tmp_path,
|
|
154
|
+
active_workflows=lambda: ["verify"],
|
|
155
|
+
goal_label=lambda: "fix bug",
|
|
156
|
+
)
|
|
157
|
+
stats = UsageStats()
|
|
158
|
+
stats.context_limit = 200_000
|
|
159
|
+
stats.total_input_tokens = 10_000
|
|
160
|
+
stats.total_output_tokens = 1_000
|
|
161
|
+
stats.begin_turn()
|
|
162
|
+
stats.update_context(10_000)
|
|
163
|
+
stats.total_input_tokens = 126_100
|
|
164
|
+
stats.total_output_tokens = 1_043
|
|
165
|
+
status.usage_stats = stats
|
|
166
|
+
tui = PureTui(status, COMMANDS)
|
|
167
|
+
plain = _status_plain(tui, width=80)
|
|
168
|
+
assert "verify" in plain
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_usage_is_right_aligned_and_preserved_with_long_goal(tmp_path):
|
|
172
|
+
status = _make_status(
|
|
173
|
+
tmp_path,
|
|
174
|
+
active_workflows=lambda: ["重命名"],
|
|
175
|
+
goal_label=lambda: "将某个文件/模块重命名并移动到 file 目录下,需确认具体对象并同步更新 import 路径",
|
|
176
|
+
usage_stats=_usage_stats(),
|
|
177
|
+
)
|
|
178
|
+
tui = PureTui(status, COMMANDS)
|
|
179
|
+
width = 120
|
|
180
|
+
|
|
181
|
+
plain = _status_plain(tui, width=width)
|
|
182
|
+
|
|
183
|
+
assert plain.endswith("46.3k/200k 21% 1.9m")
|
|
184
|
+
assert "\n" not in plain
|
|
185
|
+
assert cell_len(plain) == width
|
|
186
|
+
assert "重命名" in plain
|
|
187
|
+
assert "…" in plain
|
|
188
|
+
assert "import 路径" not in plain
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_usage_survives_when_left_and_middle_are_too_wide(tmp_path):
|
|
192
|
+
status = _make_status(
|
|
193
|
+
tmp_path,
|
|
194
|
+
model="astron-code-latest",
|
|
195
|
+
reasoning_effort="xhigh",
|
|
196
|
+
active_workflows=lambda: ["workflow-with-a-very-long-name"],
|
|
197
|
+
goal_label=lambda: "x" * 200,
|
|
198
|
+
usage_stats=_usage_stats(),
|
|
199
|
+
)
|
|
200
|
+
tui = PureTui(status, COMMANDS)
|
|
201
|
+
width = 72
|
|
202
|
+
|
|
203
|
+
plain = _status_plain(tui, width=width)
|
|
204
|
+
|
|
205
|
+
assert plain.endswith("46.3k/200k 21% 1.9m")
|
|
206
|
+
assert "\n" not in plain
|
|
207
|
+
assert cell_len(plain) == width
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_pinned_usage_status_keeps_segment_styles(tmp_path):
|
|
211
|
+
status = _make_status(
|
|
212
|
+
tmp_path,
|
|
213
|
+
active_workflows=lambda: ["重命名"],
|
|
214
|
+
goal_label=lambda: "将某个文件/模块重命名并移动到 file 目录下,需确认具体对象并同步更新 import 路径",
|
|
215
|
+
usage_stats=_usage_stats(),
|
|
216
|
+
)
|
|
217
|
+
tui = PureTui(status, COMMANDS)
|
|
218
|
+
|
|
219
|
+
text = tui._status_summary_text(120)
|
|
220
|
+
|
|
221
|
+
assert "#56D4DD" in _styles_covering(text, "46.3k/200k 21% 1.9m")
|
|
222
|
+
assert "#C698F0" in _styles_covering(text, "…")
|
|
@@ -64,7 +64,7 @@ def test_input_cursor_position_accounts_for_status_line(tmp_path, monkeypatch):
|
|
|
64
64
|
|
|
65
65
|
fake_stdout = FakeStdout()
|
|
66
66
|
monkeypatch.setattr(sys, "stdout", fake_stdout)
|
|
67
|
-
status = SimpleNamespace(
|
|
67
|
+
status = SimpleNamespace(model="gpt", workspace=str(tmp_path))
|
|
68
68
|
tui = PureTui(status, COMMANDS)
|
|
69
69
|
tui._input_lines = ["现在"]
|
|
70
70
|
tui._cursor_row = 0
|
|
@@ -191,16 +191,13 @@ def test_command_panel_renders_below_input_with_bottom_rule(tmp_path):
|
|
|
191
191
|
|
|
192
192
|
def test_command_panel_keeps_dynamic_status_below_panel(tmp_path):
|
|
193
193
|
status = SimpleNamespace(
|
|
194
|
-
provider="mimo-token-plan",
|
|
195
194
|
model="mimo-v2.5-pro",
|
|
196
195
|
workspace=str(tmp_path),
|
|
197
196
|
reasoning_effort="xhigh",
|
|
198
|
-
permission_label=lambda: "accept edits",
|
|
199
197
|
sandbox_label=lambda: "w-write",
|
|
200
198
|
approval_label=lambda: "ask",
|
|
201
199
|
interaction_mode=lambda: "auto",
|
|
202
200
|
debug=lambda: True,
|
|
203
|
-
plan_mode=lambda: False,
|
|
204
201
|
)
|
|
205
202
|
tui = PureTui(status, [("/model", "Switch model"), ("/model new", "Create profile")])
|
|
206
203
|
tui._input_lines = ["/model"]
|
|
@@ -209,7 +206,7 @@ def test_command_panel_keeps_dynamic_status_below_panel(tmp_path):
|
|
|
209
206
|
|
|
210
207
|
lines = _render_lines(tui)
|
|
211
208
|
panel_index = next(i for i, line in enumerate(lines) if "/model new" in line)
|
|
212
|
-
status_index = next(i for i, line in enumerate(lines) if "mimo-
|
|
209
|
+
status_index = next(i for i, line in enumerate(lines) if "mimo-v2.5-pro" in line)
|
|
213
210
|
|
|
214
211
|
assert panel_index < status_index
|
|
215
212
|
assert "↑↓ select" not in lines[status_index]
|
|
@@ -219,7 +216,6 @@ def test_command_panel_keeps_dynamic_status_below_panel(tmp_path):
|
|
|
219
216
|
|
|
220
217
|
def test_pinned_todo_renders_above_input_and_status(tmp_path):
|
|
221
218
|
status = SimpleNamespace(
|
|
222
|
-
provider="mimo",
|
|
223
219
|
model="mimo-v2.5",
|
|
224
220
|
workspace=str(tmp_path),
|
|
225
221
|
)
|
|
@@ -247,7 +243,7 @@ def test_pinned_todo_renders_above_input_and_status(tmp_path):
|
|
|
247
243
|
transcript_index = next(i for i, line in enumerate(lines) if "transcript line" in line)
|
|
248
244
|
todo_index = next(i for i, line in enumerate(lines) if "Todo: 0/2 done" in line)
|
|
249
245
|
input_index = next(i for i, line in enumerate(lines) if line.strip() == "❯ hello")
|
|
250
|
-
status_index = next(i for i, line in enumerate(lines) if "mimo
|
|
246
|
+
status_index = next(i for i, line in enumerate(lines) if "mimo-v2.5" in line)
|
|
251
247
|
assert transcript_index < todo_index < input_index < status_index
|
|
252
248
|
|
|
253
249
|
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from voidx.ui.output.dock import active_permission_request_detail_text
|
|
6
|
+
|
|
5
7
|
from .helpers import _candidate_meta, _escape_markup
|
|
6
8
|
|
|
7
9
|
|
|
@@ -40,13 +42,14 @@ class _OverlayRendererMixin:
|
|
|
40
42
|
remaining = total - start - len(visible)
|
|
41
43
|
if remaining > 0:
|
|
42
44
|
result.append(f" [dim]... {remaining} below[/dim]")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
if not active_permission_request_detail_text():
|
|
46
|
+
for detail in self._choice_details[:8]:
|
|
47
|
+
name = _escape_markup(str(detail.get("name", "")))
|
|
48
|
+
pattern = _escape_markup(str(detail.get("pattern", "")))
|
|
49
|
+
if pattern:
|
|
50
|
+
result.append(f" [dim]{name}: {pattern}[/dim]")
|
|
51
|
+
elif name:
|
|
52
|
+
result.append(f" [dim]{name}[/dim]")
|
|
50
53
|
return result
|
|
51
54
|
|
|
52
55
|
def _render_panel_lines(self, width: int) -> list[str]:
|
|
@@ -34,18 +34,11 @@ _STATUS_STYLES = {
|
|
|
34
34
|
"goal": "#C698F0",
|
|
35
35
|
"separator": "#4B5563",
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
("model", "policy", "state"
|
|
39
|
-
("model", "policy", "state", "workflow", "usage"),
|
|
40
|
-
("model", "policy", "state", "workflow", "goal"),
|
|
41
|
-
("model", "policy", "state", "usage", "goal"),
|
|
42
|
-
("model", "policy", "workflow", "usage", "goal"),
|
|
43
|
-
("model", "policy", "state", "workflow"),
|
|
44
|
-
("model", "policy", "usage", "goal"),
|
|
45
|
-
("model", "policy", "usage"),
|
|
46
|
-
("model", "policy", "goal"),
|
|
37
|
+
_LEFT_VARIANTS = (
|
|
38
|
+
("model", "policy", "state"),
|
|
47
39
|
("model", "policy"),
|
|
48
40
|
("model",),
|
|
41
|
+
(),
|
|
49
42
|
)
|
|
50
43
|
|
|
51
44
|
_WORKFLOW_RAINBOW = ("#FF6B6B", "#FFD93D", "#6BCB77", "#4D96FF", "#B983FF")
|
|
@@ -89,19 +82,14 @@ class _StatusRendererMixin:
|
|
|
89
82
|
return self._status_text_from_segments(summary, selected)
|
|
90
83
|
|
|
91
84
|
def _status_segments(self, *, include_busy: bool) -> tuple[tuple, tuple[StatusSegment, ...]]:
|
|
92
|
-
provider = _safe_status_value(getattr(self.status, "provider", ""), "")
|
|
93
85
|
model = _safe_status_value(getattr(self.status, "model", ""), "")
|
|
94
86
|
effort = _safe_status_value(getattr(self.status, "reasoning_effort", ""), "")
|
|
95
|
-
permission = _call_status(getattr(self.status, "permission_label", None), "")
|
|
96
87
|
sandbox = _call_status(getattr(self.status, "sandbox_label", None), "")
|
|
97
88
|
approval = _call_status(getattr(self.status, "approval_label", None), "")
|
|
98
89
|
reviewer = _call_status(getattr(self.status, "approval_reviewer_label", None), "")
|
|
99
90
|
mode = _call_status(getattr(self.status, "interaction_mode", None), "")
|
|
100
|
-
plan = _call_bool(getattr(self.status, "plan_mode", None))
|
|
101
91
|
debug = _call_bool(getattr(self.status, "debug", None))
|
|
102
92
|
goal_label = _call_status(getattr(self.status, "goal_label", None), "")
|
|
103
|
-
goal_type = _call_status(getattr(self.status, "goal_type", None), "")
|
|
104
|
-
goal_awaiting = _call_bool(getattr(self.status, "goal_awaiting_approval", None))
|
|
105
93
|
active_workflows = _call_workflows(getattr(self.status, "active_workflows", None))
|
|
106
94
|
stats = getattr(self.status, "usage_stats", None)
|
|
107
95
|
context_limit = getattr(stats, "context_limit", None) or getattr(self.status, "context_limit", 0)
|
|
@@ -112,35 +100,30 @@ class _StatusRendererMixin:
|
|
|
112
100
|
getattr(stats, "cache_hit_rate", None) if stats is not None else None,
|
|
113
101
|
)
|
|
114
102
|
snapshot = (
|
|
115
|
-
provider,
|
|
116
103
|
model,
|
|
117
104
|
effort,
|
|
118
|
-
permission,
|
|
119
105
|
sandbox,
|
|
120
106
|
approval,
|
|
121
107
|
reviewer,
|
|
122
108
|
mode,
|
|
123
|
-
plan,
|
|
124
109
|
debug,
|
|
125
110
|
goal_label,
|
|
126
111
|
tuple(active_workflows),
|
|
127
112
|
self._busy,
|
|
128
113
|
stats_snapshot,
|
|
129
114
|
)
|
|
130
|
-
model_text =
|
|
115
|
+
model_text = model
|
|
131
116
|
if effort:
|
|
132
117
|
model_text = f"{model_text} {effort}"
|
|
133
118
|
|
|
134
|
-
policy_parts = [part for part in (
|
|
119
|
+
policy_parts = [part for part in (sandbox, approval) if part]
|
|
135
120
|
if reviewer and reviewer != "user":
|
|
136
121
|
policy_parts.append(reviewer)
|
|
137
122
|
policy_text = " ".join(policy_parts)
|
|
138
123
|
|
|
139
124
|
state_parts = []
|
|
140
|
-
if mode:
|
|
125
|
+
if mode and mode != "auto":
|
|
141
126
|
state_parts.append(mode)
|
|
142
|
-
if plan:
|
|
143
|
-
state_parts.append("plan")
|
|
144
127
|
if debug:
|
|
145
128
|
state_parts.append("debug")
|
|
146
129
|
state_text = " ".join(state_parts)
|
|
@@ -148,10 +131,10 @@ class _StatusRendererMixin:
|
|
|
148
131
|
usage_text = ""
|
|
149
132
|
if stats is not None:
|
|
150
133
|
usage_text = (
|
|
151
|
-
f"
|
|
134
|
+
f"{format_token_count(getattr(stats, 'context_tokens', 0))}/"
|
|
152
135
|
f"{format_token_count(context_limit)}"
|
|
153
|
-
f"
|
|
154
|
-
f"
|
|
136
|
+
f" {format_cache_hit_rate(stats)}"
|
|
137
|
+
f" {format_token_count(getattr(stats, 'total_tokens', 0))}"
|
|
155
138
|
)
|
|
156
139
|
|
|
157
140
|
goal_text = ""
|
|
@@ -185,8 +168,17 @@ class _StatusRendererMixin:
|
|
|
185
168
|
) -> tuple[str, tuple[StatusSegment, ...]]:
|
|
186
169
|
by_kind = {segment.kind: segment for segment in segments}
|
|
187
170
|
prefix_segments = (prefix,) if prefix is not None and prefix.text else ()
|
|
171
|
+
usage = by_kind.get("usage")
|
|
172
|
+
if usage is not None and prefix is None:
|
|
173
|
+
return self._select_pinned_usage_status_variant(width, by_kind, usage)
|
|
188
174
|
|
|
189
|
-
for variant in
|
|
175
|
+
for variant in _LEFT_VARIANTS + (
|
|
176
|
+
("model", "policy", "state", "workflow", "goal"),
|
|
177
|
+
("model", "policy", "workflow", "goal"),
|
|
178
|
+
("model", "policy", "state", "workflow"),
|
|
179
|
+
("model", "policy", "workflow"),
|
|
180
|
+
("model", "policy", "goal"),
|
|
181
|
+
):
|
|
190
182
|
selected = tuple(
|
|
191
183
|
by_kind[kind]
|
|
192
184
|
for kind in variant
|
|
@@ -207,10 +199,66 @@ class _StatusRendererMixin:
|
|
|
207
199
|
summary = _clip_cells(self._status_summary_from_segments(fallback), width)
|
|
208
200
|
return summary, ()
|
|
209
201
|
|
|
202
|
+
def _select_pinned_usage_status_variant(
|
|
203
|
+
self,
|
|
204
|
+
width: int,
|
|
205
|
+
by_kind: dict[str, StatusSegment],
|
|
206
|
+
usage: StatusSegment,
|
|
207
|
+
) -> tuple[str, tuple[StatusSegment, ...]]:
|
|
208
|
+
usage_text = usage.text
|
|
209
|
+
usage_width = cell_len(usage_text)
|
|
210
|
+
if width <= 0:
|
|
211
|
+
return "", ()
|
|
212
|
+
if usage_width >= width:
|
|
213
|
+
return _clip_cells(usage_text, width), ()
|
|
214
|
+
|
|
215
|
+
for left_variant in _LEFT_VARIANTS:
|
|
216
|
+
left = tuple(
|
|
217
|
+
by_kind[kind]
|
|
218
|
+
for kind in left_variant
|
|
219
|
+
if kind in by_kind
|
|
220
|
+
)
|
|
221
|
+
middle = tuple(
|
|
222
|
+
by_kind[kind]
|
|
223
|
+
for kind in ("workflow", "goal")
|
|
224
|
+
if kind in by_kind
|
|
225
|
+
)
|
|
226
|
+
summary = self._pinned_usage_summary(width, left, middle, usage_text)
|
|
227
|
+
if summary:
|
|
228
|
+
selected = left + middle + (usage,)
|
|
229
|
+
return summary, selected
|
|
230
|
+
|
|
231
|
+
return usage_text.rjust(width), (usage,)
|
|
232
|
+
|
|
210
233
|
@staticmethod
|
|
211
234
|
def _status_summary_from_segments(segments: tuple[StatusSegment, ...]) -> str:
|
|
212
235
|
return " " + " | ".join(segment.text for segment in segments if segment.text)
|
|
213
236
|
|
|
237
|
+
def _pinned_usage_summary(
|
|
238
|
+
self,
|
|
239
|
+
width: int,
|
|
240
|
+
left: tuple[StatusSegment, ...],
|
|
241
|
+
middle: tuple[StatusSegment, ...],
|
|
242
|
+
usage_text: str,
|
|
243
|
+
) -> str:
|
|
244
|
+
left_text = self._status_summary_from_segments(left) if left else ""
|
|
245
|
+
middle_text = " | ".join(segment.text for segment in middle if segment.text)
|
|
246
|
+
usage_width = cell_len(usage_text)
|
|
247
|
+
gap = 2
|
|
248
|
+
if left_text and cell_len(left_text) + gap + usage_width > width:
|
|
249
|
+
return ""
|
|
250
|
+
middle_separator = " | " if left_text and middle_text else ""
|
|
251
|
+
base_width = cell_len(left_text) + cell_len(middle_separator) + gap + usage_width
|
|
252
|
+
available_middle_width = max(0, width - base_width)
|
|
253
|
+
clipped_middle = _clip_cells(middle_text, available_middle_width) if middle_text else ""
|
|
254
|
+
left_and_middle = left_text
|
|
255
|
+
if clipped_middle:
|
|
256
|
+
left_and_middle = f"{left_and_middle}{middle_separator}{clipped_middle}" if left_and_middle else f" {clipped_middle}"
|
|
257
|
+
padding = width - cell_len(left_and_middle) - usage_width
|
|
258
|
+
if padding < gap:
|
|
259
|
+
return ""
|
|
260
|
+
return left_and_middle + (" " * padding) + usage_text
|
|
261
|
+
|
|
214
262
|
def _status_text_from_segments(
|
|
215
263
|
self,
|
|
216
264
|
summary: str,
|
|
@@ -221,7 +269,8 @@ class _StatusRendererMixin:
|
|
|
221
269
|
if not segments:
|
|
222
270
|
return Text(summary, style="#8F9BA8")
|
|
223
271
|
if summary != self._status_summary_from_segments(segments):
|
|
224
|
-
|
|
272
|
+
pinned = self._pinned_usage_text_from_segments(summary, segments)
|
|
273
|
+
return pinned if pinned is not None else Text(summary, style="#8F9BA8")
|
|
225
274
|
text = Text(" ")
|
|
226
275
|
appended = False
|
|
227
276
|
for segment in segments:
|
|
@@ -236,6 +285,44 @@ class _StatusRendererMixin:
|
|
|
236
285
|
appended = True
|
|
237
286
|
return text
|
|
238
287
|
|
|
288
|
+
def _pinned_usage_text_from_segments(
|
|
289
|
+
self,
|
|
290
|
+
summary: str,
|
|
291
|
+
segments: tuple[StatusSegment, ...],
|
|
292
|
+
) -> Text | None:
|
|
293
|
+
usage = next((segment for segment in segments if segment.kind == "usage"), None)
|
|
294
|
+
if usage is None or not summary.endswith(usage.text):
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
text = Text()
|
|
298
|
+
cursor = 0
|
|
299
|
+
for segment in segments:
|
|
300
|
+
if segment.kind == "usage":
|
|
301
|
+
continue
|
|
302
|
+
start = summary.find(segment.text, cursor)
|
|
303
|
+
if start < 0:
|
|
304
|
+
continue
|
|
305
|
+
if start > cursor:
|
|
306
|
+
text.append(summary[cursor:start], style=_STATUS_STYLES["separator"])
|
|
307
|
+
if segment.kind == "workflow":
|
|
308
|
+
_append_rainbow(text, segment.text)
|
|
309
|
+
else:
|
|
310
|
+
text.append(segment.text, style=_STATUS_STYLES.get(segment.kind, "#8F9BA8"))
|
|
311
|
+
cursor = start + len(segment.text)
|
|
312
|
+
|
|
313
|
+
usage_start = len(summary) - len(usage.text)
|
|
314
|
+
if usage_start > cursor:
|
|
315
|
+
middle = summary[cursor:usage_start]
|
|
316
|
+
goal = next((segment for segment in segments if segment.kind == "goal"), None)
|
|
317
|
+
if goal is not None and "…" in middle:
|
|
318
|
+
ellipsis_index = middle.index("…")
|
|
319
|
+
text.append(middle[:ellipsis_index], style=_STATUS_STYLES["separator"])
|
|
320
|
+
text.append(middle[ellipsis_index:], style=_STATUS_STYLES["goal"])
|
|
321
|
+
else:
|
|
322
|
+
text.append(middle, style=_STATUS_STYLES["separator"])
|
|
323
|
+
text.append(usage.text, style=_STATUS_STYLES["usage"])
|
|
324
|
+
return text
|
|
325
|
+
|
|
239
326
|
|
|
240
327
|
def _append_rainbow(text: Text, value: str) -> None:
|
|
241
328
|
color_index = 0
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voidx-cli
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.6.0
|
|
4
4
|
Summary: CLI / terminal UI frontend for voidx.
|
|
5
5
|
Author: chikhamx
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.11
|
|
8
|
-
Requires-Dist: voidx==3.
|
|
8
|
+
Requires-Dist: voidx==3.6.0
|
|
9
9
|
Requires-Dist: rich>=13.9.0
|
|
@@ -13,6 +13,7 @@ tests/test_paste_handling.py
|
|
|
13
13
|
tests/test_startup.py
|
|
14
14
|
tests/test_status_activity.py
|
|
15
15
|
tests/test_status_layout.py
|
|
16
|
+
tests/test_status_simplify.py
|
|
16
17
|
tests/test_terminal_input.py
|
|
17
18
|
tests/test_terminal_panels.py
|
|
18
19
|
tests/test_win32_paste_drain_integration.py
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
voidx==3.
|
|
1
|
+
voidx==3.6.0
|
|
2
2
|
rich>=13.9.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
voidx_cli
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|