labnotes 0.0.98__tar.gz → 0.0.100__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 (166) hide show
  1. {labnotes-0.0.98 → labnotes-0.0.100}/PKG-INFO +1 -1
  2. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/run_output.py +27 -0
  3. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/labnotes.css +56 -17
  4. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/new-note.js +0 -2
  5. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/note-edit.js +9 -16
  6. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/note-editor.js +113 -69
  7. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/run-output.js +29 -0
  8. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/open_tables.py +13 -0
  9. labnotes-0.0.100/src/labnotes/tables/read_blocks.py +17 -0
  10. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/editor_imports.html +1 -0
  11. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/run.html +2 -0
  12. labnotes-0.0.100/src/labnotes/tui/log_block.py +31 -0
  13. labnotes-0.0.100/src/labnotes/tui/log_blocks.py +55 -0
  14. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/run_app.py +5 -0
  15. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/run_screen.py +3 -0
  16. {labnotes-0.0.98 → labnotes-0.0.100}/.gitignore +0 -0
  17. {labnotes-0.0.98 → labnotes-0.0.100}/CHANGELOG.md +0 -0
  18. {labnotes-0.0.98 → labnotes-0.0.100}/MIGRATIONS.md +0 -0
  19. {labnotes-0.0.98 → labnotes-0.0.100}/README.md +0 -0
  20. {labnotes-0.0.98 → labnotes-0.0.100}/pyproject.toml +0 -0
  21. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/__main__.py +0 -0
  23. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/context.md +0 -0
  27. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/context_guide.py +0 -0
  28. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/count_groups.py +0 -0
  29. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/count_runs.py +0 -0
  30. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/create_app.py +0 -0
  31. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/create_experiment.py +0 -0
  32. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/create_note.py +0 -0
  33. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/__init__.py +0 -0
  34. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/control_run.py +0 -0
  35. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/daemon_app.py +0 -0
  36. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/daemon_client.py +0 -0
  37. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/daemon_running.py +0 -0
  38. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/daemon_server.py +0 -0
  39. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/describe_status.py +0 -0
  40. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/ensure_daemon.py +0 -0
  41. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/exit_when_idle.py +0 -0
  42. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/remote_start.py +0 -0
  43. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/run_daemon.py +0 -0
  44. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/run_logs.py +0 -0
  45. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/serve_daemon.py +0 -0
  46. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/socket_path.py +0 -0
  47. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/stop_runs.py +0 -0
  48. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/watch_daemon.py +0 -0
  49. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon/watch_status.py +0 -0
  50. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/daemon_command.py +0 -0
  51. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/data_html.py +0 -0
  52. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/default_config_text.py +0 -0
  53. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/default_counts.py +0 -0
  54. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/drain_watch.py +0 -0
  55. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/experiment_notes.py +0 -0
  56. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/experiment_runs.py +0 -0
  57. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/experiments.py +0 -0
  58. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/file_changes.py +0 -0
  59. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/format_duration.py +0 -0
  60. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/format_started.py +0 -0
  61. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/format_value.py +0 -0
  62. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/group_runs.py +0 -0
  63. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/init.py +0 -0
  64. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/invocation_started.py +0 -0
  65. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/is_positive_int.py +0 -0
  66. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/list_experiments.py +0 -0
  67. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/list_runs.py +0 -0
  68. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/load_config.py +0 -0
  69. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/load_experiment.py +0 -0
  70. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/load_run.py +0 -0
  71. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/log.py +0 -0
  72. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/log_path.py +0 -0
  73. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/models.py +0 -0
  74. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/modes.py +0 -0
  75. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/nests_runs.py +0 -0
  76. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/note_path.py +0 -0
  77. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/notebook_kernel.py +0 -0
  78. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/output_html.py +0 -0
  79. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/parse_run_view.py +0 -0
  80. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/print_rows.py +0 -0
  81. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/python_blocks.py +0 -0
  82. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/read_config.py +0 -0
  83. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/read_path.py +0 -0
  84. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/render_markdown.py +0 -0
  85. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/run_command.py +0 -0
  86. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/run_control.py +0 -0
  87. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/run_status.py +0 -0
  88. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/__init__.py +0 -0
  89. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/describe_event.py +0 -0
  90. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/execute_run.py +0 -0
  91. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/hold_folder.py +0 -0
  92. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/new_param_run_id.py +0 -0
  93. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/new_run_id.py +0 -0
  94. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  95. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/param_args.py +0 -0
  96. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/param_grid.py +0 -0
  97. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/param_run_name.py +0 -0
  98. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/plan_runs.py +0 -0
  99. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/prepare_experiment.py +0 -0
  100. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/read_experiment_config.py +0 -0
  101. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/run_experiment.py +0 -0
  102. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/run_folder.py +0 -0
  103. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/run_headless.py +0 -0
  104. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/runs_parent.py +0 -0
  105. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/shell_command.py +0 -0
  106. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/signal_tree.py +0 -0
  107. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/stop_then_kill.py +0 -0
  108. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/wait_for_tree.py +0 -0
  109. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/runner/write_run_json.py +0 -0
  110. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/save_conflict_copy.py +0 -0
  111. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/save_note.py +0 -0
  112. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/saved_outputs.py +0 -0
  113. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/serve_app.py +0 -0
  114. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/server.py +0 -0
  115. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/shared_params.py +0 -0
  116. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/sort_href.py +0 -0
  117. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/sql_quote.py +0 -0
  118. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/elapsed.js +0 -0
  119. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/experiment.js +0 -0
  120. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/local-time.js +0 -0
  121. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/row-link.js +0 -0
  122. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/run-cell.js +0 -0
  123. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/static/view-toggle.js +0 -0
  124. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/summarize_config.py +0 -0
  125. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/__init__.py +0 -0
  126. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/experiment_rows.py +0 -0
  127. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/experiment_run_ids.py +0 -0
  128. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/file_prefix.py +0 -0
  129. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/file_text.py +0 -0
  130. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/iso_time.py +0 -0
  131. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/list_experiment_runs.py +0 -0
  132. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/note_text.py +0 -0
  133. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/path_glob.py +0 -0
  134. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/path_pattern.py +0 -0
  135. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/read_experiment.py +0 -0
  136. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/read_file.py +0 -0
  137. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/read_log.py +0 -0
  138. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/read_run.py +0 -0
  139. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tables/summarize_experiments.py +0 -0
  140. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/base.html +0 -0
  141. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/experiment.html +0 -0
  142. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/index.html +0 -0
  143. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/templates/note.html +0 -0
  144. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/__init__.py +0 -0
  145. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/browse_app.py +0 -0
  146. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/browse_tui.py +0 -0
  147. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/dim.py +0 -0
  148. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/experiment_screen.py +0 -0
  149. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/experiments_screen.py +0 -0
  150. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/fill_table.py +0 -0
  151. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/format_elapsed.py +0 -0
  152. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/group_cells.py +0 -0
  153. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/invocation_columns.py +0 -0
  154. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/invocation_rows.py +0 -0
  155. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/leave_prompt.py +0 -0
  156. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/reload_on_change.py +0 -0
  157. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/run_cells.py +0 -0
  158. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/run_output.py +0 -0
  159. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/run_tui.py +0 -0
  160. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/runs_table.py +0 -0
  161. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/tui/stopped_title.py +0 -0
  162. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/wait_for_note.py +0 -0
  163. {labnotes-0.0.98 → labnotes-0.0.100}/src/labnotes/watch_rows.py +0 -0
  164. {labnotes-0.0.98 → labnotes-0.0.100}/testing-conventions.toml +0 -0
  165. {labnotes-0.0.98 → labnotes-0.0.100}/tests/integration/conftest.py +0 -0
  166. {labnotes-0.0.98 → labnotes-0.0.100}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.98
