codeplain 0.2.4__py3-none-any.whl → 0.2.5__py3-none-any.whl

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 (41) hide show
  1. {codeplain-0.2.4.dist-info → codeplain-0.2.5.dist-info}/METADATA +3 -3
  2. {codeplain-0.2.4.dist-info → codeplain-0.2.5.dist-info}/RECORD +41 -41
  3. codeplain_REST_api.py +57 -37
  4. config/system_config.yaml +1 -16
  5. file_utils.py +5 -4
  6. git_utils.py +42 -7
  7. memory_management.py +1 -1
  8. module_renderer.py +114 -0
  9. plain2code.py +87 -19
  10. plain2code_console.py +3 -5
  11. plain2code_exceptions.py +14 -4
  12. plain2code_logger.py +11 -5
  13. plain2code_utils.py +7 -5
  14. plain_file.py +1 -1
  15. plain_spec.py +22 -2
  16. render_machine/actions/create_dist.py +1 -1
  17. render_machine/actions/exit_with_error.py +1 -1
  18. render_machine/actions/prepare_testing_environment.py +1 -1
  19. render_machine/actions/render_conformance_tests.py +2 -4
  20. render_machine/actions/render_functional_requirement.py +6 -6
  21. render_machine/actions/run_conformance_tests.py +3 -2
  22. render_machine/actions/run_unit_tests.py +1 -1
  23. render_machine/render_context.py +3 -3
  24. render_machine/render_utils.py +14 -6
  25. standard_template_library/golang-console-app-template.plain +2 -2
  26. standard_template_library/python-console-app-template.plain +2 -2
  27. standard_template_library/typescript-react-app-template.plain +2 -2
  28. system_config.py +3 -11
  29. tests/test_imports.py +2 -2
  30. tests/test_plainfile.py +1 -1
  31. tests/test_plainfileparser.py +10 -10
  32. tests/test_plainspec.py +2 -2
  33. tui/components.py +311 -103
  34. tui/plain2code_tui.py +101 -52
  35. tui/state_handlers.py +94 -47
  36. tui/styles.css +240 -52
  37. tui/widget_helpers.py +43 -47
  38. {codeplain-0.2.4.dist-info → codeplain-0.2.5.dist-info}/WHEEL +0 -0
  39. {codeplain-0.2.4.dist-info → codeplain-0.2.5.dist-info}/entry_points.txt +0 -0
  40. {codeplain-0.2.4.dist-info → codeplain-0.2.5.dist-info}/licenses/LICENSE +0 -0
  41. /spinner.py → /tui/spinner.py +0 -0
tests/test_imports.py CHANGED
@@ -27,13 +27,13 @@ def test_diamond_imports(load_test_data, get_test_data_path):
27
27
  {"markdown": "- :Import1Def: is a definition in diamond_import_1."},
28
28
  {"markdown": "- :Import2Def: is a definition in diamond_import_2."},
29
29
  ],
