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.
Files changed (49) hide show
  1. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/PKG-INFO +2 -2
  2. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/pyproject.toml +3 -1
  3. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_stream.py +40 -0
  4. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_status_activity.py +2 -2
  5. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_status_layout.py +29 -1
  6. voidx_cli-3.6.0/tests/test_status_simplify.py +222 -0
  7. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_terminal_input.py +3 -7
  8. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/__init__.py +1 -1
  9. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/overlays.py +10 -7
  10. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_status.py +115 -28
  11. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/PKG-INFO +2 -2
  12. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/SOURCES.txt +1 -0
  13. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli.egg-info/requires.txt +1 -1
  14. voidx_cli-3.6.0/voidx_cli.egg-info/top_level.txt +1 -0
  15. voidx_cli-3.5.2/voidx_cli.egg-info/top_level.txt +0 -4
  16. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/setup.cfg +0 -0
  17. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/conftest.py +0 -0
  18. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_frame_advanced.py +0 -0
  19. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_frame_rendering.py +0 -0
  20. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_headless.py +0 -0
  21. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_input_advanced.py +0 -0
  22. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_input_handling.py +0 -0
  23. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_paste.py +0 -0
  24. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_tools.py +0 -0
  25. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_output_tree.py +0 -0
  26. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_paste_handling.py +0 -0
  27. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_startup.py +0 -0
  28. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_terminal_panels.py +0 -0
  29. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_integration.py +0 -0
  30. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_regression.py +0 -0
  31. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/test_win32_paste_drain_unit.py +0 -0
  32. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/tests/tui_helpers.py +0 -0
  33. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/activity.py +0 -0
  34. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/app.py +0 -0
  35. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/choice_mixin.py +0 -0
  36. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/clipboard_mixin.py +0 -0
  37. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/helpers.py +0 -0
  38. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/input.py +0 -0
  39. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/panels.py +0 -0
  40. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/parser.py +0 -0
  41. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_activity.py +0 -0
  42. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_frame.py +0 -0
  43. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_input.py +0 -0
  44. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/render_todo.py +0 -0
  45. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/renderer.py +0 -0
  46. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/state.py +0 -0
  47. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/terminal_mixin.py +0 -0
  48. {voidx_cli-3.5.2 → voidx_cli-3.6.0}/voidx_cli/text_prompt_mixin.py +0 -0
  49. {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.5.2
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.5.2
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.5.2",
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.sh -m pytest tests/test_tools/test_plan_checkpoint.py'}]",
253
- " new_string: ./python.sh -m pytest tests/test_tools/test_plan_checkpoint.py tests/test_tools/test_basic.py",
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(provider="openai", model="gpt", workspace=str(tmp_path))
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-token-plan/mimo-v2.5-pro" in line)
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/mimo-v2.5" in line)
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,5 +2,5 @@ from .app import PureTui, _dump_transcript_log
2
2
  from .helpers import _ENTER_TERMINAL_SEQUENCE, _EXIT_TERMINAL_SEQUENCE, _rendered_row_count
3
3
 
4
4
 
5
- __version__ = "3.5.2"
5
+ __version__ = "3.6.0"
6
6
  __all__ = ["PureTui"]
@@ -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
- for detail in self._choice_details[:8]:
44
- name = _escape_markup(str(detail.get("name", "")))
45
- pattern = _escape_markup(str(detail.get("pattern", "")))
46
- if pattern:
47
- result.append(f" [dim]{name}: {pattern}[/dim]")
48
- elif name:
49
- result.append(f" [dim]{name}[/dim]")
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
- _STATUS_VARIANTS = (
38
- ("model", "policy", "state", "workflow", "usage", "goal"),
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 = "/".join(part for part in (provider, model) if part)
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 (permission, sandbox, approval) if part]
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"ctx {format_token_count(getattr(stats, 'context_tokens', 0))}/"
134
+ f"{format_token_count(getattr(stats, 'context_tokens', 0))}/"
152
135
  f"{format_token_count(context_limit)}"
153
- f" cache {format_cache_hit_rate(stats)}"
154
- f" total {format_token_count(getattr(stats, 'total_tokens', 0))}"
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 _STATUS_VARIANTS:
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
- return Text(summary, style="#8F9BA8")
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.5.2
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.5.2
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.5.2
1
+ voidx==3.6.0
2
2
  rich>=13.9.0
@@ -0,0 +1 @@
1
+ voidx_cli
@@ -1,4 +0,0 @@
1
- build
2
- dist
3
- tests
4
- voidx_cli
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes