cjm-transcript-correction-tui 0.0.1__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,19 @@
1
+ """Keyboard-first TUI driver for the transcript-correction workflow — a presentation
2
+ driver bound onto `cjm-transcript-correction-core`'s operation vocabulary (the core
3
+ stays headless; this library owns interaction only). Born on-graph: every region of
4
+ this package is authored as graph nodes and projected to `.py`."""
5
+
6
+ import os
7
+
8
+ __version__ = "0.0.1"
9
+
10
+ # PipeWire routing: conda-forge PortAudio ships an ALSA that cannot see the system
11
+ # pipewire/default PCM (hw-only enumeration -> audio lands on the wrong sink, e.g. a
12
+ # monitor instead of the earbuds the OS routes to). Point it at the system ALSA config
13
+ # HERE, in the package __init__, because canonical emit hoists a module own import
14
+ # block above any module-level code — only the package boundary runs strictly before
15
+ # a submodule imports sounddevice. Pre-set env always wins (setdefault).
16
+ if os.path.exists("/usr/share/alsa/alsa.conf"):
17
+ os.environ.setdefault("ALSA_CONFIG_PATH", "/usr/share/alsa/alsa.conf")
18
+ if os.path.isdir("/usr/lib/x86_64-linux-gnu/alsa-lib"):
19
+ os.environ.setdefault("ALSA_PLUGIN_DIR", "/usr/lib/x86_64-linux-gnu/alsa-lib")
@@ -0,0 +1,640 @@
1
+ import shutil
2
+ import subprocess
3
+ import time
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from cjm_context_graph_layer.journal import sidecar_journal_path
7
+ from cjm_substrate_tui_kit.audio import ChunkPlayer, load_chunk
8
+ from cjm_substrate_tui_kit.state import SidecarState
9
+ from cjm_transcript_correction_core.graph import (commit_boundary_shift_correction,
10
+ commit_mark_correction, commit_mark_dismissal,
11
+ commit_prune_amendment, commit_text_correction,
12
+ record_review_markers, start_session)
13
+ from cjm_transcript_correction_core.models import RECOMMENDED_MARK_CLASSES
14
+ from rich.text import Text
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.widgets import Input, Static
18
+
19
+ from .spine import parse_mark_input, plan_boundary_shift, resolve_mark_class_token, SpineView
20
+
21
+
22
+ class CorrectionApp(App):
23
+ """The correction loop, v0 thinnest slice: document-order segment walk with
24
+ VAD-chunk auto-play and in-place fidelity edits, over the shared transcription
25
+ graph through correction-core's operation vocabulary.
26
+
27
+ Interaction contract (DEC 54640079 + the walkthrough capture): the surface is a
28
+ CENTER-PINNED window over the cursor-parameterized effective spine (drive
29
+ round 4 ratification): the focused card's text line sits at the exact screen
30
+ center, neighbor cards stack outward and absorb the varying text heights, so
31
+ the eyes never leave center — segments flow past the pin. Scrolling (keys AND
32
+ wheel) moves the CURSOR, the paint recomposes around it, nothing moves
33
+ unbidden, content never overlaps. Focusing a segment auto-plays its VAD chunk
34
+ from the model-input WAV (immediate-play; churn accepted per the spike). An
35
+ edit commits a `text_content` Correction (+ its REVIEWED marker) and updates
36
+ the local effective text — decisions persist, the worklist stays derived.
37
+ The graph stack opens INSIDE the app (`on_mount`) so the JobQueue lives on
38
+ Textual's event loop."""
39
+
40
+ AUTO_FOCUS = None # the hidden editor Input must not swallow the walk keys at mount
41
+
42
+ SPEEDS = (0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0) # the [ ] playback-rate ladder (0.5/3.0 = the comprehension bounds, drive-round-7 verdict)
43
+
44
+ CSS = """
45
+ #cards { height: 1fr; overflow: hidden hidden; }
46
+ """
47
+
48
+ BINDINGS = [
49
+ Binding("j", "next", "next"),
50
+ Binding("down", "next", "next", show=False),
51
+ Binding("s", "next", "next", show=False),
52
+ Binding("k", "prev", "prev"),
53
+ Binding("up", "prev", "prev", show=False),
54
+ Binding("w", "prev", "prev", show=False),
55
+ Binding("r", "replay", "replay"),
56
+ Binding("left_square_bracket", "speed_down", "slower", key_display="["),
57
+ Binding("right_square_bracket", "speed_up", "faster", key_display="]"),
58
+ Binding("e", "edit", "edit text"),
59
+ Binding("y", "yank", "copy text"),
60
+ Binding("right", "shift_push", "push word", key_display="→"),
61
+ Binding("d", "shift_push", "push word", show=False),
62
+ Binding("left", "shift_pull", "pull word", key_display="←"),
63
+ Binding("a", "shift_pull", "pull word", show=False),
64
+ Binding("space", "reviewed", "mark reviewed"),
65
+ Binding("u", "unreview", "un-review"),
66
+ Binding("m", "mark_quick", "mark"),
67
+ Binding("b", "mark_boundary", "mark boundary"),
68
+ Binding("M", "mark_editor", "mark+class"),
69
+ Binding("n", "next_mark", "next mark"),
70
+ Binding("N", "prev_mark", "prev mark"),
71
+ Binding("p", "next_prune", "next prune"),
72
+ Binding("P", "prev_prune", "prev prune"),
73
+ Binding("escape", "cancel", "cancel/stop", show=False, priority=True),
74
+ Binding("q", "quit_app", "quit"),
75
+ ]
76
+
77
+ def __init__(self, graph_db_path: str, # The shared transcription graph db
78
+ *, source: Optional[str] = None, # Source id or title substring
79
+ manifests_dir: str = ".cjm/manifests", # Capability manifests directory
80
+ rendition: Optional[str] = None, # Rendition selector (None = auto)
81
+ actor: str = "human", # Actor recorded on corrections
82
+ autoplay: bool = True, # Auto-play the focused chunk
83
+ audio_device: Optional[object] = None, # Output device (None = system default)
84
+ resume: bool = True, # Reopen at the source's last-focused segment
85
+ shift_floor_s: float = 0.0): # Min seconds between held-key boundary shifts (0 = ungoverned; the commit guard is the real governor)
86
+ super().__init__()
87
+ self._open_kwargs = dict(source=source, manifests_dir=manifests_dir,
88
+ rendition=rendition)
89
+ self._graph_db_path = graph_db_path
90
+ # Every correction write appends through to the db's sidecar journal (DEC ccbab9f5).
91
+ self._journal_path = sidecar_journal_path(graph_db_path)
92
+ self.view: Optional[SpineView] = None
93
+ self.player: Optional[ChunkPlayer] = None
94
+ self.cursor = 0
95
+ self.actor = actor
96
+ self.autoplay = autoplay
97
+ self.speed = 1.0 # playback rate ([ ] preset ladder; sidecar-persisted preference)
98
+ self.audio_device = audio_device
99
+ self.session_id: Optional[str] = None
100
+ self._marks: Dict[int, str] = {} # cursor position -> local decision echo
101
+ self._mark_class = "suspect" # last-used ⚑ class (m/b repeat it; sidecar-persisted)
102
+ self._input_mode = "edit" # what the hidden Input commits ("edit" | "mark")
103
+ self._shift_busy = False # in-flight boundary-shift commit (key-repeat throttle)
104
+ self._last_shift = 0.0 # last completed shift (monotonic; paint-rate floor)
105
+ self._shift_floor = float(shift_floor_s) # tune with tests_manual/keyrate_probe.py
106
+ self.resume = resume
107
+ self._state_saved = 0.0 # last sidecar bookmark write (monotonic; 1s throttle)
108
+
109
+ def compose(self) -> ComposeResult:
110
+ yield Static("", id="cards")
111
+ yield Static("loading spine…", id="status")
112
+ editor = Input(id="editor")
113
+ editor.display = False
114
+ yield editor
115
+
116
+ async def on_mount(self) -> None:
117
+ self.view = await SpineView.open(self._graph_db_path, **self._open_kwargs)
118
+ self.player = ChunkPlayer(device=self.audio_device)
119
+ sess = await start_session(self.view.queue, self.view.graph_id,
120
+ [self.view.source_id],
121
+ journal_path=self._journal_path)
122
+ self.session_id = sess.id
123
+ state = load_tui_state(self._graph_db_path)
124
+ try:
125
+ # Speed is a PREFERENCE, not a position — restored even with resume=False.
126
+ self.speed = float(state.get("_speed") or 1.0)
127
+ except (TypeError, ValueError):
128
+ self.speed = 1.0
129
+ mc = str(state.get("_mark_class") or "suspect")
130
+ self._mark_class = mc if mc[:1].isalnum() else "suspect" # heal a junk-class sidecar
131
+ if self.resume:
132
+ saved = state.get(self.view.source_id)
133
+ if saved and self.view.size:
134
+ self.cursor = max(0, min(self.view.size - 1, int(saved.get("cursor", 0))))
135
+ self._render()
136
+ if self.autoplay:
137
+ self._play_cursor()
138
+
139
+ def on_resize(self, event) -> None:
140
+ if self.view is not None:
141
+ self._render()
142
+
143
+ def _card_lines(self, pos: int, width: int) -> Tuple[List[Text], int]:
144
+ """One segment card as styled screen lines + the offset of its first body line.
145
+
146
+ FIXED GUTTER, ONE TEXT LANE (presentation agenda item 1): index/time/marks
147
+ live in a fixed-width left column and ALWAYS recede (dim — the eye must be
148
+ unable to accidentally read a timestamp); segment text gets its own
149
+ consistently-indented lane, so walking scans a single vertical column of
150
+ pure prose. Focus emphasis carries over: cursor±1 lane text bright, far
151
+ field dim, the focused card a full-width reverse band."""
152
+ view = self.view
153
+ seg = view.segments[pos]
154
+ gut_w = self._gutter_w
155
+ lane_w = max(10, width - gut_w)
156
+ mark = {"reviewed": "✓", "corrected": "✎"}.get(self._marks.get(pos, ""), "·")
157
+ # Gutter styling must ride SPANS, not the Text base style: lane text is
158
+ # appended onto these same row objects, and a base style would bleed
159
+ # into it (the round-2 drive regression — first two lane lines dimmed).
160
+ g1 = Text()
161
+ g1.append(f"#{seg.index} {mark}", style="dim")
162
+ if seg.id in view.pruned_ids:
163
+ g1.append(" ✂", style="red")
164
+ if seg.id in view.marked_ids:
165
+ g1.append(" ⚑", style="yellow")
166
+ g2 = Text()
167
+ g2.append(f"{seg.start_time:.1f}–{seg.end_time:.1f}s"
168
+ if seg.start_time is not None else "(no audio)", style="dim")
169
+ body = Text(seg.text) if seg.text else Text("(empty)", style="dim")
170
+ if abs(pos - self.cursor) > 1 and seg.text:
171
+ body.stylize("dim")
172
+ lane = body.wrap(self.console, lane_w)
173
+ lines: List[Text] = []
174
+ a = view.aseg_index(pos)
175
+ if a is not None and (pos == 0 or view.aseg_index(pos - 1) != a):
176
+ lines.append(Text(f"━━━ audio segment {a} ━━━", style="yellow"))
177
+ body_offset = len(lines)
178
+ gutter = [g1, g2]
179
+ for i in range(max(len(gutter), len(lane))):
180
+ row = gutter[i] if i < len(gutter) else Text("")
181
+ row.pad_right(max(0, gut_w - row.cell_len))
182
+ if i < len(lane):
183
+ row.append_text(lane[i])
184
+ lines.append(row)
185
+ if pos == self.cursor:
186
+ for ln in lines:
187
+ ln.pad_right(max(0, width - ln.cell_len))
188
+ ln.stylize("reverse")
189
+ return lines, body_offset
190
+
191
+ @property
192
+ def _gutter_w(self) -> int:
193
+ """The source-wide gutter width: sized ONCE from the last segment (the widest
194
+ index + time span), so the text lane's indent never wobbles while walking."""
195
+ last = self.view.segments[-1]
196
+ t_w = (len(f"{last.end_time:.1f}–{last.end_time:.1f}s")
197
+ if last.end_time is not None else 0)
198
+ return max(t_w, len("(no audio)"), len(f"#{last.index}") + 6) + 2 # +6: the ✓/✂/⚑ glyph rail
199
+
200
+ def _render(self) -> None:
201
+ """Center-pinned paint (drive round 4): the focused card's FIRST TEXT LINE
202
+ is pinned to the vertical center of the card area; neighbor cards stack
203
+ outward from it (one blank separator row) and absorb the height variance,
204
+ clipping at the screen edges. The pin never moves — the spine flows past it."""
205
+ view = self.view
206
+ if not view.size:
207
+ self.query_one("#status", Static).update(f"{view.source_title} · empty spine")
208
+ return
209
+ width = max(20, self.size.width)
210
+ height = max(3, self.size.height - 1) # the status line keeps the last row
211
+ rows: List[Optional[Text]] = [None] * height
212
+
213
+ def place(lines: List[Text], top: int) -> None:
214
+ for i, ln in enumerate(lines):
215
+ if 0 <= top + i < height:
216
+ rows[top + i] = ln
217
+
218
+ f_lines, f_off = self._card_lines(self.cursor, width)
219
+ top_f = height // 2 - f_off # body line 0 lands dead center
220
+ place(f_lines, top_f)
221
+ pos, bottom = self.cursor - 1, top_f - 2
222
+ while pos >= 0 and bottom >= 0:
223
+ lines, _ = self._card_lines(pos, width)
224
+ place(lines, bottom - len(lines) + 1)
225
+ bottom -= len(lines) + 1
226
+ pos -= 1
227
+ pos, top = self.cursor + 1, top_f + len(f_lines) + 1
228
+ while pos < view.size and top < height:
229
+ lines, _ = self._card_lines(pos, width)
230
+ place(lines, top)
231
+ top += len(lines) + 1
232
+ pos += 1
233
+ self.query_one("#cards", Static).update(
234
+ Text("\n").join(ln if ln is not None else Text("") for ln in rows))
235
+ done = sum(1 for v in self._marks.values() if v)
236
+ self.query_one("#status", Static).update(
237
+ f"{view.source_title} · segment {self.cursor + 1}/{view.size}"
238
+ f" · marked {done} · ×{self.speed:g} · session {str(self.session_id or '')[:8]}"
239
+ f" · j/k·w/s walk · ←→/a/d shift · r replay · \\[/] speed · e edit · y copy"
240
+ f" · space/u ±reviewed · m/b/M ⚑mark · n/N⚑ p/P✂ jump · q quit")
241
+
242
+ def _play_cursor(self) -> None:
243
+ c = self.view.chunk(self.cursor)
244
+ if c is None:
245
+ self.player.stop()
246
+ return
247
+ self.player.play(load_chunk(c.wav_path, c.start_s, c.end_s, speed=self.speed))
248
+
249
+ def _move(self, delta: int) -> None:
250
+ new = max(0, min(self.view.size - 1, self.cursor + delta))
251
+ if new == self.cursor:
252
+ return
253
+ self.cursor = new
254
+ now = time.monotonic()
255
+ if now - self._state_saved > 1.0: # bookmark survives crashes, not just quits
256
+ save_tui_state(self._graph_db_path, self.view.source_id, new)
257
+ self._state_saved = now
258
+ self._render()
259
+ if self.autoplay:
260
+ self._play_cursor()
261
+
262
+ def action_next(self) -> None:
263
+ self._move(1)
264
+
265
+ def action_prev(self) -> None:
266
+ self._move(-1)
267
+
268
+ def on_mouse_scroll_down(self, event) -> None: # wheel = the same cursor move as keys
269
+ self._move(1)
270
+
271
+ def on_mouse_scroll_up(self, event) -> None:
272
+ self._move(-1)
273
+
274
+ def action_replay(self) -> None:
275
+ self._play_cursor()
276
+
277
+ def _step_speed(self, delta: int) -> None:
278
+ """Step the playback rate along the preset ladder, re-sound the chunk at the
279
+ new rate (immediate audible confirmation), persist the preference (sidecar —
280
+ view state like the cursor bookmark, never a graph write)."""
281
+ i = min(range(len(self.SPEEDS)), key=lambda j: abs(self.SPEEDS[j] - self.speed))
282
+ self.speed = self.SPEEDS[max(0, min(len(self.SPEEDS) - 1, i + delta))]
283
+ save_tui_state(self._graph_db_path, self.view.source_id, self.cursor,
284
+ speed=self.speed)
285
+ self._render()
286
+ self._play_cursor()
287
+
288
+ def action_speed_down(self) -> None:
289
+ self._step_speed(-1)
290
+
291
+ def action_speed_up(self) -> None:
292
+ self._step_speed(1)
293
+
294
+ def action_yank(self) -> None:
295
+ """Copy the focused segment's effective text to the system clipboard —
296
+ sharing a segment must not require a screenshot or re-typing.
297
+
298
+ A clipboard TOOL (wl-copy/xclip/xsel) is the primary path: OSC 52 is
299
+ fire-and-forget and VTE terminals commonly reject it (drive round 5),
300
+ so it stays only as the fallback for tool-less hosts."""
301
+ seg = self.view.segments[self.cursor]
302
+ via = self._copy_system(seg.text)
303
+ if via is None:
304
+ self.copy_to_clipboard(seg.text) # OSC 52 — may be ignored by the terminal
305
+ via = "osc52, terminal-dependent"
306
+ self.query_one("#status", Static).update(
307
+ f"copied segment #{seg.index} text ({len(seg.text)} chars, {via})")
308
+
309
+ def _copy_system(self, text: str) -> Optional[str]:
310
+ """Pipe text to the first available system clipboard tool; None = no tool took it."""
311
+ for cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"],
312
+ ["xsel", "--clipboard", "--input"]):
313
+ if shutil.which(cmd[0]) is None:
314
+ continue
315
+ try:
316
+ subprocess.run(cmd, input=text.encode(), check=True, timeout=2)
317
+ return cmd[0]
318
+ except (OSError, subprocess.SubprocessError):
319
+ continue
320
+ return None
321
+
322
+ def action_edit(self) -> None:
323
+ editor = self.query_one("#editor", Input)
324
+ self._input_mode = "edit"
325
+ editor.value = self.view.segments[self.cursor].text
326
+ editor.display = True
327
+ editor.focus()
328
+
329
+ async def on_input_submitted(self, event) -> None:
330
+ if self._input_mode == "mark":
331
+ await self._submit_mark(event.value)
332
+ return
333
+ seg = self.view.segments[self.cursor]
334
+ new_text = event.value
335
+ if new_text != seg.text:
336
+ await commit_text_correction(
337
+ self.view.queue, self.view.graph_id, self.view.source_id,
338
+ seg.id, new_text, self.session_id,
339
+ old_text=seg.text, actor=self.actor,
340
+ journal_path=self._journal_path)
341
+ seg.text = new_text # local echo of the new effective text
342
+ self._marks[self.cursor] = "corrected"
343
+ # A text-bearing PRUNED position must leave the prune set (the same
344
+ # rescue as boundary shifts): the prune otherwise drops the position —
345
+ # WITH its restored text — from the downstream effective view. Fires
346
+ # on re-submit too (recovery path for edits made before this guard).
347
+ if new_text.strip() and seg.id in self.view.pruned_ids:
348
+ prior = self.view.prune_correction_for(seg.id)
349
+ if prior is not None:
350
+ amended = await commit_prune_amendment(
351
+ self.view.queue, self.view.graph_id, prior, [seg.id],
352
+ self.session_id, actor=self.actor,
353
+ journal_path=self._journal_path)
354
+ self.view.unprune_local(prior["id"], amended)
355
+ self._marks[self.cursor] = "corrected"
356
+ self._close_editor()
357
+ self._render()
358
+
359
+ def _close_editor(self) -> None:
360
+ editor = self.query_one("#editor", Input)
361
+ editor.display = False
362
+ self.set_focus(None)
363
+ self._input_mode = "edit"
364
+
365
+ async def action_reviewed(self) -> None:
366
+ seg = self.view.segments[self.cursor]
367
+ await record_review_markers(self.view.queue, self.view.graph_id,
368
+ self.session_id, [(seg.id, "reviewed")],
369
+ journal_path=self._journal_path)
370
+ self._marks.setdefault(self.cursor, "reviewed")
371
+ self._move(1)
372
+
373
+ async def action_unreview(self) -> None:
374
+ """u: undo an accidental space — appends an 'unreviewed' RE-DECISION
375
+ (review markers are events; the read is latest-wins), so the segment
376
+ returns to undecided for this session. Committed corrections are NOT
377
+ touched: undo those by supersession (re-edit / the opposite shift)."""
378
+ seg = self.view.segments[self.cursor]
379
+ await record_review_markers(self.view.queue, self.view.graph_id,
380
+ self.session_id, [(seg.id, "unreviewed")],
381
+ journal_path=self._journal_path)
382
+ was = self._marks.get(self.cursor)
383
+ if was == "reviewed":
384
+ self._marks.pop(self.cursor, None)
385
+ self._render()
386
+ note = ("" if was != "corrected"
387
+ else " (corrections stay — supersede via re-edit / opposite shift)")
388
+ self.query_one("#status", Static).update(f"un-reviewed #{seg.index}{note}")
389
+
390
+ async def _shift_boundary(self, direction: str) -> None:
391
+ """One [ / ] press: move ONE word across the boundary AFTER the cursor.
392
+
393
+ Commits a boundary_shift Correction (word-level payload, layer 0.0.8
394
+ semantics); when the RECEIVING segment is prune-covered, also commits
395
+ the unprune amendment (the falsified-D14 rescue — without it the
396
+ projection drops the moved text with the pruned position). Key-repeat
397
+ is DROPPED while a commit is in flight, so a held key can only shift
398
+ as fast as the screen shows it (first-drive feedback, 2026-07-12)."""
399
+ now = time.monotonic()
400
+ if self._shift_busy or now - self._last_shift < self._shift_floor:
401
+ return # busy commit OR inside the paint-rate floor — drop the repeat
402
+ self._shift_busy = True
403
+ try:
404
+ await self._shift_boundary_now(direction)
405
+ finally:
406
+ self._last_shift = time.monotonic()
407
+ self._shift_busy = False
408
+
409
+ async def _shift_boundary_now(self, direction: str) -> None:
410
+ view, i = self.view, self.cursor
411
+ status = self.query_one("#status", Static)
412
+ if i + 1 >= view.size:
413
+ status.update("boundary shift: no segment after the cursor")
414
+ return
415
+ if view.aseg_index(i) != view.aseg_index(i + 1):
416
+ status.update("boundary shift: ✋ audio-segment seam — text stays within its audio segment")
417
+ return
418
+ left, right = view.segments[i], view.segments[i + 1]
419
+ plan = plan_boundary_shift(left.text, right.text, direction)
420
+ if plan is None:
421
+ status.update(f"boundary shift: nothing to {direction}")
422
+ return
423
+ moved, new_left, new_right = plan
424
+ await commit_boundary_shift_correction(
425
+ view.queue, view.graph_id, view.source_id, left.id, right.id,
426
+ moved, direction, self.session_id, actor=self.actor,
427
+ journal_path=self._journal_path)
428
+ receiver = right if direction == "push" else left
429
+ if receiver.id in view.pruned_ids:
430
+ prior = view.prune_correction_for(receiver.id)
431
+ if prior is not None:
432
+ amended = await commit_prune_amendment(
433
+ view.queue, view.graph_id, prior, [receiver.id],
434
+ self.session_id, actor=self.actor,
435
+ journal_path=self._journal_path)
436
+ view.unprune_local(prior["id"], amended)
437
+ left.text, right.text = new_left, new_right # local echo (same math as the layer)
438
+ self._marks[i] = "corrected"
439
+ self._marks[i + 1] = "corrected"
440
+ self._render()
441
+
442
+ async def action_shift_push(self) -> None:
443
+ await self._shift_boundary("push")
444
+
445
+ async def action_shift_pull(self) -> None:
446
+ await self._shift_boundary("pull")
447
+
448
+ def _jump_glyph(self, direction: int, ids: set, what: str) -> None:
449
+ """n/N (⚑), p/P (✂): cursor to the next/previous segment in a glyph id
450
+ set (wraps) — resolution passes walk glyphs directly instead of
451
+ scanning thousands of segments (drive find: had to dig for a ✂)."""
452
+ view = self.view
453
+ if not ids:
454
+ self.query_one("#status", Static).update(f"no {what} segments on this source")
455
+ return
456
+ for step in range(1, view.size + 1):
457
+ j = (self.cursor + direction * step) % view.size
458
+ if view.segments[j].id in ids:
459
+ self._move(j - self.cursor)
460
+ return
461
+
462
+ def action_next_mark(self) -> None:
463
+ self._jump_glyph(1, self.view.marked_ids, "⚑ marked")
464
+
465
+ def action_prev_mark(self) -> None:
466
+ self._jump_glyph(-1, self.view.marked_ids, "⚑ marked")
467
+
468
+ def action_next_prune(self) -> None:
469
+ self._jump_glyph(1, self.view.pruned_ids, "✂ pruned")
470
+
471
+ def action_prev_prune(self) -> None:
472
+ self._jump_glyph(-1, self.view.pruned_ids, "✂ pruned")
473
+
474
+ def _mark_class_menu(self) -> List[str]:
475
+ """Selectable classes for the M picker: the recommended slate first, then
476
+ classes carried by this source's OPEN marks — dismissing a class's last
477
+ open mark removes it (junk cleanup); proven classes persist by promotion
478
+ into RECOMMENDED_MARK_CLASSES (open vocab, DEC 2a231843)."""
479
+ return list(RECOMMENDED_MARK_CLASSES) + [
480
+ c for c in self.view.seen_mark_classes if c not in RECOMMENDED_MARK_CLASSES]
481
+
482
+ async def action_mark_quick(self) -> None:
483
+ """m: mark the focused segment with the last-used class and keep walking —
484
+ the held-back-corrections gesture (DEC 42854519) must cost one keystroke."""
485
+ seg = self.view.segments[self.cursor]
486
+ await self._commit_mark({"kind": "segment", "segment_id": seg.id},
487
+ self._mark_class, None)
488
+
489
+ async def action_mark_boundary(self) -> None:
490
+ """b: mark the boundary AFTER the cursor (the shift gesture's coordinates).
491
+
492
+ Unlike shifts, audio-segment seams are NOT refused — a suspect seam is
493
+ exactly what a boundary mark is for."""
494
+ view, i = self.view, self.cursor
495
+ if i + 1 >= view.size:
496
+ self.query_one("#status", Static).update("boundary mark: no segment after the cursor")
497
+ return
498
+ await self._commit_mark({"kind": "boundary",
499
+ "boundary_after": view.segments[i].id,
500
+ "right_segment_id": view.segments[i + 1].id},
501
+ self._mark_class, None)
502
+
503
+ def action_mark_editor(self) -> None:
504
+ """M: class-picker mark — `class-or-# ["snippet"] [note...]`: a leading
505
+ digit picks from the numbered class menu (recommended slate + this
506
+ source's journaled classes); a snippet found in the segment text becomes
507
+ a SPAN anchor; a punctuation-led token dismisses ALL open marks at the
508
+ cursor."""
509
+ editor = self.query_one("#editor", Input)
510
+ self._input_mode = "mark"
511
+ editor.value = f"{self._mark_class} "
512
+ editor.display = True
513
+ editor.focus()
514
+ menu = self._mark_class_menu()
515
+ self.query_one("#status", Static).update(
516
+ 'mark: class-or-# ["snippet"] [note] · - dismiss · '
517
+ + " ".join(f"{i + 1}:{c}" for i, c in enumerate(menu)))
518
+
519
+ async def _submit_mark(self, raw: str) -> None:
520
+ seg = self.view.segments[self.cursor]
521
+ self._close_editor()
522
+ tokens = raw.split()
523
+ if not tokens:
524
+ self._render()
525
+ return
526
+ first = tokens[0].strip('`"\'')
527
+ if first.startswith("-") or not first:
528
+ # Dismissal gesture, tolerant of formatting fumbles ('`-`', '- oops'):
529
+ # a punctuation-led token must never mint a junk class and hijack the
530
+ # last-used class (drive find, 2026-07-19). ALL open marks at the
531
+ # cursor go — the ⚑ must actually clear (boundary marks from a
532
+ # neighbor's b press anchor this segment too).
533
+ marks = self.view.marks_for(seg.id)
534
+ if not marks:
535
+ self._render()
536
+ self.query_one("#status", Static).update(f"no open mark on #{seg.index}")
537
+ return
538
+ for m in marks:
539
+ await commit_mark_dismissal(
540
+ self.view.queue, self.view.graph_id, self.view.source_id,
541
+ m["id"], self.session_id, actor=self.actor,
542
+ journal_path=self._journal_path)
543
+ self.view.dismiss_mark_local(m["id"])
544
+ classes = ", ".join(str((m.get("payload") or {}).get("mark_class")) for m in marks)
545
+ self._render()
546
+ self.query_one("#status", Static).update(
547
+ f"dismissed {len(marks)} mark(s) on #{seg.index} [{classes}]")
548
+ return
549
+ raw, err = resolve_mark_class_token(raw, self._mark_class_menu())
550
+ if err:
551
+ self._render()
552
+ self.query_one("#status", Static).update(f"mark: {err}")
553
+ return
554
+ parsed = parse_mark_input(raw, seg.text)
555
+ if parsed is None:
556
+ self._render()
557
+ return
558
+ mark_class, span, note = parsed
559
+ if span is not None:
560
+ start, end, snapshot = span
561
+ anchor = {"kind": "span", "segment_id": seg.id, "char_start": start,
562
+ "char_end": end, "text_snapshot": snapshot}
563
+ else:
564
+ anchor = {"kind": "segment", "segment_id": seg.id}
565
+ await self._commit_mark(anchor, mark_class, note)
566
+
567
+ async def _commit_mark(self, anchor: Dict[str, Any], mark_class: str,
568
+ note: Optional[str]) -> None:
569
+ """Commit one mark Correction + local echo (the ⚑ paints immediately).
570
+
571
+ A mark records attention, not a decision: no review marker, no text
572
+ change, the cursor stays put — mark and keep walking."""
573
+ try:
574
+ mark_id = await commit_mark_correction(
575
+ self.view.queue, self.view.graph_id, self.view.source_id,
576
+ anchor, mark_class, self.session_id, actor=self.actor, note=note,
577
+ journal_path=self._journal_path)
578
+ except ValueError as e:
579
+ self._render()
580
+ self.query_one("#status", Static).update(f"mark refused: {e}")
581
+ return
582
+ self._mark_class = mark_class
583
+ save_tui_state(self._graph_db_path, self.view.source_id, self.cursor,
584
+ mark_class=mark_class)
585
+ self.view.add_mark_local({"id": mark_id, "correction_type": "mark",
586
+ "payload": {"operation": "mark", "anchor": dict(anchor),
587
+ "mark_class": mark_class}})
588
+ self._render()
589
+ seg = self.view.segments[self.cursor]
590
+ suffix = f" — {note}" if note else ""
591
+ self.query_one("#status", Static).update(
592
+ f"⚑ #{seg.index} [{mark_class}] ({anchor['kind']}){suffix}")
593
+
594
+ def action_cancel(self) -> None:
595
+ editor = self.query_one("#editor", Input)
596
+ if editor.display:
597
+ self._close_editor()
598
+ self._render()
599
+ else:
600
+ self.player.stop()
601
+
602
+ async def action_quit_app(self) -> None:
603
+ if self.view is not None:
604
+ save_tui_state(self._graph_db_path, self.view.source_id, self.cursor,
605
+ speed=self.speed)
606
+ if self.player is not None:
607
+ self.player.close()
608
+ if self.view is not None:
609
+ await self.view.close()
610
+ self.exit()
611
+
612
+
613
+ def load_tui_state(
614
+ graph_db_path: str, # The graph db whose sidecar state file to read
615
+ ) -> Dict[str, Any]: # {source_id: {"cursor": int, "ts": float}}; empty when absent/corrupt
616
+ """Read the per-graph TUI sidecar state (last-focused positions)."""
617
+ return SidecarState(f"{graph_db_path}.tui-state.json").load()
618
+
619
+
620
+ def save_tui_state(
621
+ graph_db_path: str, # The graph db whose sidecar state file to write
622
+ source_id: str, # Source whose position is being remembered
623
+ cursor: int, # Last-focused segment position
624
+ speed: Optional[float] = None, # Playback-rate preference (db-wide `_speed`; None = leave as-is)
625
+ mark_class: Optional[str] = None, # Last-used ⚑ class (db-wide `_mark_class`; None = leave as-is)
626
+ ) -> None:
627
+ """Merge one source's last-focused position into the sidecar state file.
628
+
629
+ VIEW state, not knowledge — it lives in a local sidecar next to the db,
630
+ never as a graph write (the cursor is where the eye was, not a decision).
631
+ Write failures are silently tolerated: losing a bookmark must never break
632
+ the correction loop."""
633
+ store = SidecarState(f"{graph_db_path}.tui-state.json")
634
+ state = store.load()
635
+ state[source_id] = {"cursor": int(cursor), "ts": time.time()}
636
+ if speed is not None:
637
+ state["_speed"] = float(speed)
638
+ if mark_class is not None:
639
+ state["_mark_class"] = str(mark_class)
640
+ store.write(state)