30
- "technical specs": [
30
+ "implementation reqs": [
31
31
  {"markdown": "- :CommonImportDef: is used in diamond_import_common."},
32
32
  {"markdown": "- :Import1Def: is used in diamond_import_1."},
33
33
  {"markdown": "- :Import2Def: is used in diamond_import_2."},
34
34
  {"markdown": '- :MainExecutableFile: of :App: should be called "hello_world.py".'},
35
35
  ],
36
- "test specs": [
36
+ "test reqs": [
37
37
  {"markdown": "- :CommonImportDef: is tested in diamond_import_common."},
38
38
  {"markdown": "- :Import1Def: is tested in diamond_import_1."},
39
39
  {"markdown": "- :Import2Def: is tested in diamond_import_2."},
tests/test_plainfile.py CHANGED
@@ -107,7 +107,7 @@ def test_process_acceptance_tests_no_sections_direct_frs(mock_process_single):
107
107
  plain_source_tree = {
108
108
  plain_spec.FUNCTIONAL_REQUIREMENTS: mock_frs_top_level,
109
109
  "definitions": MagicMock(name="TopLevelDefs"),
110
- "technical specs": MagicMock(name="TopLevelNFRs"),
110
+ "implementation reqs": MagicMock(name="TopLevelNFRs"),
111
111
  }
112
112
 
113
113
  process_acceptance_tests(plain_source_tree)
@@ -19,7 +19,7 @@ def test_regular_plain_source(get_test_data_path):
19
19
  )
20
20
  assert plain_sections == {
21
21
  "definitions": [],
22
- "technical specs": [
22
+ "implementation reqs": [
23
23
  {"markdown": "- First non-functional requirement."},
24
24
  {"markdown": "- Second non-functional requirement."},
25
25
  ],
@@ -36,7 +36,7 @@ def test_unknown_section():
36
36
  with pytest.raises(
37
37
  Exception,
38
38
  match=re.escape(
39
- "Syntax error at line 3: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, technical specs, test specs, functional specs, acceptance tests"
39
+ "Syntax error at line 3: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests"
40
40
  ),
41
41
  ):
42
42
  plain_file.parse_plain_source(plain_source, {}, [], [], [])
@@ -76,7 +76,7 @@ def test_plain_file_parser_with_comments(get_test_data_path):
76
76
  )
77
77
  assert plain_sections == {
78
78
  "definitions": [],
79
- "technical specs": [{"markdown": "- Second non-functional requirement."}],
79
+ "implementation reqs": [{"markdown": "- Second non-functional requirement."}],
80
80
  "functional specs": [{"markdown": '- Display "hello, world"'}],
81
81
  }
82
82
 
@@ -88,7 +88,7 @@ def test_plain_file_parser_with_comments_indented(get_test_data_path):
88
88
  )
89
89
  assert plain_sections == {
90
90
  "definitions": [],
91
- "technical specs": [
91
+ "implementation reqs": [
92
92
  {"markdown": "- First non-functional requirement."},
93
93
  {"markdown": "- Second non-functional requirement."},
94
94
  ],
@@ -202,7 +202,7 @@ def test_indented_include_tags():
202
202
 
203
203
  - This is a definition.
204
204
 
205
- ***technical specs***
205
+ ***implementation reqs***
206
206
  - First non-functional requirement.
207
207
  - Second non-functional requirement.
208
208
 
@@ -230,7 +230,7 @@ def test_indented_include_tags():
230
230
 
231
231
  - This is a definition.
232
232
 
233
- ***technical specs***
233
+ ***implementation reqs***
234
234
  - First non-functional requirement.
235
235
  - Second non-functional requirement.
236
236
 
@@ -288,7 +288,7 @@ def test_code_variables(load_test_data, get_test_data_path):
288
288
 
289
289
  - :concept: is a concept.
290
290
 
291
- ***technical specs***
291
+ ***implementation reqs***
292
292
  - First non-functional requirement.
293
293
  - Second non-functional requirement.
294
294
 
@@ -303,7 +303,7 @@ def test_code_variables(load_test_data, get_test_data_path):
303
303
  _, plain_source, _ = plain_file.plain_file_parser("code_variables.plain", [get_test_data_path("data/templates")])
304
304
  expected_plain_source = {
305
305
  "definitions": [{"markdown": "- :concept: is a concept."}],
306
- "technical specs": [
306
+ "implementation reqs": [
307
307
  {"markdown": "- First non-functional requirement."},
308
308
  {"markdown": "- Second non-functional requirement."},
309
309
  ],
@@ -330,7 +330,7 @@ def test_code_variables(load_test_data, get_test_data_path):
330
330
 
331
331
  - :concept: is a concept.
332
332
 
333
- ***technical specs***
333
+ ***implementation reqs***
334
334
  - First non-functional requirement {keys[0]}.
335
335
  - Second non-functional requirement {keys[1]}.
336
336
 
@@ -345,7 +345,7 @@ def test_code_variables(load_test_data, get_test_data_path):
345
345
  _, plain_source, _ = plain_file.plain_file_parser("template_include.plain", [get_test_data_path("data/templates")])
346
346
  expected_plain_source = {
347
347
  "definitions": [{"markdown": "- :concept: is a concept."}],
348
- "technical specs": [
348
+ "implementation reqs": [
349
349
  {
350
350
  "markdown": "- First non-functional requirement {{ variable_name_1 }}.",
351
351
  "code_variables": [{"name": "variable_name_1", "value": "nice_1"}],
tests/test_plainspec.py CHANGED
@@ -61,7 +61,7 @@ def test_get_specifications_simple(get_test_data_path):
61
61
 
62
62
  assert specifications == {
63
63
  "definitions": [],
64
- "technical specs": ["- Simple non-functional requirement"],
65
- "test specs": [],
64
+ "implementation reqs": ["- Simple non-functional requirement"],
65
+ "test reqs": [],
66
66
  "functional specs": ["- Simple functional requirement"],
67
67
  }
tui/components.py CHANGED
@@ -1,18 +1,32 @@
1
+ import time
1
2
  from enum import Enum
3
+ from typing import Optional
2
4
 
3
5
  from textual.containers import Horizontal, Vertical, VerticalScroll
4
6
  from textual.message import Message
5
7
  from textual.widgets import Button, Static
6
8
 
7
- from spinner import Spinner
8
-
9
9
  from .models import Substate
10
+ from .spinner import Spinner
11
+
12
+
13
+ class CustomFooter(Horizontal):
14
+ """A custom footer with keyboard shortcuts and render ID."""
15
+
16
+ def __init__(self, render_id: str = "", **kwargs):
17
+ super().__init__(**kwargs)
18
+ self.render_id = render_id
19
+
20
+ def compose(self):
21
+ yield Static("ctrl+c: quit * ctrl+l: toggle logs", classes="custom-footer-text")
22
+ if self.render_id:
23
+ yield Static(f"render id: {self.render_id}", classes="custom-footer-render-id")
10
24
 
11
25
 
12
26
  class ScriptOutputType(str, Enum):
13
- UNIT_TEST_OUTPUT_TEXT = "Latest unit test script execution output: "
14
- CONFORMANCE_TEST_OUTPUT_TEXT = "Latest conformance tests script execution output: "
15
- TESTING_ENVIRONMENT_OUTPUT_TEXT = "Latest testing environment preparation script execution output: "
27
+ UNIT_TEST_OUTPUT_TEXT = "Unit tests output: "
28
+ CONFORMANCE_TEST_OUTPUT_TEXT = "Conformance tests output: "
29
+ TESTING_ENVIRONMENT_OUTPUT_TEXT = "Testing environment preparation execution output: "
16
30
 
17
31
  @staticmethod
18
32
  def get_max_label_width(active_types: list["ScriptOutputType"]) -> int:
@@ -28,17 +42,14 @@ class ScriptOutputType(str, Enum):
28
42
  return 0
29
43
  return max(len(script_type.value) for script_type in active_types)
30
44
 
31
- def get_padded_label(self, active_types: list["ScriptOutputType"]) -> str:
32
- """Get the label right-padded to align with other active labels.
33
-
34
- Args:
35
- active_types: List of ScriptOutputType enum members that are currently active
45
+ def get_padded_label(self) -> str:
46
+ """Get the label left-aligned (no padding).
36
47
 
37
48
  Returns:
38
- Right-aligned label padded to match the longest active label
49
+ Label without padding (left-aligned)
39
50
  """
40
- max_width = ScriptOutputType.get_max_label_width(active_types)
41
- return self.value.rjust(max_width)
51
+ # Return label as-is without padding for left alignment
52
+ return self.value
42
53
 
43
54
 
44
55
  class TUIComponents(str, Enum):
@@ -57,6 +68,9 @@ class TUIComponents(str, Enum):
57
68
  FRID_PROGRESS_REFACTORING = "frid-progress-refactoring"
58
69
  FRID_PROGRESS_CONFORMANCE_TEST = "frid-progress-conformance-test"
59
70
 
71
+ # Test scripts container widgets
72
+ TEST_SCRIPTS_CONTAINER = "test-scripts-container"
73
+
60
74
  CONTENT_SWITCHER = "content-switcher"
61
75
  DASHBOARD_VIEW = "dashboard-view"
62
76
  LOG_VIEW = "log-view"
@@ -64,6 +78,47 @@ class TUIComponents(str, Enum):
64
78
  LOG_FILTER = "log-filter"
65
79
 
66
80
 
81
+ class SubstateLine(Horizontal):
82
+ """A single substate row with an attached timer."""
83
+
84
+ def __init__(self, text: str, indent: str, **kwargs):
85
+ super().__init__(**kwargs)
86
+ self.text = text
87
+ self.indent = indent
88
+ self.start_time = time.monotonic()
89
+ self._line_widget: Static | None = None
90
+
91
+ def compose(self):
92
+ self._line_widget = Static(self._format_line(), classes="substate-line-text")
93
+ yield self._line_widget
94
+
95
+ def on_mount(self) -> None:
96
+ self._refresh_timer()
97
+ self.set_interval(1, self._refresh_timer)
98
+
99
+ def _format_timer(self) -> str:
100
+ elapsed = int(time.monotonic() - self.start_time)
101
+ if elapsed < 60:
102
+ return f"{elapsed}s"
103
+ minutes = elapsed // 60
104
+ seconds = elapsed % 60
105
+ if minutes < 60:
106
+ return f"{minutes}m {seconds}s"
107
+ hours = minutes // 60
108
+ return f"{hours}h {minutes % 60}m"
109
+
110
+ def _format_line(self) -> str:
111
+ timer = self._format_timer()
112
+ return f"{self.indent} └ {self.text} [#888888]({timer})[/#888888]"
113
+
114
+ def _refresh_timer(self) -> None:
115
+ try:
116
+ if self._line_widget:
117
+ self._line_widget.update(self._format_line())
118
+ except Exception:
119
+ pass
120
+
121
+
67
122
  class ProgressItem(Vertical):
68
123
  """A vertical container for a status, description, and substates."""
69
124
 
@@ -160,7 +215,7 @@ class ProgressItem(Vertical):
160
215
 
161
216
  for substate in substates:
162
217
  # Render the current substate
163
- substate_widget = Static(f"{indent} └ {substate.text}", classes="substate")
218
+ substate_widget = SubstateLine(substate.text, indent, classes="substate-row")
164
219
  await container.mount(substate_widget)
165
220
 
166
221
  # Recursively render children if they exist
@@ -176,6 +231,111 @@ class ProgressItem(Vertical):
176
231
  pass
177
232
 
178
233
 
234
+ class RenderingInfoBox(Vertical):
235
+ """Responsive container for module and functionality information."""
236
+
237
+ def __init__(self, **kwargs):
238
+ super().__init__(**kwargs)
239
+ self.module_text = ""
240
+ self.functionality_text = ""
241
+ self.module_widget: Static | None = None
242
+ self.functionality_widget: Static | None = None
243
+
244
+ def update_module(self, text: str) -> None:
245
+ """Update the module name display."""
246
+ self.module_text = text
247
+ self._refresh_content()
248
+
249
+ def update_functionality(self, text: str) -> None:
250
+ """Update the functionality text display."""
251
+ self.functionality_text = text
252
+ self._refresh_content()
253
+
254
+ def _refresh_content(self) -> None:
255
+ """Refresh text inside the box."""
256
+ if self.module_widget is not None:
257
+ self.module_widget.update(self.module_text or "")
258
+ if self.functionality_widget is not None:
259
+ self.functionality_widget.update(self.functionality_text or "")
260
+
261
+ def on_mount(self) -> None:
262
+ """Initialize default labels on mount."""
263
+ self.module_text = "Module: "
264
+ self.functionality_text = "Functionality:"
265
+ self._refresh_content()
266
+
267
+ def compose(self):
268
+ self.module_widget = Static(self.module_text, classes="rendering-info-row")
269
+ self.functionality_widget = Static(self.functionality_text, classes="rendering-info-row")
270
+ yield Static("module status", classes="rendering-info-title")
271
+ with Vertical(classes="rendering-info-box"):
272
+ yield self.module_widget
273
+ yield self.functionality_widget
274
+
275
+
276
+ class TestScriptsContainer(Vertical):
277
+ """Container with ASCII border for test script outputs."""
278
+
279
+ def __init__(
280
+ self,
281
+ show_unit_test: bool = True,
282
+ show_conformance_test: bool = True,
283
+ show_testing_env: bool = True,
284
+ **kwargs,
285
+ ):
286
+ super().__init__(**kwargs)
287
+ self.show_unit_test = show_unit_test
288
+ self.show_conformance_test = show_conformance_test
289
+ self.show_testing_env = show_testing_env
290
+ self.unit_test_text = ScriptOutputType.UNIT_TEST_OUTPUT_TEXT.value
291
+ self.conformance_test_text = ScriptOutputType.CONFORMANCE_TEST_OUTPUT_TEXT.value
292
+ self.testing_env_text = ScriptOutputType.TESTING_ENVIRONMENT_OUTPUT_TEXT.value
293
+ self.unit_widget: Static | None = None
294
+ self.conformance_widget: Static | None = None
295
+ self.testing_widget: Static | None = None
296
+
297
+ def update_unit_test(self, text: str) -> None:
298
+ """Update unit test output and refresh."""
299
+ self.unit_test_text = text
300
+ self._refresh_content()
301
+
302
+ def update_conformance_test(self, text: str) -> None:
303
+ """Update conformance test output and refresh."""
304
+ self.conformance_test_text = text
305
+ self._refresh_content()
306
+
307
+ def update_testing_env(self, text: str) -> None:
308
+ """Update testing env output and refresh."""
309
+ self.testing_env_text = text
310
+ self._refresh_content()
311
+
312
+ def _refresh_content(self) -> None:
313
+ """Refresh the test script rows."""
314
+ if self.unit_widget is not None:
315
+ self.unit_widget.update(self.unit_test_text)
316
+ self.unit_widget.display = self.show_unit_test
317
+ if self.conformance_widget is not None:
318
+ self.conformance_widget.update(self.conformance_test_text)
319
+ self.conformance_widget.display = self.show_conformance_test
320
+ if self.testing_widget is not None:
321
+ self.testing_widget.update(self.testing_env_text)
322
+ self.testing_widget.display = self.show_testing_env
323
+
324
+ def on_mount(self) -> None:
325
+ """Initialize the box on mount."""
326
+ self._refresh_content()
327
+
328
+ def compose(self):
329
+ yield Static("testing status", classes="test-scripts-title")
330
+ with Vertical(classes="test-scripts-box"):
331
+ self.unit_widget = Static(self.unit_test_text, classes="test-script-row")
332
+ self.conformance_widget = Static(self.conformance_test_text, classes="test-script-row")
333
+ self.testing_widget = Static(self.testing_env_text, classes="test-script-row")
334
+ yield self.unit_widget
335
+ yield self.conformance_widget
336
+ yield self.testing_widget
337
+
338
+
179
339
  class FRIDProgress(Vertical):
180
340
  """A widget to display the status of subcomponent tasks."""
181
341
 
@@ -185,16 +345,24 @@ class FRIDProgress(Vertical):
185
345
  REFACTORING_TEXT = "Refactoring"
186
346
  CONFORMANCE_TEST_VALIDATION_TEXT = "Conformance tests"
187
347
 
188
- RENDERING_MODULE_TEXT = "Rendering module: "
189
- RENDERING_FUNCTIONALITY_TEXT = "Rendering functionality:"
348
+ RENDERING_MODULE_TEXT = "Module: "
349
+ RENDERING_FUNCTIONALITY_TEXT = "Functionality:"
190
350
 
191
- def __init__(self, **kwargs):
351
+ def __init__(
352
+ self,
353
+ unittests_script: Optional[str],
354
+ conformance_tests_script: Optional[str],
355
+ **kwargs,
356
+ ):
192
357
  super().__init__(**kwargs)
358
+ self.unittests_script = unittests_script
359
+ self.conformance_tests_script = conformance_tests_script
193
360
 
194
361
  def update_fr_text(self, text: str) -> None:
195
362
  try:
196
- widget = self.query_one(f"#{TUIComponents.FRID_PROGRESS_HEADER.value}", Static)
197
- widget.update(text)
363
+ # Update the rendering info box instead
364
+ info_box = self.query_one(RenderingInfoBox)
365
+ info_box.update_functionality(text)
198
366
  except Exception:
199
367
  pass
200
368
 
@@ -209,45 +377,31 @@ class FRIDProgress(Vertical):
209
377
  self.border_title = "FRID Progress"
210
378
 
211
379
  def compose(self):
212
- yield Static(self.RENDERING_MODULE_TEXT, id=TUIComponents.RENDER_MODULE_NAME_WIDGET.value)
213
- yield Static(self.RENDERING_FUNCTIONALITY_TEXT, id=TUIComponents.FRID_PROGRESS_HEADER.value)
214
- yield ProgressItem(
215
- self.IMPLEMENTING_FUNCTIONALITY_TEXT,
216
- id=TUIComponents.FRID_PROGRESS_RENDER_FR.value,
217
- )
218
- yield ProgressItem(
219
- self.UNIT_TEST_VALIDATION_TEXT,
220
- id=TUIComponents.FRID_PROGRESS_UNIT_TEST.value,
221
- )
222
- yield ProgressItem(
223
- self.REFACTORING_TEXT,
224
- id=TUIComponents.FRID_PROGRESS_REFACTORING.value,
225
- )
226
- yield ProgressItem(
227
- self.CONFORMANCE_TEST_VALIDATION_TEXT,
228
- id=TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value,
229
- )
230
-
231
-
232
- class ClickableArrow(Static):
233
- """A clickable arrow widget for expanding/collapsing logs."""
234
-
235
- def __init__(self, is_expanded: bool = False, **kwargs):
236
- arrow = "" if is_expanded else ""
237
- super().__init__(arrow, **kwargs)
238
- self.classes = "log-arrow"
239
-
240
- def on_click(self, event):
241
- """Notify parent to toggle expansion."""
242
- # Bubble up to parent CollapsibleLogEntry
243
- event.stop()
244
- parent = self.parent
245
- if isinstance(parent, CollapsibleLogEntry):
246
- parent.toggle_expansion()
247
-
248
-
249
- class CollapsibleLogEntry(Horizontal):
250
- """A single collapsible log entry that can be clicked to expand/collapse."""
380
+ yield RenderingInfoBox()
381
+ yield Static("rendering status", classes="frid-state-machine-title")
382
+ with Vertical(classes="frid-state-machine-box"):
383
+ yield ProgressItem(
384
+ self.IMPLEMENTING_FUNCTIONALITY_TEXT,
385
+ id=TUIComponents.FRID_PROGRESS_RENDER_FR.value,
386
+ )
387
+ if self.unittests_script is not None:
388
+ yield ProgressItem(
389
+ self.UNIT_TEST_VALIDATION_TEXT,
390
+ id=TUIComponents.FRID_PROGRESS_UNIT_TEST.value,
391
+ )
392
+ yield ProgressItem(
393
+ self.REFACTORING_TEXT,
394
+ id=TUIComponents.FRID_PROGRESS_REFACTORING.value,
395
+ )
396
+ if self.conformance_tests_script is not None:
397
+ yield ProgressItem(
398
+ self.CONFORMANCE_TEST_VALIDATION_TEXT,
399
+ id=TUIComponents.FRID_PROGRESS_CONFORMANCE_TEST.value,
400
+ )
401
+
402
+
403
+ class LogEntry(Vertical):
404
+ """A single log entry that can be expanded to show details."""
251
405
 
252
406
  def __init__(self, logger_name: str, level: str, message: str, timestamp: str = "", **kwargs):
253
407
  super().__init__(**kwargs)
@@ -259,36 +413,58 @@ class CollapsibleLogEntry(Horizontal):
259
413
  self.classes = f"log-entry log-{level.lower()}"
260
414
 
261
415
  def compose(self):
262
- # Start with collapsed view - arrow and full message text side by side
263
- yield ClickableArrow(id="arrow")
264
- yield Static(self.message, classes="log-summary")
265
-
266
- def toggle_expansion(self):
267
- """Toggle the expansion state of this log entry."""
268
- self.is_expanded = not self.is_expanded
269
- self.call_later(self.refresh_display)
270
-
271
- async def refresh_display(self):
272
- """Update the display based on expanded state."""
273
- # Remove all children
274
- await self.remove_children()
275
-
276
- if self.is_expanded:
277
- # Expanded view: shows arrow, full message, and structured metadata
278
- await self.mount(ClickableArrow(is_expanded=True))
279
- expanded_text = f"{self.message}\n"
280
- expanded_text += f" Level: {self.level}\n"
281
- expanded_text += f" Location: {self.logger_name}\n"
282
- expanded_text += f" Time: {self.timestamp}"
283
- await self.mount(Static(expanded_text, classes="log-expanded"))
284
- else:
285
- # Collapsed view: shows arrow and full message only
286
- await self.mount(ClickableArrow(is_expanded=False))
287
- await self.mount(Static(self.message, classes="log-summary"))
416
+ # Main row: just the message with a clickable indicator
417
+ with Horizontal(classes="log-main-row"):
418
+ # Expandable indicator
419
+ yield Static("▶", classes="log-expand-indicator")
420
+
421
+ time_part = self.timestamp.split()[-1] if self.timestamp else ""
422
+ time_prefix = f"[#888888][{time_part}][/#888888] " if time_part else ""
423
+ indent_spaces = len(f"[{time_part}] ") if time_part else 0
424
+
425
+ message_body = self.message
426
+ if any(
427
+ keyword in self.message.lower()
428
+ for keyword in ["completed", "success", "successfully", "passed", "done", "✓"]
429
+ ):
430
+ message_body = f"[green]✓[/green] {message_body}"
431
+
432
+ if indent_spaces and "\n" in message_body:
433
+ message_body = message_body.replace("\n", "\n" + " " * indent_spaces)
434
+
435
+ yield Static(f"{time_prefix}{message_body}", classes="log-col-message")
436
+
437
+ # Details row (hidden by default) - vertical layout
438
+ with Vertical(id=f"log-details-{id(self)}", classes="log-details-row"):
439
+ location = self.logger_name
440
+ if len(location) > 20:
441
+ location = location[:20] + "..."
442
+ yield Static(f" [#888]level:[/#888] {self.level}", classes="log-details-text")
443
+ yield Static(f" [#888]location:[/#888] {location}", classes="log-details-text")
444
+
445
+ def on_mount(self) -> None:
446
+ """Hide details on mount."""
447
+ try:
448
+ details = self.query_one(f"#log-details-{id(self)}")
449
+ details.display = False
450
+ except Exception:
451
+ pass
452
+
453
+ def on_click(self) -> None:
454
+ """Toggle details visibility on click."""
455
+ try:
456
+ details = self.query_one(f"#log-details-{id(self)}")
457
+ indicator = self.query_one(".log-expand-indicator", Static)
458
+
459
+ self.is_expanded = not self.is_expanded
460
+ details.display = self.is_expanded
461
+ indicator.update("▼" if self.is_expanded else "▶")
462
+ except Exception:
463
+ pass
288
464
 
289
465
 
290
466
  class StructuredLogView(VerticalScroll):
291
- """A scrollable container for collapsible log entries."""
467
+ """A scrollable container for log entries displayed as a table."""
292
468
 
293
469
  # Log level hierarchy (lower number = lower priority)
294
470
  LOG_LEVELS = {
@@ -296,7 +472,6 @@ class StructuredLogView(VerticalScroll):
296
472
  "INFO": 1,
297
473
  "WARNING": 2,
298
474
  "ERROR": 3,
299
- "CRITICAL": 4,
300
475
  }
301
476
 
302
477
  def __init__(self, **kwargs):
@@ -311,7 +486,17 @@ class StructuredLogView(VerticalScroll):
311
486
 
312
487
  async def add_log(self, logger_name: str, level: str, message: str, timestamp: str = ""):
313
488
  """Add a new log entry."""
314
- entry = CollapsibleLogEntry(logger_name, level, message, timestamp)
489
+ # Check if this is a success message that should have spacing before it
490
+ is_success_message = any(
491
+ keyword in message.lower() for keyword in ["completed", "success", "successfully", "passed", "done", "✓"]
492
+ )
493
+
494
+ # Add empty line before success messages
495
+ if is_success_message:
496
+ spacer = Static("", classes="log-spacer")
497
+ await self.mount(spacer)
498
+
499
+ entry = LogEntry(logger_name, level, message, timestamp)
315
500
 
316
501
  # Only show if level is >= minimum level
317
502
  if not self._should_show_log(level):
@@ -326,7 +511,7 @@ class StructuredLogView(VerticalScroll):
326
511
  self.min_level = min_level
327
512
 
328
513
  # Update visibility of all existing log entries
329
- for entry in self.query(CollapsibleLogEntry):
514
+ for entry in self.query(LogEntry):
330
515
  entry.display = self._should_show_log(entry.level)
331
516
 
332
517
 
@@ -341,17 +526,34 @@ class LogFilterChanged(Message):
341
526
  class LogLevelFilter(Horizontal):
342
527
  """Filter logs by minimum level with buttons."""
343
528
 
344
- LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
529
+ LEVELS = ["debug", "info", "warning", "error"]
530
+
531
+ # Make the widget focusable to receive keyboard events
532
+ can_focus = True
345
533
 
346
534
  def __init__(self, **kwargs):
347
535
  super().__init__(**kwargs)
348
- self.current_level = "DEBUG"
536
+ self.current_level = "INFO"
537
+ self.current_index = self.LEVELS.index(self.current_level.lower())
349
538
 
350
539
  def compose(self):
351
- yield Static("Level: ", classes="filter-label")
352
- for level in self.LEVELS:
353
- variant = "primary" if level == self.current_level else "default"
354
- yield Button(level, id=f"filter-{level.lower()}", variant=variant, classes="filter-button") # type: ignore[arg-type]
540
+ yield Static("level: ", classes="filter-label")
541
+ with Horizontal(classes="filter-buttons-container"):
542
+ for level in self.LEVELS:
543
+ variant = "primary" if level.upper() == self.current_level else "default"
544
+ btn = Button(level.upper(), id=f"filter-{level.lower()}", variant=variant, classes="filter-button") # type: ignore[arg-type]
545
+ btn.can_focus = False # Prevent buttons from receiving focus
546
+ yield btn
547
+
548
+ def on_key(self, event):
549
+ """Handle tab key to cycle through levels."""
550
+ if event.key == "tab":
551
+ # Move to next level
552
+ self.current_index = (self.current_index + 1) % len(self.LEVELS)
553
+ new_level = self.LEVELS[self.current_index].upper()
554
+ self._update_level(new_level)
555
+ event.prevent_default()
556
+ event.stop()
355
557
 
356
558
  def on_button_pressed(self, event):
357
559
  """Handle level button press."""
@@ -359,14 +561,20 @@ class LogLevelFilter(Horizontal):
359
561
  button_id = event.button.id
360
562
  if button_id and button_id.startswith("filter-"):
361
563
  level = button_id.replace("filter-", "").upper()
362
- self.current_level = level
564
+ self.current_index = self.LEVELS.index(level.lower())
565
+ self._update_level(level)
363
566
 
364
- # Update button variants
365
- for btn in self.query(Button):
366
- if btn.id == f"filter-{level.lower()}":
367
- btn.variant = "primary"
368
- else:
369
- btn.variant = "default"
567
+ def _update_level(self, level: str):
568
+ """Update the current level and button states."""
569
+ self.current_level = level
570
+
571
+ # Update button variants
572
+ for btn in self.query(Button):
573
+ if btn.id == f"filter-{level.lower()}":
574
+ btn.variant = "primary"
575
+ else:
576
+ btn.variant = "default"
577
+ btn.refresh() # Force immediate visual update
370
578
 
371
- # Notify parent to refresh log visibility
372
- self.post_message(LogFilterChanged(level))
579
+ # Notify parent to refresh log visibility
580
+ self.post_message(LogFilterChanged(level))