m2f-cli 0.2.0__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.
@@ -0,0 +1,627 @@
1
+ """Fill Sorry workflow - upload Lean project and fill sorry placeholders."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import difflib
7
+ import io
8
+ import os
9
+ from datetime import datetime, timezone, timedelta
10
+ from pathlib import Path
11
+
12
+ from textual.widgets import Static, Input, DataTable
13
+ from textual.containers import Vertical
14
+ from textual.message import Message
15
+
16
+ from m2f_cli.api_client import APIError, M2FClient
17
+ from m2f_cli.i18n import t
18
+
19
+ POLL_INTERVAL = 5
20
+ TERMINAL_STATUSES = {"succeeded", "failed"}
21
+
22
+ # Files/dirs to exclude from project zip
23
+ EXCLUDE_PATTERNS = {
24
+ ".git", ".lake", "lake-packages", "build", "__pycache__",
25
+ ".DS_Store", "Thumbs.db", "node_modules",
26
+ }
27
+
28
+
29
+ def _find_project_root(lean_file: Path) -> Path | None:
30
+ """Walk up from a .lean file to find the project root (containing lakefile.lean)."""
31
+ current = lean_file.parent
32
+ for _ in range(10):
33
+ if (current / "lakefile.lean").exists() or (current / "lakefile.toml").exists():
34
+ return current
35
+ parent = current.parent
36
+ if parent == current:
37
+ break
38
+ current = parent
39
+ return None
40
+
41
+
42
+ def _make_tar_gz(project_root: Path) -> bytes:
43
+ """Create an in-memory tar.gz of the project, excluding build artifacts."""
44
+ import tarfile as _tarfile
45
+ buf = io.BytesIO()
46
+
47
+ def _filter(tarinfo):
48
+ parts = set(tarinfo.name.replace(os.sep, '/').split('/'))
49
+ if parts & EXCLUDE_PATTERNS:
50
+ return None
51
+ return tarinfo
52
+
53
+ with _tarfile.open(fileobj=buf, mode='w:gz') as tar:
54
+ tar.add(str(project_root), arcname='.', filter=_filter)
55
+ return buf.getvalue()
56
+
57
+
58
+ _BJT = timezone(timedelta(hours=8))
59
+
60
+
61
+ def _fmt_time(iso_str: str) -> str:
62
+ """Convert ISO timestamp to Beijing time 'YYYY-MM-DD HH:MM:SS'."""
63
+ if not iso_str:
64
+ return ""
65
+ try:
66
+ dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
67
+ return dt.astimezone(_BJT).strftime("%Y-%m-%d %H:%M:%S")
68
+ except (ValueError, TypeError):
69
+ return iso_str[:19]
70
+
71
+
72
+ def _status_label(status: str) -> str:
73
+ key = f"status_{status}"
74
+ label = t(key)
75
+ return label if label != key else status
76
+
77
+
78
+ def _status_color(status: str) -> str:
79
+ colors = {
80
+ "queued": "#f0a020",
81
+ "template_creating": "#2080f0",
82
+ "compiling": "#2080f0",
83
+ "running": "#2080f0",
84
+ "succeeded": "#22c55e",
85
+ "failed": "#d03050",
86
+ }
87
+ return colors.get(status, "#999")
88
+
89
+
90
+ class FillSorryFormWidget(Vertical, can_focus=True):
91
+ """Form to input target .lean file path."""
92
+
93
+ class Submitted(Message):
94
+ def __init__(self, lean_file: str, project_root: str, local_path: str) -> None:
95
+ self.lean_file = lean_file
96
+ self.project_root = project_root
97
+ self.local_path = local_path # absolute path of the original .lean file
98
+ super().__init__()
99
+
100
+ class BackRequested(Message):
101
+ pass
102
+
103
+ DEFAULT_CSS = """
104
+ FillSorryFormWidget {
105
+ height: auto;
106
+ padding: 1 2;
107
+ }
108
+ FillSorryFormWidget .label {
109
+ margin: 1 0 0 0;
110
+ }
111
+ FillSorryFormWidget .hint {
112
+ color: #64748b;
113
+ }
114
+ """
115
+
116
+ def compose(self):
117
+ yield Static(f"[bold]{t('fill_sorry_new')}[/]")
118
+ yield Static(f"{t('fill_sorry_select_file')}:", classes="label")
119
+ yield Static(f"[dim]{t('fill_sorry_select_file_hint')}[/]", classes="hint")
120
+ yield Input(placeholder="path/to/file.lean", id="lean-file-input")
121
+ yield Static("", id="fs-project-info")
122
+ yield Static(f"[dim]{t('enter_submit_esc_back')}[/]")
123
+
124
+ def on_mount(self) -> None:
125
+ self.query_one("#lean-file-input", Input).focus()
126
+
127
+ def on_input_changed(self, event: Input.Changed) -> None:
128
+ if event.input.id != "lean-file-input":
129
+ return
130
+ path = Path(event.value.strip())
131
+ info = self.query_one("#fs-project-info", Static)
132
+ if path.exists() and path.suffix == ".lean":
133
+ root = _find_project_root(path)
134
+ if root:
135
+ info.update(f"[green]{t('fill_sorry_detected_project')}: {root}[/]")
136
+ else:
137
+ info.update(f"[yellow]{t('project_root_not_found')}[/]")
138
+ elif event.value.strip():
139
+ info.update(f"[red]{t('not_lean_file')}[/]" if not path.suffix == ".lean" else "")
140
+ else:
141
+ info.update("")
142
+
143
+ def on_input_submitted(self, event: Input.Submitted) -> None:
144
+ value = event.value.strip()
145
+ if not value:
146
+ return
147
+ path = Path(value)
148
+ if not path.exists() or path.suffix != ".lean":
149
+ return
150
+ root = _find_project_root(path)
151
+ if not root:
152
+ return
153
+ # Disable input immediately to prevent duplicate submits
154
+ inp = self.query_one("#lean-file-input", Input)
155
+ inp.disabled = True
156
+ self.query_one("#fs-project-info", Static).update(
157
+ f"[bold yellow]{t('fill_sorry_packaging')}...[/]"
158
+ )
159
+ # Make target_file relative to project root
160
+ abs_path = path.resolve()
161
+ try:
162
+ rel = abs_path.relative_to(root.resolve())
163
+ except ValueError:
164
+ rel = path.name
165
+ self.post_message(self.Submitted(
166
+ lean_file=str(rel).replace("\\", "/"),
167
+ project_root=str(root),
168
+ local_path=str(abs_path),
169
+ ))
170
+
171
+ def on_key(self, event) -> None:
172
+ if event.key == "escape":
173
+ self.post_message(self.BackRequested())
174
+
175
+
176
+ class FillSorryProgressWidget(Vertical, can_focus=True):
177
+ """Monitor a fill sorry job's progress with auto-polling."""
178
+
179
+ class BackRequested(Message):
180
+ pass
181
+
182
+ DEFAULT_CSS = """
183
+ FillSorryProgressWidget {
184
+ height: 1fr;
185
+ padding: 1 2;
186
+ }
187
+ FillSorryProgressWidget #fs-diff-area {
188
+ margin-top: 1;
189
+ max-height: 30;
190
+ overflow-y: auto;
191
+ }
192
+ """
193
+
194
+ def __init__(
195
+ self,
196
+ job_id: str,
197
+ job_data: dict,
198
+ local_path: str | None = None,
199
+ ) -> None:
200
+ super().__init__()
201
+ self.job_id = job_id
202
+ self.job_data = job_data
203
+ self.local_path = local_path # absolute path of original .lean file (None when viewed from history)
204
+ self._monitor_task: asyncio.Task | None = None
205
+ self._filled_content: str | None = None # cached filled content after success
206
+ self._original_content: str | None = None
207
+
208
+ def compose(self):
209
+ target = self.job_data.get("target_file", "")
210
+ yield Static(f"[bold]{t('fill_sorry')}[/] [cyan]{target}[/]")
211
+ yield Static(f"[bold]{t('formalize_job_id')}:[/] [dim]{self.job_id[:8]}[/]")
212
+ if self.local_path:
213
+ yield Static(f"[dim]Local: {self.local_path}[/]")
214
+ yield Static("", id="fs-status-line")
215
+ yield Static("", id="fs-stage-line")
216
+ yield Static("", id="fs-message-line")
217
+ yield Static("", id="fs-diff-area")
218
+ yield Static(f"[dim]{t('fill_sorry_monitoring')}[/]", id="fs-hint-line")
219
+
220
+ def on_mount(self) -> None:
221
+ self._update_display()
222
+ self.focus()
223
+ status = self.job_data.get("status", "")
224
+ if status not in TERMINAL_STATUSES:
225
+ self._monitor_task = asyncio.create_task(self._poll())
226
+ elif status == "succeeded":
227
+ asyncio.create_task(self._load_and_render_diff())
228
+ else:
229
+ # Already failed — show failure reason immediately
230
+ fail_msg = self.job_data.get("message", "")
231
+ hint = self.query_one("#fs-hint-line", Static)
232
+ hint.update(
233
+ f"[red]{t('fill_sorry_finished')} ({_status_label(status)})[/]\n"
234
+ f"[dim]{fail_msg}[/]\n\n"
235
+ f"[dim]{t('esc_back_list')}[/]"
236
+ )
237
+
238
+ def _update_display(self) -> None:
239
+ status = self.job_data.get("status", "unknown")
240
+ stage = self.job_data.get("stage", "")
241
+ message = self.job_data.get("message", "")
242
+ color = _status_color(status)
243
+
244
+ self.query_one("#fs-status-line", Static).update(
245
+ f"[bold]{t('status')}:[/] [{color}]{_status_label(status)}[/]"
246
+ )
247
+ if stage:
248
+ self.query_one("#fs-stage-line", Static).update(
249
+ f"[bold]{t('formalize_stage')}:[/] {stage}"
250
+ )
251
+ if message:
252
+ self.query_one("#fs-message-line", Static).update(
253
+ f"[bold]{t('formalize_message')}:[/] {message}"
254
+ )
255
+
256
+ async def _poll(self) -> None:
257
+ try:
258
+ client = M2FClient()
259
+ while True:
260
+ await asyncio.sleep(POLL_INTERVAL)
261
+ try:
262
+ data = client.sorry_status(self.job_id)
263
+ self.job_data = data
264
+ self._update_display()
265
+
266
+ status = data.get("status", "")
267
+ if status in TERMINAL_STATUSES:
268
+ if status == "succeeded":
269
+ await self._load_and_render_diff()
270
+ else:
271
+ hint = self.query_one("#fs-hint-line", Static)
272
+ fail_msg = data.get("message", "")
273
+ hint.update(
274
+ f"[red]{t('fill_sorry_finished')} ({_status_label(status)})[/]\n"
275
+ f"[dim]{fail_msg}[/]\n\n"
276
+ f"[dim]{t('esc_back_list')}[/]"
277
+ )
278
+ break
279
+ except (APIError, Exception):
280
+ pass
281
+ except asyncio.CancelledError:
282
+ pass
283
+
284
+ async def _load_and_render_diff(self) -> None:
285
+ """Fetch filled content from server, read local original, render diff."""
286
+ hint = self.query_one("#fs-hint-line", Static)
287
+ target_file = self.job_data.get("target_file") or ""
288
+ if not target_file:
289
+ hint.update(f"[yellow]{t('fill_sorry_no_target')}[/]")
290
+ return
291
+
292
+ hint.update(f"[dim]{t('fill_sorry_loading_diff')}[/]")
293
+ try:
294
+ client = M2FClient()
295
+ resp = client.sorry_read_file(self.job_id, target_file)
296
+ filled = resp.get("content", "")
297
+ self._filled_content = filled
298
+ except (APIError, Exception) as exc:
299
+ detail = getattr(exc, "detail", str(exc))
300
+ hint.update(f"[red]{t('fill_sorry_load_failed')}: {detail}[/]")
301
+ return
302
+
303
+ # Load original if we have the local path
304
+ if self.local_path and Path(self.local_path).exists():
305
+ try:
306
+ self._original_content = Path(self.local_path).read_text(encoding="utf-8")
307
+ except Exception:
308
+ self._original_content = None
309
+
310
+ self._render_diff()
311
+
312
+ if self.local_path:
313
+ hint.update(f"[dim]{t('fill_sorry_action_hint')}[/]")
314
+ else:
315
+ hint.update(f"[dim]{t('fill_sorry_action_hint_no_local')}[/]")
316
+
317
+ def _render_diff(self) -> None:
318
+ """Render git-style unified diff in the fs-diff-area widget."""
319
+ area = self.query_one("#fs-diff-area", Static)
320
+ filled = self._filled_content or ""
321
+
322
+ if self._original_content is None:
323
+ # No local file — just show the filled content
324
+ lines = filled.splitlines()
325
+ if len(lines) > 100:
326
+ preview = "\n".join(lines[:100]) + f"\n... ({len(lines) - 100} more lines)"
327
+ else:
328
+ preview = "\n".join(lines)
329
+ area.update(
330
+ f"[bold]{t('fill_sorry_filled_content')}:[/]\n[green]{preview}[/]"
331
+ )
332
+ return
333
+
334
+ # Git-style unified diff
335
+ diff_lines = list(difflib.unified_diff(
336
+ self._original_content.splitlines(),
337
+ filled.splitlines(),
338
+ fromfile=f"a/{self.job_data.get('target_file', 'file.lean')}",
339
+ tofile=f"b/{self.job_data.get('target_file', 'file.lean')}",
340
+ lineterm="",
341
+ ))
342
+
343
+ if not diff_lines:
344
+ area.update(f"[dim]{t('fill_sorry_no_changes')}[/]")
345
+ return
346
+
347
+ rendered: list[str] = []
348
+ for line in diff_lines:
349
+ # Escape Rich markup characters
350
+ escaped = line.replace("[", "\\[")
351
+ if line.startswith("+++") or line.startswith("---"):
352
+ rendered.append(f"[bold]{escaped}[/]")
353
+ elif line.startswith("@@"):
354
+ rendered.append(f"[cyan]{escaped}[/]")
355
+ elif line.startswith("+"):
356
+ rendered.append(f"[green]{escaped}[/]")
357
+ elif line.startswith("-"):
358
+ rendered.append(f"[red]{escaped}[/]")
359
+ else:
360
+ rendered.append(f"[dim]{escaped}[/]")
361
+
362
+ # Cap huge diffs
363
+ MAX_LINES = 200
364
+ if len(rendered) > MAX_LINES:
365
+ rendered = rendered[:MAX_LINES] + [f"[dim]... ({len(diff_lines) - MAX_LINES} more lines)[/]"]
366
+
367
+ area.update("\n".join(rendered))
368
+
369
+ def on_key(self, event) -> None:
370
+ key = (event.key or "").lower()
371
+ if key == "escape":
372
+ if self._monitor_task and not self._monitor_task.done():
373
+ self._monitor_task.cancel()
374
+ self.post_message(self.BackRequested())
375
+ return
376
+ if self.job_data.get("status") != "succeeded":
377
+ return
378
+ if key == "o":
379
+ asyncio.create_task(self._overwrite_local())
380
+ elif key == "s":
381
+ asyncio.create_task(self._save_as())
382
+ elif key == "d":
383
+ self._render_diff() # re-render existing diff
384
+
385
+ async def _overwrite_local(self) -> None:
386
+ hint = self.query_one("#fs-hint-line", Static)
387
+ if not self.local_path or self._filled_content is None:
388
+ hint.update(f"[yellow]{t('fill_sorry_cannot_overwrite')}[/]")
389
+ return
390
+ try:
391
+ p = Path(self.local_path)
392
+ backup = p.with_suffix(p.suffix + ".bak")
393
+ if p.exists():
394
+ backup.write_bytes(p.read_bytes())
395
+ p.write_text(self._filled_content, encoding="utf-8")
396
+ hint.update(
397
+ f"[green]{t('fill_sorry_overwritten')}: {p}[/]\n"
398
+ f"[dim]Backup: {backup}[/]"
399
+ )
400
+ except Exception as exc:
401
+ hint.update(f"[red]{t('fill_sorry_overwrite_failed')}: {exc}[/]")
402
+
403
+ async def _save_as(self) -> None:
404
+ hint = self.query_one("#fs-hint-line", Static)
405
+ if self._filled_content is None:
406
+ hint.update(f"[yellow]{t('fill_sorry_no_content')}[/]")
407
+ return
408
+ target = self.job_data.get("target_file", "filled.lean")
409
+ # Save to current working directory preserving the file name
410
+ out_path = Path.cwd() / Path(target).name
411
+ try:
412
+ out_path.write_text(self._filled_content, encoding="utf-8")
413
+ hint.update(f"[green]{t('fill_sorry_saved')}: {out_path}[/]")
414
+ except Exception as exc:
415
+ hint.update(f"[red]{t('fill_sorry_save_failed')}: {exc}[/]")
416
+
417
+ def on_unmount(self) -> None:
418
+ if self._monitor_task and not self._monitor_task.done():
419
+ self._monitor_task.cancel()
420
+
421
+
422
+ class FillSorryJobListWidget(Vertical, can_focus=True):
423
+ """Display a paginated list of fill sorry jobs."""
424
+
425
+ class BackRequested(Message):
426
+ pass
427
+
428
+ class JobSelected(Message):
429
+ def __init__(self, job_id: str, job_data: dict) -> None:
430
+ self.job_id = job_id
431
+ self.job_data = job_data
432
+ super().__init__()
433
+
434
+ class PageChanged(Message):
435
+ def __init__(self, page: int) -> None:
436
+ self.page = page
437
+ super().__init__()
438
+
439
+ DEFAULT_CSS = """
440
+ FillSorryJobListWidget {
441
+ height: 1fr;
442
+ padding: 1 2;
443
+ }
444
+ FillSorryJobListWidget DataTable {
445
+ height: 1fr;
446
+ }
447
+ """
448
+
449
+ def __init__(
450
+ self,
451
+ jobs: list[dict],
452
+ page: int = 1,
453
+ page_size: int = 20,
454
+ total: int = 0,
455
+ ) -> None:
456
+ super().__init__()
457
+ self._jobs = jobs
458
+ self.page = page
459
+ self.page_size = page_size
460
+ self.total = total
461
+
462
+ @property
463
+ def total_pages(self) -> int:
464
+ if self.total <= 0 or self.page_size <= 0:
465
+ return 1
466
+ return max(1, (self.total + self.page_size - 1) // self.page_size)
467
+
468
+ def compose(self):
469
+ yield Static(
470
+ f"[bold]{t('fill_sorry_history')}[/] "
471
+ f"[dim]Enter=详情 Esc=返回 n/p=翻页 r=刷新[/]"
472
+ )
473
+ yield DataTable(id="fs-table")
474
+ yield Static(f"第 {self.page}/{self.total_pages} 页 · 共 {self.total} 条", id="fs-page-info")
475
+
476
+ def on_mount(self) -> None:
477
+ table = self.query_one("#fs-table", DataTable)
478
+ table.add_columns(
479
+ t("col_name"), t("col_status"), t("col_stage"),
480
+ t("col_message"), t("col_created"),
481
+ )
482
+ table.cursor_type = "row"
483
+ for job in self._jobs:
484
+ name = job.get("statement_prompt", "")[:50]
485
+ if len(job.get("statement_prompt", "")) > 50:
486
+ name += "..."
487
+ status = job.get("status", "")
488
+ color = _status_color(status)
489
+ table.add_row(
490
+ name,
491
+ f"[{color}]{_status_label(status)}[/]",
492
+ job.get("stage", ""),
493
+ (job.get("message", "") or "")[:30],
494
+ _fmt_time(job.get("created_at", "")),
495
+ key=job.get("id", ""),
496
+ )
497
+ table.focus()
498
+
499
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
500
+ row_key = str(event.row_key.value)
501
+ for job in self._jobs:
502
+ if job.get("id") == row_key:
503
+ self.post_message(self.JobSelected(job_id=row_key, job_data=job))
504
+ return
505
+
506
+ def on_key(self, event) -> None:
507
+ key = (event.key or "").lower()
508
+ if key == "escape":
509
+ self.post_message(self.BackRequested())
510
+ elif key == "n":
511
+ if self.page < self.total_pages:
512
+ self.post_message(self.PageChanged(page=self.page + 1))
513
+ elif key == "p":
514
+ if self.page > 1:
515
+ self.post_message(self.PageChanged(page=self.page - 1))
516
+ elif key == "r":
517
+ self.post_message(self.PageChanged(page=self.page))
518
+
519
+
520
+ async def run_fill_sorry_submit(
521
+ app, lean_file: str, project_root: str, local_path: str | None = None,
522
+ ) -> None:
523
+ """Package project as tar.gz, upload via sorry-cli, list sorries, start run."""
524
+ container = app.query_one("#main-scroll")
525
+
526
+ status_widget = Static(f"[bold]{t('fill_sorry_packaging')}[/]")
527
+ container.mount(status_widget)
528
+
529
+ # Step 1: Package project as tar.gz
530
+ try:
531
+ root = Path(project_root)
532
+ tar_data = _make_tar_gz(root)
533
+ size_kb = len(tar_data) / 1024
534
+ status_widget.update(
535
+ f"[bold]{t('fill_sorry_uploading')}[/] ({size_kb:.0f} KB)"
536
+ )
537
+ except Exception as exc:
538
+ from m2f_cli.tui.components.message import MessageWidget
539
+ container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {exc}", "error"))
540
+ container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
541
+ return
542
+
543
+ # Step 2: Upload via sorry-cli
544
+ try:
545
+ client = M2FClient()
546
+ data = client.sorry_upload(tar_data, project_name=Path(project_root).name)
547
+ except (APIError, Exception) as exc:
548
+ from m2f_cli.tui.components.message import MessageWidget
549
+ detail = exc.detail if isinstance(exc, APIError) else str(exc)
550
+ container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {detail}", "error"))
551
+ container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
552
+ return
553
+
554
+ job_id = data.get("id", "")
555
+ status_widget.update(
556
+ f"[bold green]{t('fill_sorry_submitted')}[/] {t('formalize_job_id')}: [cyan]{job_id[:8]}[/]"
557
+ )
558
+
559
+ # Step 3: List sorries → auto-start run with first sorry
560
+ try:
561
+ sorries_data = client.sorry_list_sorries(job_id, lean_file)
562
+ sorry_count = sorries_data.get("count", 0)
563
+ if sorry_count > 0:
564
+ sorries = sorries_data.get("sorries", [])
565
+ first = sorries[0] if sorries else {}
566
+ container.mount(Static(
567
+ f"[bold]发现 {sorry_count} 个 sorry[/],"
568
+ f"自动处理第 1 个 (line {first.get('line', '?')} — {first.get('declaration', '')[:50]})"
569
+ ))
570
+ else:
571
+ container.mount(Static(f"[yellow]该文件没有 sorry[/]"))
572
+ container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
573
+ return
574
+ except (APIError, Exception):
575
+ container.mount(Static("[dim]无法列出 sorry,使用 first 模式[/]"))
576
+
577
+ # Step 4: Start run (compile + prove) via Worker
578
+ try:
579
+ run_payload = {
580
+ "lean_file": lean_file,
581
+ "target_sorry": {"mode": "first"},
582
+ "strict_target_only": True,
583
+ }
584
+ client.sorry_run(job_id, run_payload)
585
+ except (APIError, Exception) as exc:
586
+ from m2f_cli.tui.components.message import MessageWidget
587
+ detail = exc.detail if isinstance(exc, APIError) else str(exc)
588
+ container.mount(MessageWidget(f"{t('fill_sorry_submit_failed')}: {detail}", "error"))
589
+ container.mount(Static(f"[dim]{t('press_any_key_back')}[/]"))
590
+ return
591
+
592
+ # Show progress widget — polls sorry_status
593
+ from m2f_cli.tui.components.logo import LogoWidget
594
+ container.remove_children()
595
+ container.mount(LogoWidget())
596
+ container.mount(FillSorryProgressWidget(
597
+ job_id=job_id, job_data=data, local_path=local_path,
598
+ ))
599
+
600
+
601
+ def fetch_and_show_fill_sorry_jobs(app, page: int = 1, page_size: int = 20) -> None:
602
+ """Fetch paginated CLI fill sorry job list and display."""
603
+ from m2f_cli.tui.components.message import MessageWidget
604
+
605
+ try:
606
+ client = M2FClient()
607
+ result = client.list_cli_jobs(page=page, page_size=page_size)
608
+ jobs = result.get("items", [])
609
+ total = result.get("total", len(jobs))
610
+ except APIError as exc:
611
+ app._clear_and_mount(MessageWidget(f"Error: {exc.detail}", "error"))
612
+ return
613
+ except Exception as exc:
614
+ app._clear_and_mount(MessageWidget(f"{t('connection_error')}: {exc}", "error"))
615
+ return
616
+
617
+ if not jobs and page == 1:
618
+ from textual.widgets import Static
619
+ app._clear_and_mount(
620
+ Static(f"[dim]{t('no_fill_sorry_jobs')}[/]"),
621
+ Static(f"\n[dim]{t('press_any_key_back')}[/]"),
622
+ )
623
+ return
624
+
625
+ app._clear_and_mount(FillSorryJobListWidget(
626
+ jobs=jobs, page=page, page_size=page_size, total=total,
627
+ ))