3
+ Version: 0.0.100
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -43,8 +43,30 @@ async def run_output(
43
43
  last, mid_line = stream, not text.endswith("\n")
44
44
  yield f"data: {json.dumps({'stream': stream, 'text': text})}\n\n"
45
45
 
46
+ blocks_read = 0
47
+
48
+ def read_blocks() -> Iterator[str]:
49
+ nonlocal blocks_read
50
+ path = folder / "log.jsonl"
51
+ if not path.exists():
52
+ return
53
+ with path.open("rb") as log:
54
+ log.seek(blocks_read)
55
+ data = log.read()
56
+ whole = data[: data.rfind(b"\n") + 1]
57
+ blocks_read += len(whole)
58
+ for line in whole.splitlines():
59
+ try:
60
+ record = json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if isinstance(record, dict):
64
+ yield f"event: block\ndata: {json.dumps(record)}\n\n"
65
+
46
66
  for message in read(STREAMS):
47
67
  yield message
68
+ for message in read_blocks():
69
+ yield message
48
70
  exit_code = run["exit_code"]
49
71
  if run["ended"] is None:
50
72
  async for event in events:
@@ -54,6 +76,9 @@ async def run_output(
54
76
  if event.table == "logs" and row["run"] == run_id:
55
77
  for message in read([row["stream"]]):
56
78
  yield message
79
+ elif event.table == "blocks" and row["run"] == run_id:
80
+ for message in read_blocks():
81
+ yield message
57
82
  elif (
58
83
  event.table == "runs"
59
84
  and row["id"] == run_id
@@ -63,4 +88,6 @@ async def run_output(
63
88
  break
64
89
  for message in read(STREAMS):
65
90
  yield message
91
+ for message in read_blocks():
92
+ yield message
66
93
  yield f"event: end\ndata: {json.dumps(exit_code)}\n\n"
@@ -302,6 +302,13 @@ h3 {
302
302
  /* Run output */
303
303
  .run-state.good { color: var(--good); }
304
304
  .run-state.bad { color: var(--bad); }
305
+ .run-blocks { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; margin-bottom: 1rem; padding: 0.5rem 0; max-height: 50vh; overflow: auto; }
306
+ .run-blocks[hidden] { display: none; }
307
+ .log-block { display: block; width: 100%; padding: 0.2rem 1.25rem; border: 0; background: none; color: inherit; font: inherit; font-family: var(--mono); font-size: 0.82rem; text-align: left; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
308
+ .log-block::before { content: "▸ "; color: var(--ink-3); }
309
+ .log-block[aria-expanded="true"] { white-space: pre-wrap; overflow-wrap: anywhere; }
310
+ .log-block[aria-expanded="true"]::before { content: "▾ "; }
311
+ .log-block:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); }
305
312
  .run-log { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; }
306
313
  .run-log header { display: flex; gap: 1rem; padding: 0.4rem 1.25rem; border-bottom: 1px solid var(--rule); font-family: var(--mono); font-size: 0.75rem; color: var(--ink-3); }
307
314
  .run-log header span::before { content: "■ "; }
@@ -418,7 +425,7 @@ h3 {
418
425
  top: 0.5rem;
419
426
  right: 0.5rem;
420
427
  }
421
- .cell .run, .note-name .restart {
428
+ .cell .run, .editor .run, .note-name .restart {
422
429
  font: inherit;
423
430
  font-family: var(--sans);
424
431
  font-size: 0.75rem;
@@ -429,8 +436,8 @@ h3 {
429
436
  color: var(--ink-2);
430
437
  cursor: pointer;
431
438
  }
432
- .cell .run:hover, .note-name .restart:hover { color: var(--accent); border-color: var(--accent); }
433
- .cell .run:disabled, .note-name .restart:disabled { cursor: progress; opacity: 0.6; }
439
+ .cell .run:hover, .editor .run:hover, .note-name .restart:hover { color: var(--accent); border-color: var(--accent); }
440
+ .cell .run:disabled, .editor .run:disabled, .note-name .restart:disabled { cursor: progress; opacity: 0.6; }
434
441
  .note-name:has(.restart) { display: flex; align-items: center; justify-content: flex-end; padding-top: 0.45rem; padding-bottom: 0.45rem; }
435
442
  .cell-output { display: block; }
436
443
  .cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
@@ -438,25 +445,57 @@ h3 {
438
445
  .cell-output .stderr, .cell-output .error { color: var(--bad); }
439
446
  .cell-output img, .cell-output svg { max-width: 100%; height: auto; }
440
447
 
441
- /* Editing a note in place */
442
- .prose.editable { cursor: text; }
448
+ /* Editing a note in place: CodeMirror styled to match .prose */
443
449
  .edit-status { margin: 0; padding: 0.45rem 1.75rem; font-family: var(--sans); font-size: 0.8rem; color: var(--ink-3); border-bottom: 1px solid var(--rule); }
444
450
  .editor { padding: 0.5rem 1.75rem 1.5rem; max-width: 46rem; }
445
451
  .editor .cm-editor.cm-focused { outline: none; }
446
- .editor .cm-content { font-family: var(--serif); font-size: 1.06rem; line-height: 1.68; caret-color: var(--ink); }
452
+ .editor .cm-content { padding: 0.8em 0; font-family: var(--serif); font-size: 1.06rem; line-height: 1.68; caret-color: var(--ink); }
447
453
  .editor .cm-line { padding: 0; }
448
- .editor .md-h1 { font-size: 1.6rem; font-weight: 600; }
449
- .editor .md-h2 { font-size: 1.3rem; font-weight: 600; }
450
- .editor .md-h3 { font-size: 1.1rem; font-weight: 600; }
451
- .editor .md-h4 { font-weight: 600; }
452
- .editor .md-strong { font-weight: 700; }
453
- .editor .md-em { font-style: italic; }
454
- .editor .md-strike { text-decoration: line-through; }
455
- .editor .md-link { color: var(--accent); }
456
- .editor .md-code { font-family: var(--mono); font-size: 0.86em; }
457
- .editor .md-quote { color: var(--ink-2); }
454
+ .editor .cm-line:has(> br:only-child):not(.cm-atomic-fenced-code) { line-height: 0.8em; }
455
+ .editor .cm-atomic-h1, .editor .cm-atomic-h2, .editor .cm-atomic-h3, .editor .cm-atomic-h4, .editor .cm-atomic-h5, .editor .cm-atomic-h6 { font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; padding: 1em 0 0.5em; }
456
+ .editor .cm-line.cm-atomic-h1 { font-size: 1.6rem; }
457
+ .editor .cm-line.cm-atomic-h2 { font-size: 1.3rem; }
458
+ .editor .cm-line.cm-atomic-h3 { font-size: 1.1rem; }
459
+ .editor .cm-line.cm-atomic-h4, .editor .cm-line.cm-atomic-h5, .editor .cm-line.cm-atomic-h6 { font-size: 1rem; }
460
+ .editor .cm-atomic-strong { font-weight: 700; }
461
+ .editor .cm-atomic-em { font-style: italic; }
462
+ .editor .cm-atomic-strike { text-decoration: line-through; }
463
+ .editor .cm-atomic-inline-code { font-family: var(--mono); font-size: 0.86em; background: var(--chip); padding: 0.1em 0.35em; border-radius: 4px; }
464
+ .editor .cm-atomic-link { text-decoration: underline; text-decoration-color: var(--rule-strong); text-underline-offset: 3px; }
465
+ .editor .cm-atomic-link::after {
466
+ content: "↗";
467
+ margin-left: 0.15em;
468
+ font-family: var(--sans);
469
+ font-size: 0.75em;
470
+ color: var(--ink-3);
471
+ cursor: pointer;
472
+ }
473
+ .editor .cm-atomic-link:hover::after { color: var(--accent); }
474
+ .editor .cm-line.cm-atomic-blockquote { padding-left: 1rem; border-left: 2px solid var(--rule-strong); color: var(--ink-2); }
475
+ .editor .cm-atomic-list-marker { display: inline-block; width: 0.9em; margin-right: 0.3em; text-align: right; }
476
+ .editor .cm-atomic-list-marker, .editor .cm-atomic-list-marker .md-mark { color: var(--ink); }
477
+ .editor .cm-line.cm-atomic-hr { position: relative; }
478
+ .editor .cm-line.cm-atomic-hr::after { content: ""; position: absolute; left: 0; right: 0; top: 50%; border-top: 1px solid var(--rule); }
479
+ .editor .cm-line.cm-atomic-fenced-code {
480
+ position: relative;
481
+ font-family: var(--mono);
482
+ font-size: 0.8rem;
483
+ line-height: 1.5;
484
+ background: var(--paper);
485
+ padding: 0 1rem;
486
+ border: solid var(--rule);
487
+ border-width: 0 1px;
488
+ }
489
+ .editor .cm-line:not(.cm-atomic-fenced-code) + .cm-atomic-fenced-code, .editor .cm-atomic-fenced-code:first-child { border-top-width: 1px; border-radius: 6px 6px 0 0; }
490
+ .editor .cm-atomic-fenced-code:not(:has(+ .cm-atomic-fenced-code)) { border-bottom-width: 1px; border-radius: 0 0 6px 6px; }
491
+ .editor .cm-line:not(.cm-atomic-fenced-code) + .cm-atomic-fenced-code:not(:has(+ .cm-atomic-fenced-code)) { border-radius: 6px; }
492
+ .editor .cm-atomic-fenced-selection { background: var(--accent-soft); }
493
+ .editor .run { position: absolute; z-index: 1; top: 0.5rem; right: 0.5rem; }
494
+ /* CodeMirror measures block widgets without their margins. */
495
+ .editor .cell-below { display: flow-root; padding-bottom: 0.8em; }
496
+ .editor .cell-output pre { margin: 0; font-size: 0.8rem; line-height: 1.5; }
497
+ .editor .cm-line .cm-widgetBuffer { vertical-align: top; }
458
498
  .editor .md-mark { color: var(--ink-3); }
459
- .editor .md-code-line { font-family: var(--mono); font-size: 0.8rem; line-height: 1.5; background: var(--paper); padding: 0 1rem; }
460
499
  .editor .py-keyword { color: var(--accent); }
461
500
  .editor .py-string { color: var(--good); }
462
501
  .editor .py-comment { color: var(--ink-3); font-style: italic; }
@@ -22,8 +22,6 @@ function show(note, button) {
22
22
  }
23
23
 
24
24
  for (const button of document.querySelectorAll(".new-note")) {
25
- // An open editor closes, and reloads the page, on a mousedown outside it.
26
- button.addEventListener("mousedown", (event) => event.stopPropagation());
27
25
  button.addEventListener("click", async () => {
28
26
  button.disabled = true;
29
27
  const response = await fetch(button.dataset.url, {
@@ -1,35 +1,28 @@
1
1
  let detach = () => {};
2
2
 
3
- export function attach(note, openNow) {
3
+ export function attach(note, focus) {
4
4
  detach();
5
5
  const prose = note.querySelector(".prose");
6
6
  const status = note.querySelector(".edit-status");
7
- const editor = import(note.dataset.editor);
8
7
  const source = JSON.parse(note.querySelector(".note-source").textContent);
9
8
  let editing = null;
10
9
  let attached = true;
11
10
 
12
- editor.then(
13
- () => prose.classList.add("editable"),
11
+ import(note.dataset.editor).then(
12
+ ({ edit }) => {
13
+ if (!attached) return;
14
+ editing = edit(note, prose, status);
15
+ if (focus) editing.view.focus();
16
+ },
14
17
  () => {},
15
18
  );
16
19
 
17
- const open = async (event) => {
18
- if (event.target.closest("a, button, output") || getSelection().toString()) return;
19
- const { edit } = await editor;
20
- if (editing || !attached) return;
21
- prose.removeEventListener("click", open);
22
- editing = edit(note, prose, status);
23
- };
24
- prose.addEventListener("click", open);
25
- if (openNow) open({ target: prose });
26
-
27
20
  const changes = new EventSource(note.dataset.changesUrl);
28
21
  changes.addEventListener("message", (event) => {
29
22
  if (JSON.parse(event.data) !== note.dataset.note) return;
30
23
  if (editing) return editing.pull();
31
- // A touch or a duplicate event leaves the text alone, and reloading then
32
- // would swallow a click that is opening the editor.
24
+ // Without the editor the note is rendered HTML. A touch or a duplicate
25
+ // event leaves the text alone, and reloading then is wasted.
33
26
  fetch(note.dataset.saveUrl, { cache: "no-store" })
34
27
  .then((response) => response.text())
35
28
  .then((text) => {
@@ -4,12 +4,14 @@ import {
4
4
  HighlightStyle,
5
5
  Language,
6
6
  defineLanguageFacet,
7
+ ensureSyntaxTree,
7
8
  languageDataProp,
8
9
  syntaxHighlighting,
9
10
  syntaxTree,
10
11
  } from "@codemirror/language";
11
- import { EditorState, RangeSetBuilder } from "@codemirror/state";
12
- import { Decoration, EditorView, ViewPlugin, keymap } from "@codemirror/view";
12
+ import { EditorState, StateField } from "@codemirror/state";
13
+ import { Decoration, EditorView, WidgetType, keymap } from "@codemirror/view";
14
+ import { inlinePreview } from "@atomic-editor/editor/inline-preview";
13
15
  import { tags } from "@lezer/highlight";
14
16
  import { GFM, parseCode, parser } from "@lezer/markdown";
15
17
  import { DIFF_DELETE, DIFF_EQUAL, diff_match_patch } from "diff-match-patch";
@@ -31,16 +33,6 @@ const markdown = new Language(
31
33
  );
32
34
 
33
35
  const style = HighlightStyle.define([
34
- { tag: tags.heading1, class: "md-h1" },
35
- { tag: tags.heading2, class: "md-h2" },
36
- { tag: tags.heading3, class: "md-h3" },
37
- { tag: [tags.heading4, tags.heading5, tags.heading6], class: "md-h4" },
38
- { tag: tags.strong, class: "md-strong" },
39
- { tag: tags.emphasis, class: "md-em" },
40
- { tag: tags.strikethrough, class: "md-strike" },
41
- { tag: [tags.link, tags.url], class: "md-link" },
42
- { tag: tags.monospace, class: "md-code" },
43
- { tag: tags.quote, class: "md-quote" },
44
36
  { tag: [tags.processingInstruction, tags.contentSeparator], class: "md-mark" },
45
37
  { tag: [tags.keyword, tags.operatorKeyword, tags.bool, tags.null], class: "py-keyword" },
46
38
  { tag: [tags.string, tags.special(tags.string)], class: "py-string" },
@@ -49,54 +41,86 @@ const style = HighlightStyle.define([
49
41
  { tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], class: "py-name" },
50
42
  ]);
51
43
 
52
- // Markup is hidden except on the lines the cursor is on, as in Obsidian.
53
- const HIDDEN = new Set(["HeaderMark", "EmphasisMark", "CodeMark", "StrikethroughMark"]);
54
- const hide = Decoration.replace({});
55
- const codeLine = Decoration.line({ class: "md-code-line" });
56
-
57
- function decorate(view) {
58
- const builder = new RangeSetBuilder();
59
- const { state } = view;
60
- const active = new Set();
61
- for (const range of state.selection.ranges) {
62
- for (let n = state.doc.lineAt(range.from).number; n <= state.doc.lineAt(range.to).number; n++) active.add(n);
44
+ class RunButton extends WidgetType {
45
+ constructor(block, run) {
46
+ super();
47
+ this.block = block;
48
+ this.run = run;
63
49
  }
64
- for (const { from, to } of view.visibleRanges) {
65
- syntaxTree(state).iterate({
66
- from,
67
- to,
68
- enter(node) {
69
- if (node.name === "FencedCode") {
70
- for (let at = node.from; at <= node.to; ) {
71
- const line = state.doc.lineAt(at);
72
- builder.add(line.from, line.from, codeLine);
73
- at = line.to + 1;
74
- }
75
- return false;
76
- }
77
- if (!HIDDEN.has(node.name) || active.has(state.doc.lineAt(node.from).number)) return;
78
- let end = node.to;
79
- if (node.name === "HeaderMark" && state.doc.sliceString(end, end + 1) === " ") end += 1;
80
- builder.add(node.from, end, hide);
81
- },
82
- });
50
+ eq(other) {
51
+ return other.block === this.block;
52
+ }
53
+ toDOM() {
54
+ const button = document.createElement("button");
55
+ button.type = "button";
56
+ button.className = "run";
57
+ button.textContent = "Run";
58
+ button.addEventListener("click", () => this.run(this.block, button));
59
+ return button;
60
+ }
61
+ ignoreEvent() {
62
+ return true;
83
63
  }
84
- return builder.finish();
85
64
  }
86
65
 
87
- const livePreview = ViewPlugin.fromClass(
88
- class {
89
- constructor(view) {
90
- this.decorations = decorate(view);
66
+ class CellOutput extends WidgetType {
67
+ constructor(block, outputs) {
68
+ super();
69
+ this.block = block;
70
+ this.outputs = outputs;
71
+ }
72
+ eq(other) {
73
+ return other.block === this.block;
74
+ }
75
+ toDOM() {
76
+ const cell = document.createElement("div");
77
+ cell.className = "cell-below";
78
+ if (!this.outputs.has(this.block)) {
79
+ const output = document.createElement("output");
80
+ output.className = "cell-output";
81
+ this.outputs.set(this.block, output);
91
82
  }
92
- update(update) {
93
- if (update.docChanged || update.selectionSet || update.viewportChanged) {
94
- this.decorations = decorate(update.view);
83
+ cell.append(this.outputs.get(this.block));
84
+ return cell;
85
+ }
86
+ ignoreEvent() {
87
+ return true;
88
+ }
89
+ }
90
+
91
+ // Numbered in document order, as python_blocks numbers them on the server.
92
+ function cellWidgets(state, run, outputs) {
93
+ const widgets = [];
94
+ let block = 0;
95
+ const tree = ensureSyntaxTree(state, state.doc.length, 200) ?? syntaxTree(state);
96
+ tree.iterate({
97
+ enter(node) {
98
+ if (node.name !== "FencedCode") return;
99
+ const info = node.node.getChild("CodeInfo");
100
+ if (info && state.doc.sliceString(info.from, info.to).split(/\s/)[0] === "python") {
101
+ const open = state.doc.lineAt(node.from);
102
+ widgets.push(Decoration.widget({ widget: new RunButton(block, run), side: 1 }).range(open.to));
103
+ widgets.push(
104
+ Decoration.widget({ widget: new CellOutput(block, outputs), block: true, side: 1 }).range(node.to),
105
+ );
106
+ block += 1;
95
107
  }
96
- }
97
- },
98
- { decorations: (plugin) => plugin.decorations },
99
- );
108
+ return false;
109
+ },
110
+ });
111
+ return Decoration.set(widgets, true);
112
+ }
113
+
114
+ function cells(run, outputs) {
115
+ return StateField.define({
116
+ create: (state) => cellWidgets(state, run, outputs),
117
+ update(widgets, tr) {
118
+ if (!tr.docChanged && syntaxTree(tr.state) === syntaxTree(tr.startState)) return widgets;
119
+ return cellWidgets(tr.state, run, outputs);
120
+ },
121
+ provide: (field) => EditorView.decorations.from(field),
122
+ });
123
+ }
100
124
 
101
125
  function show(status, message) {
102
126
  status.textContent = message;
@@ -196,15 +220,33 @@ export function edit(note, prose, status) {
196
220
 
197
221
  const save = () => run(push);
198
222
 
199
- const close = async () => {
200
- await save();
201
- document.removeEventListener("mousedown", outside);
202
- location.reload();
203
- };
204
- const outside = (event) => {
205
- if (!note.contains(event.target)) close();
223
+ // The rendered note's outputs move into the editor, so a run started
224
+ // before the editor loaded still shows its result.
225
+ const outputs = new Map();
226
+ for (const cell of prose.querySelectorAll(".cell")) {
227
+ outputs.set(Number(cell.dataset.block), cell.querySelector(".cell-output"));
228
+ }
229
+
230
+ // The server runs the block from the file, so unsaved edits go first.
231
+ const runCell = async (block, button) => {
232
+ button.disabled = true;
233
+ button.textContent = "Running";
234
+ try {
235
+ await save();
236
+ const response = await fetch(note.dataset.runUrl, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/json" },
239
+ body: JSON.stringify({ block }),
240
+ });
241
+ outputs.get(block).innerHTML = response.ok
242
+ ? await response.text()
243
+ : `<pre class="error">${response.status} ${response.statusText}</pre>`;
244
+ view.requestMeasure();
245
+ } finally {
246
+ button.disabled = false;
247
+ button.textContent = "Run";
248
+ }
206
249
  };
207
- document.addEventListener("mousedown", outside);
208
250
 
209
251
  const view = new EditorView({
210
252
  parent: host,
@@ -212,10 +254,15 @@ export function edit(note, prose, status) {
212
254
  doc: base,
213
255
  extensions: [
214
256
  history(),
215
- keymap.of([{ key: "Escape", run: () => (close(), true) }, ...defaultKeymap, ...historyKeymap]),
257
+ keymap.of([
258
+ { key: "Escape", run: () => (view.contentDOM.blur(), true) },
259
+ ...defaultKeymap,
260
+ ...historyKeymap,
261
+ ]),
216
262
  markdown,
217
263
  syntaxHighlighting(style),
218
- livePreview,
264
+ inlinePreview({ onLinkClick: (url) => location.assign(new URL(url, location.href)) }),
265
+ cells(runCell, outputs),
219
266
  EditorView.lineWrapping,
220
267
  EditorView.contentAttributes.of({ "aria-label": "Note" }),
221
268
  EditorView.updateListener.of((update) => {
@@ -227,12 +274,9 @@ export function edit(note, prose, status) {
227
274
  ],
228
275
  }),
229
276
  });
230
- view.focus();
231
277
  return {
278
+ view,
232
279
  pull: () => run(pull),
233
- detach: () => {
234
- document.removeEventListener("mousedown", outside);
235
- save();
236
- },
280
+ detach: () => save(),
237
281
  };
238
282
  }
@@ -4,12 +4,41 @@ const output = document.querySelector(".run-output");
4
4
  const state = document.querySelector(".run-state");
5
5
  const source = new EventSource(output.dataset.url);
6
6
 
7
+ const blocks = document.querySelector(".run-blocks");
8
+
7
9
  let parsers = {};
10
+ let streaming = new Map();
8
11
 
9
12
  // The server resends the whole log on each connection, so a reconnect starts clean.
10
13
  source.addEventListener("open", () => {
11
14
  output.replaceChildren();
12
15
  parsers = {};
16
+ blocks.replaceChildren();
17
+ blocks.hidden = true;
18
+ streaming = new Map();
19
+ });
20
+
21
+ source.addEventListener("block", (event) => {
22
+ const { id, phase, content = "" } = JSON.parse(event.data);
23
+ const open = streaming.get(id);
24
+ if (open && (phase === "delta" || phase === "stop")) {
25
+ open.textContent += content;
26
+ if (phase === "stop") streaming.delete(id);
27
+ return;
28
+ }
29
+ if (phase === "stop") return;
30
+ const line = document.createElement("button");
31
+ line.type = "button";
32
+ line.className = "log-block";
33
+ line.setAttribute("aria-expanded", "false");
34
+ line.textContent = content;
35
+ line.addEventListener("click", () => {
36
+ const expanded = line.getAttribute("aria-expanded") === "true";
37
+ line.setAttribute("aria-expanded", String(!expanded));
38
+ });
39
+ if (phase === "start" || phase === "delta") streaming.set(id, line);
40
+ blocks.append(line);
41
+ blocks.hidden = false;
13
42
  });
14
43
 
15
44
  source.addEventListener("message", (event) => {
@@ -4,6 +4,7 @@ from dirsql import DirSQL, Table
4
4
 
5
5
  from labnotes.models import LabnotesConfig
6
6
  from labnotes.tables.path_glob import path_glob
7
+ from labnotes.tables.read_blocks import read_blocks
7
8
  from labnotes.tables.read_experiment import read_experiment
8
9
  from labnotes.tables.read_file import read_file
9
10
  from labnotes.tables.read_log import read_log
@@ -48,6 +49,12 @@ CREATE TABLE logs (
48
49
  PRIMARY KEY (experiment, run, stream)
49
50
  )
50
51
  """
52
+ BLOCKS = """
53
+ CREATE TABLE blocks (
54
+ experiment TEXT, run TEXT, size INTEGER,
55
+ PRIMARY KEY (experiment, run)
56
+ )
57
+ """
51
58
 
52
59
 
53
60
  def open_tables(config: LabnotesConfig) -> DirSQL:
@@ -73,6 +80,12 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
73
80
  glob=path_glob(f"{config.runs}/std*.log"),
74
81
  on_file=partial(read_log, root, config.runs),
75
82
  ),
83
+ Table(
84
+ name="blocks",
85
+ ddl=BLOCKS,
86
+ glob=path_glob(f"{config.runs}/log.jsonl"),
87
+ on_file=partial(read_blocks, root, config.runs),
88
+ ),
76
89
  Table(
77
90
  name="experiment_configs",
78
91
  ddl=EXPERIMENTS,
@@ -0,0 +1,17 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.path_pattern import path_pattern
4
+
5
+
6
+ def read_blocks(root: Path, template: str, path: str) -> list[dict[str, object]]:
7
+ relative = Path(path).relative_to(root).as_posix()
8
+ found = path_pattern(f"{template}/log.jsonl").fullmatch(relative)
9
+ if found is None:
10
+ return []
11
+ return [
12
+ {
13
+ "experiment": found["experiment"],
14
+ "run": found["run"],
15
+ "size": Path(path).stat().st_size,
16
+ }
17
+ ]
@@ -2,6 +2,7 @@
2
2
  <script type="importmap">
3
3
  {
4
4
  "imports": {
5
+ "@atomic-editor/editor/inline-preview": "https://esm.sh/*@atomic-editor/editor@0.6.2/dist/inline-preview.js",
5
6
  "@codemirror/autocomplete": "https://esm.sh/*@codemirror/autocomplete@6.20.4",
6
7
  "@codemirror/commands": "https://esm.sh/*@codemirror/commands@6.11.1",
7
8
  "@codemirror/lang-python": "https://esm.sh/*@codemirror/lang-python@6.2.1",
@@ -11,6 +11,8 @@
11
11
  </p>
12
12
  </section>
13
13
 
14
+ <section class="run-blocks" hidden></section>
15
+
14
16
  <section class="run-log">
15
17
  <header><span class="stdout">stdout</span><span class="stderr">stderr</span></header>
16
18
  <pre class="run-output" data-url="{{ url_for('output', name=run.experiment, run=run.id) }}"></pre>
@@ -0,0 +1,31 @@
1
+ from rich.text import Text
2
+ from textual.events import Click
3
+ from textual.widgets import Static
4
+
5
+
6
+ class LogBlock(Static):
7
+ DEFAULT_CSS = """
8
+ LogBlock { height: auto; }
9
+ LogBlock.-collapsed { height: 1; text-wrap: nowrap; text-overflow: ellipsis; }
10
+ """
11
+
12
+ def __init__(self, body: str) -> None:
13
+ super().__init__(classes="-collapsed")
14
+ self.body = body
15
+ self.expanded = False
16
+ self.show()
17
+
18
+ def extend(self, text: str) -> None:
19
+ self.body += text
20
+ self.show()
21
+
22
+ def on_click(self, _: Click) -> None:
23
+ self.expanded = not self.expanded
24
+ self.set_class(not self.expanded, "-collapsed")
25
+ self.show()
26
+
27
+ def show(self) -> None:
28
+ if self.expanded:
29
+ self.update(Text(f"▾ {self.body}"))
30
+ else:
31
+ self.update(Text(f"▸ {self.body}".replace("\n", " ")))
@@ -0,0 +1,55 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from textual.containers import VerticalScroll
5
+
6
+ from labnotes.tui.log_block import LogBlock
7
+
8
+ STREAMING = ("start", "delta")
9
+
10
+
11
+ class LogBlocks(VerticalScroll):
12
+ DEFAULT_CSS = "LogBlocks { height: auto; max-height: 50%; }"
13
+
14
+ def __init__(self, id: str | None = None) -> None:
15
+ super().__init__(id=id)
16
+ self.reset()
17
+
18
+ def reset(self) -> None:
19
+ self.read_to = 0
20
+ self.streaming: dict[object, LogBlock] = {}
21
+ self.remove_children()
22
+ self.display = False
23
+
24
+ def follow(self, folder: Path) -> None:
25
+ path = folder / "log.jsonl"
26
+ if not path.exists():
27
+ return
28
+ with path.open("rb") as log:
29
+ log.seek(self.read_to)
30
+ data = log.read()
31
+ complete = data[: data.rfind(b"\n") + 1]
32
+ self.read_to += len(complete)
33
+ for line in complete.splitlines():
34
+ try:
35
+ record = json.loads(line)
36
+ except json.JSONDecodeError:
37
+ continue
38
+ if isinstance(record, dict):
39
+ self.add(record)
40
+
41
+ def add(self, record: dict[str, object]) -> None:
42
+ phase, key = record.get("phase"), record.get("id")
43
+ body = str(record.get("content", ""))
44
+ if phase == "delta" and key in self.streaming:
45
+ self.streaming[key].extend(body)
46
+ return
47
+ if phase == "stop":
48
+ if key in self.streaming:
49
+ self.streaming.pop(key).extend(body)
50
+ return
51
+ block = LogBlock(body)
52
+ if phase in STREAMING:
53
+ self.streaming[key] = block
54
+ self.mount(block)
55
+ self.display = True