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.
- cjm_transcript_correction_tui/__init__.py +19 -0
- cjm_transcript_correction_tui/app.py +640 -0
- cjm_transcript_correction_tui/cli.py +68 -0
- cjm_transcript_correction_tui/spine.py +321 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/METADATA +63 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/RECORD +10 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/WHEEL +5 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/entry_points.txt +2 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/licenses/LICENSE +201 -0
- cjm_transcript_correction_tui-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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)
|