mdbub 0.3.7__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,1471 @@
1
+ """Quick mode for mdbub - A lightning-fast, keyboard-driven mindmap editor."""
2
+
3
+ import fcntl
4
+ import logging
5
+ import os
6
+ import select
7
+ import signal
8
+ import sys
9
+ import termios
10
+ import threading
11
+ import time
12
+ import tty
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional, TextIO, Tuple
15
+
16
+ from rich.console import Console
17
+ from rich.text import Text
18
+
19
+ # UI Config
20
+ from mdbub.commands.quickmode_config import (
21
+ load_quickmode_config,
22
+ load_session,
23
+ save_session,
24
+ )
25
+ from mdbub.core.mindmap import MindMapNode as Node
26
+ from mdbub.core.mindmap import mindmap_to_markdown, parse_markdown_to_mindmap
27
+
28
+ CONFIG: Dict[str, Any] = load_quickmode_config()
29
+
30
+ # Load all color constants directly from _FG/_BG keys (no legacy support)
31
+ COLOR_PRINTS_ACCENT_FG = CONFIG["COLOR_PRINTS_ACCENT_FG"]
32
+ COLOR_PRINTS_ACCENT_BG = CONFIG["COLOR_PRINTS_ACCENT_BG"]
33
+ COLOR_PRINTS_HIGHLIGHT_FG = CONFIG["COLOR_PRINTS_HIGHLIGHT_FG"]
34
+ COLOR_PRINTS_HIGHLIGHT_BG = CONFIG["COLOR_PRINTS_HIGHLIGHT_BG"]
35
+ COLOR_PRINTS_TEXT_FG = CONFIG["COLOR_PRINTS_TEXT_FG"]
36
+ COLOR_PRINTS_TEXT_BG = CONFIG["COLOR_PRINTS_TEXT_BG"]
37
+ COLOR_PRINTS_DIM_FG = CONFIG["COLOR_PRINTS_DIM_FG"]
38
+ COLOR_PRINTS_DIM_BG = CONFIG["COLOR_PRINTS_DIM_BG"]
39
+ COLOR_PRINTS_WARNING_FG = CONFIG["COLOR_PRINTS_WARNING_FG"]
40
+ COLOR_PRINTS_WARNING_BG = CONFIG["COLOR_PRINTS_WARNING_BG"]
41
+ COLOR_PRINTS_SUCCESS_FG = CONFIG["COLOR_PRINTS_SUCCESS_FG"]
42
+ COLOR_PRINTS_SUCCESS_BG = CONFIG["COLOR_PRINTS_SUCCESS_BG"]
43
+ COLOR_PRINTS_ERROR_FG = CONFIG["COLOR_PRINTS_ERROR_FG"]
44
+ COLOR_PRINTS_ERROR_BG = CONFIG["COLOR_PRINTS_ERROR_BG"]
45
+ COLOR_PRINTS_STATUS_BAR_FG = CONFIG["COLOR_PRINTS_STATUS_BAR_FG"]
46
+ COLOR_PRINTS_STATUS_BAR_BG = CONFIG["COLOR_PRINTS_STATUS_BAR_BG"]
47
+ COLOR_PRINTS_BREADCRUMB_BAR_FG = CONFIG["COLOR_PRINTS_BREADCRUMB_BAR_FG"]
48
+ COLOR_PRINTS_BREADCRUMB_BAR_BG = CONFIG["COLOR_PRINTS_BREADCRUMB_BAR_BG"]
49
+ COLOR_PRINTS_CHILD_HIGHLIGHT_FG = CONFIG["COLOR_PRINTS_CHILD_HIGHLIGHT_FG"]
50
+ COLOR_PRINTS_CHILD_HIGHLIGHT_BG = CONFIG["COLOR_PRINTS_CHILD_HIGHLIGHT_BG"]
51
+ COLOR_PRINTS_CLEAR = CONFIG["COLOR_PRINTS_CLEAR"] # Clear screen
52
+ COLOR_PRINTS_RESET = CONFIG["COLOR_PRINTS_RESET"] # Reset
53
+ COLOR_PRINTS_BLINK = CONFIG["COLOR_PRINTS_BLINK"] # Rich blink style (not ANSI)
54
+ STATUS_MESSAGE_TIMEOUT_SHORT = float(CONFIG["STATUS_MESSAGE_TIMEOUT_SHORT"])
55
+ STATUS_MESSAGE_TIMEOUT = float(CONFIG["STATUS_MESSAGE_TIMEOUT"])
56
+ STATUS_MESSAGE_TIMEOUT_LONG = float(CONFIG["STATUS_MESSAGE_TIMEOUT_LONG"])
57
+ MAX_VISIBLE_CHILDREN = int(CONFIG["MAX_VISIBLE_CHILDREN"])
58
+ MAX_NODE_LABEL_VIZ_LENGTH = int(CONFIG["MAX_NODE_LABEL_VIZ_LENGTH"])
59
+ MAX_BREADCRUMB_NODE_VIZ_LENGTH = int(CONFIG["MAX_BREADCRUMB_NODE_VIZ_LENGTH"])
60
+ MAX_CHILDNODE_VIZ_LENGTH = int(CONFIG["MAX_CHILDNODE_VIZ_LENGTH"])
61
+ COLOR_BREADCRUMBS = str(CONFIG["COLOR_BREADCRUMBS"])
62
+ COLOR_BREADCRUMBS_ROOT = str(CONFIG["COLOR_BREADCRUMBS_ROOT"])
63
+ COLOR_BREADCRUMBS_CURRENT = str(CONFIG["COLOR_BREADCRUMBS_CURRENT"])
64
+ COLOR_CURRENT_NODE = str(CONFIG["COLOR_CURRENT_NODE"])
65
+ COLOR_SELECTED_CHILD = str(CONFIG["COLOR_SELECTED_CHILD"])
66
+ COLOR_CHILD = str(CONFIG["COLOR_CHILD"])
67
+ COLOR_PAGINATION = str(CONFIG["COLOR_PAGINATION"])
68
+ COLOR_POSITION = str(CONFIG["COLOR_POSITION"])
69
+ COLOR_STATUS = str(CONFIG["COLOR_STATUS"])
70
+ COLOR_HOTKEYS = str(CONFIG["COLOR_HOTKEYS"])
71
+ COLOR_ERROR = str(CONFIG["COLOR_ERROR"])
72
+ COLOR_SUCCESS = str(CONFIG["COLOR_SUCCESS"])
73
+ SYMBOL_BULLET = str(CONFIG["SYMBOL_BULLET"])
74
+ SYMBOL_BRANCH = str(CONFIG["SYMBOL_BRANCH"])
75
+ SYMBOL_ROOT = str(CONFIG["SYMBOL_ROOT"])
76
+ SYMBOL_MORE_LEFT = str(CONFIG["SYMBOL_MORE_LEFT"])
77
+ SYMBOL_MORE_RIGHT = str(CONFIG["SYMBOL_MORE_RIGHT"])
78
+ SYMBOL_CHILDNODE_OPENWRAP = str(CONFIG["SYMBOL_CHILDNODE_OPENWRAP"])
79
+ SYMBOL_CHILDNODE_CLOSEWRAP = str(CONFIG["SYMBOL_CHILDNODE_CLOSEWRAP"])
80
+ SYMBOL_BREADCRUMBNODE_OPENWRAP = str(CONFIG["SYMBOL_BREADCRUMBNODE_OPENWRAP"])
81
+ SYMBOL_BREADCRUMBNODE_CLOSEWRAP = str(CONFIG["SYMBOL_BREADCRUMBNODE_CLOSEWRAP"])
82
+
83
+ # UI states
84
+ STATE_READY = "Ready"
85
+ STATE_EDITING = "Editing"
86
+ STATE_SAVING = "Saving..."
87
+ STATE_SAVED = "Saved"
88
+ STATE_SEARCHING = "Searching"
89
+ STATE_DELETE = "Confirm Delete"
90
+
91
+
92
+ class QuickModeUI:
93
+ """Handles rendering of the Quick Mode interface."""
94
+
95
+ def __init__(self, state: "QuickModeState"):
96
+ self.state = state
97
+ self.console = Console()
98
+ self.term_size = self.console.size
99
+ self.live = None
100
+
101
+ def get_term_size(self) -> Tuple[int, int]:
102
+ """Get current terminal dimensions."""
103
+ # Cast to Tuple[int, int] for mypy
104
+ return tuple(self.console.size) # type: ignore
105
+
106
+ def render_breadcrumbs(self) -> Text:
107
+ """Render the breadcrumb navigation showing the path to current node."""
108
+ result = Text("")
109
+
110
+ # At the root node, just show the root symbol
111
+ if not self.state.path:
112
+ result.append(SYMBOL_ROOT, style=COLOR_BREADCRUMBS_ROOT)
113
+ return result
114
+
115
+ # Build the full path including the root node
116
+ node = self.state.mindmap
117
+ path_segments = [node.label] # Start with root node label
118
+
119
+ # Follow the path to collect node labels
120
+ for i, idx in enumerate(self.state.path):
121
+ if idx < len(node.children):
122
+ node = node.children[idx]
123
+ path_segments.append(node.label)
124
+
125
+ # Helper function to truncate long node names
126
+ def truncate_node_name(name: str) -> str:
127
+ if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
128
+ return name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3] + "..."
129
+ return name
130
+
131
+ # Start the breadcrumb with the root node label (truncated if needed)
132
+ root_name = truncate_node_name(path_segments[0])
133
+ result.append(
134
+ f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{root_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
135
+ style=COLOR_BREADCRUMBS_ROOT,
136
+ ) # Root node label
137
+
138
+ # Handle the path display
139
+ if len(path_segments) > 3: # Root + at least 2 levels deep
140
+ # Handle truncation for long paths
141
+ result.append(" > ", style=COLOR_BREADCRUMBS)
142
+ # Truncate first level node name if needed
143
+ first_level_name = truncate_node_name(path_segments[1])
144
+ result.append(
145
+ f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
146
+ style=COLOR_BREADCRUMBS,
147
+ ) # First level
148
+
149
+ if len(path_segments) > 4: # More than 3 levels deep
150
+ # Add '... >' for each hidden level
151
+ hidden_levels = (
152
+ len(path_segments) - 4
153
+ ) # -4 because we show root, first, parent and current
154
+ result.append(" >", style=COLOR_BREADCRUMBS)
155
+
156
+ # Add '... >' for each hidden level
157
+ for _ in range(hidden_levels):
158
+ result.append("... >", style=COLOR_BREADCRUMBS)
159
+ result.append(" ", style=COLOR_BREADCRUMBS)
160
+
161
+ # Remove extra space at the end (already added inside the loop)
162
+ if hidden_levels > 0:
163
+ result.pop()
164
+ else:
165
+ result.append(" > ", style=COLOR_BREADCRUMBS)
166
+
167
+ # Truncate parent node name if needed
168
+ parent_name = truncate_node_name(path_segments[-2])
169
+ result.append(
170
+ f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
171
+ style=COLOR_BREADCRUMBS_CURRENT,
172
+ ) # Parent of current
173
+ elif len(path_segments) > 2: # Root + 1 level deep
174
+ # Show path directly
175
+ for i, segment in enumerate(path_segments[:-1]): # Exclude current node
176
+ if i > 0: # Skip root node label (already added)
177
+ result.append(" > ", style=COLOR_BREADCRUMBS)
178
+ # Truncate segment name if needed
179
+ segment_name = truncate_node_name(segment)
180
+ result.append(
181
+ f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{segment_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
182
+ style=COLOR_BREADCRUMBS,
183
+ )
184
+
185
+ return result
186
+
187
+ def render_current_node(self) -> Text:
188
+ """Render the current node with bullet and edit cursor if needed."""
189
+ text = Text()
190
+
191
+ if self.state.editing:
192
+ # Show input buffer with cursor
193
+ text.append(self.state.input_buffer, style=COLOR_CURRENT_NODE)
194
+ text.append("|", style="blink") # Cursor
195
+ else:
196
+ # Show node label
197
+ text.append(self.state.current_node.label, style=COLOR_CURRENT_NODE)
198
+
199
+ return text
200
+
201
+ def render_children(self) -> Text:
202
+ """Render the children of the current node in a horizontal layout."""
203
+ text = Text()
204
+ current_node = self.state.current_node
205
+
206
+ if not current_node.children:
207
+ text.append("No children", style=COLOR_CHILD)
208
+ return text
209
+
210
+ # Calculate pagination
211
+ total_children = len(current_node.children)
212
+ selected_idx = self.state.selected_index
213
+
214
+ # Calculate window start and end
215
+ window_size = min(MAX_VISIBLE_CHILDREN, total_children)
216
+ window_half = window_size // 2
217
+
218
+ # Ensure selected item is in the middle when possible
219
+ window_start = max(
220
+ 0, min(selected_idx - window_half, total_children - window_size)
221
+ )
222
+ window_end = min(window_start + window_size, total_children)
223
+
224
+ # Add left pagination indicator if needed
225
+ if window_start > 0:
226
+ text.append("< ", style=COLOR_PAGINATION)
227
+
228
+ # Helper function to truncate long node names
229
+ def truncate_child_name(name: str) -> str:
230
+ if len(name) > MAX_CHILDNODE_VIZ_LENGTH:
231
+ return name[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
232
+ return name
233
+
234
+ # Add children
235
+ for i in range(window_start, window_end):
236
+ child = current_node.children[i]
237
+ # Truncate child name if needed
238
+ child_name = truncate_child_name(child.label)
239
+ style = COLOR_SELECTED_CHILD if i == selected_idx else COLOR_CHILD
240
+ text.append(
241
+ f"{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP} ",
242
+ style=style,
243
+ )
244
+
245
+ # Add right pagination indicator if needed
246
+ if window_end < total_children:
247
+ text.append("> ", style=COLOR_PAGINATION)
248
+
249
+ # Add position indicator if there are multiple pages
250
+ if total_children > window_size:
251
+ text.append(f"({selected_idx + 1}/{total_children})", style=COLOR_POSITION)
252
+
253
+ return text
254
+
255
+ def render_status_bar(self) -> Text:
256
+ """Render the status bar with state and hotkeys."""
257
+ text = Text()
258
+
259
+ # Show all status messages
260
+ if self.state.message[0]:
261
+ status_text = self.state.message[0]
262
+ status_color = self.state.message[1]
263
+ text.append(f"{status_text} ", style=status_color)
264
+ elif self.state.editing:
265
+ text.append("Editing ", style="yellow")
266
+ text.append("↵:save esc:cancel", style=COLOR_HOTKEYS)
267
+ return text
268
+
269
+ # Mini status hotkeys
270
+ # TODO: Replace with hotkey map
271
+ if not self.state.editing:
272
+ text.append(
273
+ "^c/^d:quit ↑↓←→:nav tab:add-child enter:add-sibling del:delete",
274
+ style=COLOR_HOTKEYS,
275
+ )
276
+
277
+ return text
278
+
279
+ def render(self) -> Text:
280
+ """Render the complete UI."""
281
+ # Check for expired temporary messages before rendering
282
+ self.state.check_expired_message()
283
+
284
+ content = Text()
285
+
286
+ # Add breadcrumbs and current node on the same line
287
+ breadcrumbs = self.render_breadcrumbs()
288
+ content.append(breadcrumbs)
289
+ content.append(" ")
290
+ content.append(self.render_current_node())
291
+
292
+ # Add children on the next line
293
+ content.append("\n")
294
+ content.append(self.render_children())
295
+
296
+ # Add status bar on the next line only if there's something to show
297
+ status = self.render_status_bar()
298
+ if status.plain:
299
+ content.append("\n")
300
+ content.append(status)
301
+
302
+ return content
303
+
304
+
305
+ @dataclass
306
+ class QuickModeState:
307
+ """Manages the state of the quick mode interface."""
308
+
309
+ mindmap: Node
310
+ path: List[int] = field(default_factory=list) # Path to current node
311
+ selected_index: int = 0
312
+ editing: bool = False
313
+ input_buffer: str = ""
314
+ dirty: bool = False
315
+ should_quit: bool = False
316
+ message: Tuple[str, str] = field(
317
+ default_factory=lambda: ("", "")
318
+ ) # (message, color)
319
+ message_timestamp: float = 0.0 # When the message was set
320
+ message_temporary: bool = False # Whether the message should auto-expire
321
+ message_timeout: float = 0.0 # Timeout in seconds for temporary messages
322
+ version: str = ""
323
+ build_info: str = ""
324
+ search_mode: bool = False
325
+ search_term: str = ""
326
+ search_results: List[Tuple[List[int], int]] = field(
327
+ default_factory=list
328
+ ) # List of (path, child_index) tuples for each match
329
+ search_result_index: int = 0 # Current position in search results
330
+ search_result_mode: bool = (
331
+ False # True when we've executed search and are browsing results
332
+ )
333
+ pre_search_path: List[int] = field(
334
+ default_factory=list
335
+ ) # Path before search was started
336
+ pre_search_index: int = 0 # Selected index before search was started
337
+ delete_confirm: bool = False
338
+
339
+ @property
340
+ def current_node(self) -> Node:
341
+ """Get the currently selected node."""
342
+ node = self.mindmap
343
+ for idx in self.path:
344
+ if 0 <= idx < len(node.children):
345
+ node = node.children[idx]
346
+ else:
347
+ # Reset to root if path becomes invalid
348
+ self.path = []
349
+ return self.mindmap
350
+ return node
351
+
352
+ @property
353
+ def parent_node(self) -> Optional[Node]:
354
+ """Get the parent of the current node."""
355
+ if not self.path:
356
+ return None
357
+ return self.get_node_at_path(self.path[:-1])
358
+
359
+ def get_node_at_path(self, path: List[int]) -> Node:
360
+ """Get node at the given path."""
361
+ node = self.mindmap
362
+ for idx in path:
363
+ if 0 <= idx < len(node.children):
364
+ node = node.children[idx]
365
+ else:
366
+ raise IndexError("Invalid path")
367
+ return node
368
+
369
+ def set_message(
370
+ self,
371
+ text: str,
372
+ color: str = "",
373
+ temporary: bool = False,
374
+ timeout: float = STATUS_MESSAGE_TIMEOUT,
375
+ ) -> None:
376
+ """Set a status message to display to the user.
377
+
378
+ Args:
379
+ text: The message text
380
+ color: Color for the message
381
+ temporary: If True, message will auto-expire after timeout seconds
382
+ timeout: Number of seconds before the message expires (only used if temporary=True)
383
+ """
384
+ self.message = (text, color or "default")
385
+ self.message_timestamp = time.time()
386
+ self.message_temporary = temporary
387
+ self.message_timeout = timeout if temporary else 0.0
388
+
389
+ def clear_message(self) -> None:
390
+ """Clear any status message."""
391
+ self.message = ("", "")
392
+ self.message_temporary = False
393
+ self.message_timeout = 0.0
394
+
395
+ def check_expired_message(self) -> None:
396
+ """Check if a temporary message has expired and clear it if needed."""
397
+ if self.message_temporary and self.message[0]:
398
+ current_time = time.time()
399
+ elapsed = current_time - self.message_timestamp
400
+ # Print debug info to console about message timing
401
+ # print(f"DEBUG: Message '{self.message[0]}' age: {elapsed:.1f}s vs timeout {self.message_timeout}s", file=sys.stderr)
402
+ if elapsed >= self.message_timeout:
403
+ # print(f"DEBUG: Clearing message '{self.message[0]}'", file=sys.stderr)
404
+ self.clear_message()
405
+
406
+ def navigate_prev_sibling(self) -> None:
407
+ """Move selection to previous sibling (left)."""
408
+ # print(f"\nDEBUG: navigate_prev_sibling called, current selected_index={self.selected_index}")
409
+ if self.selected_index > 0:
410
+ self.selected_index -= 1
411
+ self.clear_message()
412
+ # print(f"DEBUG: selected_index now {self.selected_index}")
413
+ # else:
414
+ # print("DEBUG: Can't navigate to previous sibling (already at first sibling)")
415
+
416
+ def navigate_next_sibling(self) -> None:
417
+ """Move selection to next sibling (right)."""
418
+ # print(f"\nDEBUG: navigate_next_sibling called, current selected_index={self.selected_index}")
419
+ # print(f"DEBUG: current node has {len(self.current_node.children)} children")
420
+ if self.selected_index < len(self.current_node.children) - 1:
421
+ self.selected_index += 1
422
+ self.clear_message()
423
+ # print(f"DEBUG: selected_index now {self.selected_index}")
424
+ # else:
425
+ # print("DEBUG: Can't navigate to next sibling (already at last sibling)")
426
+
427
+ def navigate_to_parent(self) -> None:
428
+ """Move to parent node (up the tree)."""
429
+ # print(f"\nDEBUG: navigate_to_parent called, current path={self.path}")
430
+ if self.path:
431
+ index_to_remember = self.path[-1] # Remember which child we came from
432
+ self.path.pop()
433
+ self.selected_index = (
434
+ index_to_remember # Position cursor on the child we came from
435
+ )
436
+ self.clear_message()
437
+ # print(f"DEBUG: Moved to parent, new path={self.path}, selected_index={self.selected_index}")
438
+ # else:
439
+ # print("DEBUG: Can't navigate to parent (already at root)")
440
+
441
+ def navigate_to_child(self) -> None:
442
+ """Move to selected child node (down the tree)."""
443
+ # print(f"\nDEBUG: navigate_to_child called")
444
+ if self.current_node.children:
445
+ self.path.append(self.selected_index)
446
+ self.selected_index = 0
447
+ self.clear_message()
448
+ # print(f"DEBUG: Moved into child, new path={self.path}")
449
+ # else:
450
+ # print("DEBUG: Can't navigate to child (no children)")
451
+
452
+ def start_editing(self) -> None:
453
+ """Start editing the current node."""
454
+ self.editing = True
455
+ self.input_buffer = self.current_node.label
456
+
457
+ def start_inserting(self) -> None:
458
+ self.editing = True
459
+ self.input_buffer = self.current_node.label
460
+
461
+ def save_edit(self) -> None:
462
+ """Save the current edit."""
463
+ from mdbub.core.mindmap import _parse_node_metadata
464
+
465
+ if self.input_buffer != self.current_node.label:
466
+ # Re-parse label for tags/metadata
467
+ label, metadata = _parse_node_metadata(self.input_buffer)
468
+ truncated = False
469
+ if len(label) > MAX_NODE_LABEL_VIZ_LENGTH:
470
+ label = label[:MAX_NODE_LABEL_VIZ_LENGTH] + "... [truncated]"
471
+ truncated = True
472
+ self.current_node.label = label
473
+ # Only update tags in metadata (preserve other keys)
474
+ if "tags" in metadata:
475
+ self.current_node.metadata["tags"] = metadata["tags"]
476
+ elif "tags" in self.current_node.metadata:
477
+ del self.current_node.metadata["tags"]
478
+ # Optionally handle other metadata keys here
479
+ self.dirty = True
480
+ if truncated:
481
+ self.set_message(
482
+ f"Node label was too long and truncated to {MAX_NODE_LABEL_VIZ_LENGTH} chars.",
483
+ "yellow",
484
+ temporary=True,
485
+ timeout=STATUS_MESSAGE_TIMEOUT_LONG,
486
+ )
487
+ else:
488
+ self.set_message(
489
+ "Updated node with changes",
490
+ "green",
491
+ temporary=True,
492
+ timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
493
+ )
494
+ self.editing = False
495
+ self.input_buffer = ""
496
+
497
+ def cancel_edit(self) -> None:
498
+ """Cancel the current edit."""
499
+ self.set_message(
500
+ "Cancelling node changes",
501
+ "yellow",
502
+ temporary=True,
503
+ timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
504
+ )
505
+ self.editing = False
506
+ self.input_buffer = ""
507
+
508
+ def add_child(self) -> None:
509
+ """Add a new child node and navigate to it."""
510
+ new_node = Node("")
511
+ self.current_node.children.append(new_node)
512
+ # Store the index of the new child
513
+ child_index = len(self.current_node.children) - 1
514
+ # Navigate into the new child node
515
+ self.path.append(child_index)
516
+ self.selected_index = 0 # Reset selection to first position in the new level
517
+ self.dirty = True
518
+ self.start_editing()
519
+
520
+ def add_sibling(self) -> None:
521
+ """Add a new sibling node."""
522
+ if not self.path:
523
+ self.set_message("Cannot add sibling to root", "yellow", temporary=True)
524
+ return
525
+
526
+ parent = self.parent_node
527
+ if not parent:
528
+ self.set_message(
529
+ "Cannot add a sibling when there is no parent", "yellow", temporary=True
530
+ )
531
+ return
532
+
533
+ new_node = Node("")
534
+ insert_pos = self.path[-1] + 1
535
+ parent.children.insert(insert_pos, new_node)
536
+ self.path[-1] = insert_pos
537
+ self.dirty = True
538
+ self.start_editing()
539
+
540
+ def delete_node(self) -> None:
541
+ """Delete the selected child node."""
542
+ # If there are no children, there's nothing to delete
543
+ if not self.current_node.children:
544
+ self.set_message("No children to delete", "yellow", temporary=True)
545
+ return
546
+
547
+ # Get the selected child index and node
548
+ selected_idx = self.selected_index
549
+ if selected_idx >= len(self.current_node.children):
550
+ return
551
+
552
+ selected_node = self.current_node.children[selected_idx]
553
+
554
+ # Confirm deletion if not already confirming
555
+ if not self.delete_confirm:
556
+ self.delete_confirm = True
557
+ # Use the same wrapper style and truncation for the node name in the confirmation message
558
+ node_label = selected_node.label
559
+ if len(node_label) > MAX_CHILDNODE_VIZ_LENGTH:
560
+ node_label = node_label[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
561
+ wrapped_node = (
562
+ f"{SYMBOL_CHILDNODE_OPENWRAP}{node_label}{SYMBOL_CHILDNODE_CLOSEWRAP}"
563
+ )
564
+ self.set_message(f"Delete {wrapped_node}? (y/n)", "yellow")
565
+ return
566
+
567
+ # Delete the selected child
568
+ del self.current_node.children[selected_idx]
569
+
570
+ # Adjust selected index if we deleted the last child
571
+ if (
572
+ self.selected_index >= len(self.current_node.children)
573
+ and self.current_node.children
574
+ ):
575
+ self.selected_index = len(self.current_node.children) - 1
576
+
577
+ self.dirty = True
578
+ self.delete_confirm = False
579
+ self.set_message("Child node deleted", "green", temporary=True)
580
+
581
+ def cancel_delete(self) -> None:
582
+ """Cancel the current delete operation."""
583
+ self.delete_confirm = False
584
+ self.set_message("Delete canceled", "yellow", temporary=True)
585
+
586
+ def navigate_to_first_child(self) -> None:
587
+ """Jump to the first child."""
588
+ if self.current_node.children:
589
+ self.selected_index = 0
590
+ self.clear_message()
591
+
592
+ def navigate_to_last_child(self) -> None:
593
+ """Jump to the last child."""
594
+ if self.current_node.children:
595
+ self.selected_index = len(self.current_node.children) - 1
596
+ self.clear_message()
597
+
598
+ def navigate_to_root(self) -> None:
599
+ """Jump back to the root node."""
600
+ self.path = []
601
+ self.selected_index = 0
602
+ self.clear_message()
603
+
604
+ def start_search(self) -> None:
605
+ """Enter search mode."""
606
+ # Save current position so we can return to it if user cancels
607
+ # Create a deep copy to ensure we don't have reference issues
608
+ self.pre_search_path = self.path.copy() if self.path else []
609
+ self.pre_search_index = self.selected_index
610
+
611
+ # Log the saved position for debugging
612
+ logging.debug(
613
+ f"Saved pre-search position: path={self.pre_search_path}, index={self.pre_search_index}"
614
+ )
615
+
616
+ self.search_mode = True
617
+ self.search_result_mode = False
618
+ self.search_term = ""
619
+ self.search_results = []
620
+ self.search_result_index = 0
621
+ # This message hides where the term is being typed, commenting out for now
622
+ # self.set_message("Enter search term", "yellow", temporary=True)
623
+
624
+ def cancel_search(self) -> None:
625
+ """Exit search mode."""
626
+ self.search_mode = False
627
+ self.search_result_mode = False
628
+ self.search_term = ""
629
+
630
+ # Save current position for logging
631
+ current_path = self.path.copy()
632
+ current_index = self.selected_index
633
+
634
+ # On first launch, just return to root
635
+ if not self.pre_search_path and current_path:
636
+ # If we have no pre-search path but we're not at root, go to root
637
+ logging.debug("First search after launch, returning to root")
638
+ self.path = []
639
+ self.selected_index = 0
640
+ self.set_message(
641
+ "Returned to root",
642
+ "green",
643
+ temporary=True,
644
+ timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
645
+ )
646
+ # Otherwise use pre-search position if we've moved
647
+ elif self.pre_search_path:
648
+ # Check if we've moved from pre-search position
649
+ if (
650
+ current_path != self.pre_search_path
651
+ or current_index != self.pre_search_index
652
+ ):
653
+ logging.debug(
654
+ f"Restoring position from {current_path} to {self.pre_search_path}"
655
+ )
656
+ self.path = self.pre_search_path.copy()
657
+ self.selected_index = self.pre_search_index
658
+ self.set_message(
659
+ "Returned to pre-search position",
660
+ "green",
661
+ temporary=True,
662
+ timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
663
+ )
664
+
665
+ # Clear search-related data
666
+ self.search_results = []
667
+ self.search_result_index = 0
668
+ self.pre_search_path = []
669
+ self.pre_search_index = 0
670
+
671
+ def _fuzzy_match(self, text: str, pattern: str) -> bool:
672
+ """Perform a case-insensitive substring match.
673
+
674
+ Args:
675
+ text: The text to search in
676
+ pattern: The pattern to search for
677
+
678
+ Returns:
679
+ True if the pattern matches, False otherwise
680
+ """
681
+ if not pattern:
682
+ return False
683
+
684
+ # Convert to lowercase for case-insensitive matching
685
+ text_lower = text.lower()
686
+ pattern_lower = pattern.lower()
687
+
688
+ # Simple substring match - more precise than the previous fuzzy match
689
+ return pattern_lower in text_lower
690
+
691
+ def perform_search(self) -> None:
692
+ """Search the mindmap for the current search term."""
693
+ if not self.search_term:
694
+ return
695
+
696
+ # Reset search results
697
+ self.search_results = []
698
+ self.search_result_index = 0
699
+
700
+ # Recursively search through the mindmap
701
+ self._search_node(self.mindmap, [], 0)
702
+
703
+ # If we found matches, navigate to the first match
704
+ if self.search_results:
705
+ # Enter result browsing mode where we can't modify the search term
706
+ self.search_result_mode = True
707
+ self.navigate_to_search_result()
708
+ # result_count = len(self.search_results) # Removed unused variable
709
+ # This message hides the seach status bar, commenting out for now
710
+ # self.set_message(f"Found {result_count} match{'es' if result_count != 1 else ''}. Press right/left to navigate.", "green", temporary=True, timeout=STATUS_MESSAGE_TIMEOUT_LONG)
711
+ else:
712
+ self.set_message(
713
+ f"Pattern not found: '{self.search_term}'",
714
+ "yellow",
715
+ temporary=True,
716
+ timeout=STATUS_MESSAGE_TIMEOUT_LONG,
717
+ )
718
+
719
+ def _search_node(self, node: Node, current_path: List[int], depth: int) -> None:
720
+ """Recursively search a node and its children for the search term.
721
+
722
+ Args:
723
+ node: The node to search
724
+ current_path: The path to this node
725
+ depth: Current recursion depth (to prevent stack overflow)
726
+ """
727
+ # Limit search depth to prevent stack overflow
728
+ if depth > 100:
729
+ return
730
+
731
+ # Check this node's label
732
+ if self._fuzzy_match(node.label, self.search_term):
733
+ # For the root node, we save [0] as the child index since we can't navigate to the root itself
734
+ if not current_path:
735
+ self.search_results.append(([], 0))
736
+
737
+ # Search all children
738
+ for i, child in enumerate(node.children):
739
+ # Check if this child matches
740
+ if self._fuzzy_match(child.label, self.search_term):
741
+ # Save the path to the parent and the child's index
742
+ self.search_results.append((current_path.copy(), i))
743
+
744
+ # Recursively search this child's children
745
+ new_path = current_path.copy()
746
+ new_path.append(i)
747
+ self._search_node(child, new_path, depth + 1)
748
+
749
+ def navigate_to_search_result(self) -> None:
750
+ """Navigate to the current search result."""
751
+ if not self.search_results:
752
+ return
753
+
754
+ # Get the current result
755
+ result_path, child_index = self.search_results[self.search_result_index]
756
+
757
+ # Navigate to the parent node of the match
758
+ self.path = result_path.copy()
759
+
760
+ # Select the matching child
761
+ self.selected_index = child_index
762
+
763
+ def next_search_result(self) -> None:
764
+ """Navigate to the next search result."""
765
+ if not self.search_results:
766
+ return
767
+
768
+ # Move to the next result
769
+ self.search_result_index = (self.search_result_index + 1) % len(
770
+ self.search_results
771
+ )
772
+ self.navigate_to_search_result()
773
+ # This message hides the seach status bar, commenting out for now
774
+ # self.set_message(f"Match {self.search_result_index + 1}/{len(self.search_results)}", "green", temporary=True)
775
+
776
+ def prev_search_result(self) -> None:
777
+ """Navigate to the previous search result."""
778
+ if not self.search_results:
779
+ return
780
+
781
+ # Move to the previous result
782
+ self.search_result_index = (self.search_result_index - 1) % len(
783
+ self.search_results
784
+ )
785
+ self.navigate_to_search_result()
786
+ # This message hides the seach status bar, commenting out for now
787
+ # self.set_message(f"Match {self.search_result_index + 1}/{len(self.search_results)}", "green", temporary=True)
788
+
789
+ def select_search_result(self) -> None:
790
+ """Select the current search result and make it the focus node."""
791
+ if not self.search_results:
792
+ return
793
+
794
+ # Get the current result path and child index
795
+ result_path, child_index = self.search_results[self.search_result_index]
796
+
797
+ # Navigate to the child node itself (it should become the current node)
798
+ self.navigate_to_child()
799
+
800
+ # Clear search mode and results
801
+ self.search_mode = False
802
+ self.search_result_mode = False
803
+ self.search_results = []
804
+ self.search_result_index = 0
805
+ self.pre_search_path = []
806
+ self.pre_search_index = 0
807
+
808
+ # Confirm the selection with a message
809
+ self.set_message(
810
+ f"Selected match: '{self.current_node.label}'",
811
+ "green",
812
+ temporary=True,
813
+ timeout=STATUS_MESSAGE_TIMEOUT,
814
+ )
815
+
816
+ def confirm_delete(self, confirm: bool) -> None:
817
+ """Confirm or cancel node deletion."""
818
+ if confirm:
819
+ # Actually delete the node
820
+ parent = self.parent_node
821
+ if not parent:
822
+ return
823
+
824
+ del parent.children[self.path[-1]]
825
+
826
+ # Adjust path if we deleted the last child
827
+ if self.path[-1] >= len(parent.children) and parent.children:
828
+ self.path[-1] = len(parent.children) - 1
829
+
830
+ self.dirty = True
831
+ self.set_message("Node deleted", "green", temporary=True)
832
+ else:
833
+ self.set_message("Delete canceled", "yellow", temporary=True)
834
+
835
+ self.delete_confirm = False
836
+
837
+
838
+ # Key constants for clearer code
839
+ KEY_UP = "\x1b[A"
840
+ KEY_DOWN = "\x1b[B"
841
+ KEY_RIGHT = "\x1b[C"
842
+ KEY_LEFT = "\x1b[D"
843
+ KEY_ENTER = "\r"
844
+ KEY_ESC = "\x1b"
845
+ KEY_TAB = "\t"
846
+ KEY_BACKSPACE = "\x7f"
847
+ KEY_INSERT = "\x1b[2~"
848
+ KEY_CTRL_C = "\x03"
849
+ KEY_CTRL_D = "\x04"
850
+ KEY_SLASH = "/"
851
+ KEY_CTRL_F = "\x06"
852
+ KEY_CTRL_E = "\x05"
853
+ KEY_CTRL_I = "\x09"
854
+
855
+ # TODO not sure this is used - Vibe programming :shrug: need to see if it should be used
856
+ # Commenting out for now - JTD 2025-06-21
857
+ # def handle_key(state: QuickModeState, key: str) -> bool:
858
+ # """Process a keypress and update state accordingly.
859
+
860
+ # Args:
861
+ # state: The current state object
862
+ # key: The key that was pressed
863
+
864
+ # Returns:
865
+ # bool: True if the application should continue, False if it should exit
866
+ # """
867
+
868
+ # # Handle delete confirmation state
869
+ # if state.delete_confirm:
870
+ # if key.lower() == "y":
871
+ # state.confirm_delete(True)
872
+ # elif key.lower() == "n" or key == "\x1b": # n or Escape
873
+ # state.confirm_delete(False)
874
+ # return True
875
+
876
+ # # Handle search mode
877
+ # if state.search_mode:
878
+ # if key == KEY_ESC: # Escape
879
+ # state.cancel_search()
880
+ # elif key == KEY_ENTER: # Enter
881
+ # if state.search_result_mode:
882
+ # # We're browsing results, so select the current one
883
+ # state.select_search_result()
884
+ # elif state.search_term:
885
+ # # We're typing a search, so execute it
886
+ # state.perform_search()
887
+ # else:
888
+ # # Empty search, just cancel
889
+ # state.cancel_search()
890
+ # elif (
891
+ # key == KEY_BACKSPACE and not state.search_result_mode
892
+ # ): # Backspace - only in input mode
893
+ # if state.search_term:
894
+ # state.search_term = state.search_term[:-1]
895
+ # elif key == KEY_LEFT: # Left arrow - previous result
896
+ # if state.search_results:
897
+ # state.prev_search_result()
898
+ # elif key == KEY_RIGHT: # Right arrow - next result
899
+ # if state.search_results:
900
+ # state.next_search_result()
901
+ # elif (
902
+ # len(key) == 1 and key.isprintable() and not state.search_result_mode
903
+ # ): # Only add chars in input mode
904
+ # state.search_term += key
905
+ # return True
906
+
907
+ # # Handle editing mode
908
+ # if state.editing:
909
+ # if key == KEY_ESC: # Escape
910
+ # state.cancel_edit()
911
+ # elif key == KEY_ENTER: # Enter
912
+ # state.save_edit()
913
+ # elif key == "e":
914
+ # state.start_edit()
915
+ # elif key == KEY_BACKSPACE: # Backspace
916
+ # if state.input_buffer:
917
+ # state.input_buffer = state.input_buffer[:-1]
918
+ # elif len(key) == 1 and key.isprintable():
919
+ # state.input_buffer += key
920
+ # return True
921
+
922
+ # # Navigation mode
923
+ # if key == KEY_SLASH: # Slash - start search
924
+ # state.start_search()
925
+ # return True
926
+ # elif key == KEY_CTRL_C: # Ctrl+C
927
+ # return False
928
+ # elif key == KEY_CTRL_D: # Ctrl+D
929
+ # return False
930
+ # elif key == KEY_UP: # Up arrow
931
+ # state.navigate_up()
932
+ # elif key == KEY_DOWN: # Down arrow
933
+ # state.navigate_down()
934
+ # elif key == KEY_LEFT: # Left arrow
935
+ # state.navigate_left()
936
+ # elif key == KEY_RIGHT: # Right arrow
937
+ # state.navigate_right()
938
+ # elif key == "\x1b[1;3A": # Alt+Up
939
+ # state.navigate_to_root()
940
+ # elif key == "\x1b[1;3D": # Alt+Left
941
+ # state.navigate_to_first_child()
942
+ # elif key == "\x1b[1;3C": # Alt+Right
943
+ # state.navigate_to_last_child()
944
+ # elif key == KEY_ENTER: # Enter
945
+ # state.add_sibling()
946
+ # elif key == KEY_TAB: # Tab
947
+ # state.add_child()
948
+ # elif key == KEY_BACKSPACE: # Delete or Backspace
949
+ # state.delete_node()
950
+ # elif key == KEY_SLASH: # Slash for search
951
+ # state.start_search()
952
+ # elif len(key) == 1 and key.isprintable():
953
+ # # Any other printable character starts editing
954
+ # state.start_editing()
955
+ # state.input_buffer = key
956
+
957
+ # return True
958
+
959
+
960
+ def get_key(timeout: float = 1.0) -> str:
961
+ """Get a single keypress from terminal, handling special keys like arrows.
962
+
963
+ Args:
964
+ timeout: Maximum time to wait for a key press in seconds.
965
+ If no key is pressed within this time, returns empty string.
966
+
967
+ Returns:
968
+ The key pressed, or empty string if timeout occurred.
969
+ """
970
+ # Dictionary of escape sequences
971
+ ARROW_KEY_SEQUENCES = {
972
+ "\x1b[A": KEY_UP, # Up arrow
973
+ "\x1b[B": KEY_DOWN, # Down arrow
974
+ "\x1b[C": KEY_RIGHT, # Right arrow
975
+ "\x1b[D": KEY_LEFT, # Left arrow
976
+ "\x1bOA": KEY_UP, # Up arrow (alternate)
977
+ "\x1bOB": KEY_DOWN, # Down arrow (alternate)
978
+ "\x1bOC": KEY_RIGHT, # Right arrow (alternate)
979
+ "\x1bOD": KEY_LEFT, # Left arrow (alternate)
980
+ }
981
+
982
+ fd = sys.stdin.fileno()
983
+ old_settings = termios.tcgetattr(fd)
984
+ key = ""
985
+ try:
986
+ tty.setraw(fd)
987
+
988
+ # Set stdin to non-blocking mode
989
+ fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
990
+
991
+ # Wait for input with timeout using select
992
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
993
+ if ready:
994
+ key = sys.stdin.read(1)
995
+ else:
996
+ # Timeout occurred, no key pressed
997
+ return ""
998
+ if key == KEY_ESC: # Escape sequence
999
+ # Set stdin to non-blocking mode
1000
+ fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
1001
+
1002
+ # Try to read the escape sequence
1003
+ seq = key
1004
+ try:
1005
+ while True:
1006
+ ch = sys.stdin.read(1)
1007
+ if not ch: # No more characters
1008
+ break
1009
+ seq += ch
1010
+ # Check if we have a known sequence
1011
+ if seq in ARROW_KEY_SEQUENCES:
1012
+ return ARROW_KEY_SEQUENCES[seq]
1013
+ # Avoid reading too many characters
1014
+ if len(seq) >= 6:
1015
+ break
1016
+ except Exception:
1017
+ # If anything goes wrong, just return what we have
1018
+ pass
1019
+ finally:
1020
+ # Reset stdin to blocking mode
1021
+ fcntl.fcntl(fd, fcntl.F_SETFL, 0)
1022
+
1023
+ return seq
1024
+ finally:
1025
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1026
+
1027
+ return key
1028
+
1029
+
1030
+ def auto_save(state: QuickModeState, file: TextIO) -> None:
1031
+ """Auto-save function to periodically save changes."""
1032
+ while not state.should_quit:
1033
+ if state.dirty:
1034
+ try:
1035
+ # Only show the message if we're not in the middle of another operation
1036
+ show_message = not (state.editing or state.delete_confirm)
1037
+
1038
+ # Save changes
1039
+ file.seek(0)
1040
+ file.truncate()
1041
+ file.write(mindmap_to_markdown(state.mindmap))
1042
+ file.flush()
1043
+ state.dirty = False
1044
+
1045
+ # Only show 'Saved' message if we're not in the middle of another operation
1046
+ if show_message:
1047
+ state.set_message("Autosave complete", "green", temporary=True)
1048
+ except Exception as e:
1049
+ state.set_message(f"Error autosaving: {str(e)}", "red")
1050
+ # Check every 2 seconds
1051
+ time.sleep(2)
1052
+
1053
+
1054
+ def main(
1055
+ file: TextIO, version: str, build_info: str, deep_link_path: Optional[str] = None
1056
+ ) -> int:
1057
+ """Main entry point for quick mode with session restore support."""
1058
+ import os
1059
+
1060
+ session_used = False
1061
+ session = None
1062
+ if file is None:
1063
+ session = load_session()
1064
+ if session and session.get("last_file"):
1065
+ try:
1066
+ file = open(session["last_file"], "r+")
1067
+ session_used = True
1068
+ except Exception as e:
1069
+ print(f"[mdbub] Failed to open last session file: {e}", file=sys.stderr)
1070
+ file = None
1071
+ if file is None:
1072
+ print("No file provided and no session to restore. Exiting.", file=sys.stderr)
1073
+ return 1
1074
+ try:
1075
+ # Read the file content
1076
+ content = file.read()
1077
+
1078
+ # Parse the markdown content into a mindmap
1079
+ create_new_mindmap = False
1080
+ try:
1081
+ mindmap = parse_markdown_to_mindmap(content)
1082
+ if not mindmap or not mindmap.label: # If parsing failed or empty file
1083
+ create_new_mindmap = True
1084
+ except Exception as e:
1085
+ print(f"Error parsing mindmap: {e}", file=sys.stderr)
1086
+ create_new_mindmap = True
1087
+
1088
+ # Create a new mindmap if needed
1089
+ if create_new_mindmap:
1090
+ # Create an empty mindmap with the filename as the root node
1091
+ filename = os.path.basename(file.name)
1092
+ root_name = os.path.splitext(filename)[0] if "." in filename else filename
1093
+ mindmap = Node(root_name)
1094
+
1095
+ # Initialize state
1096
+ state = QuickModeState(mindmap)
1097
+ if create_new_mindmap:
1098
+ state.dirty = True # Ensure we save the new mindmap
1099
+ state.version = version
1100
+ state.build_info = build_info
1101
+
1102
+ # Restore last node path if session was used
1103
+ if session_used and session and session.get("last_node_path"):
1104
+ try:
1105
+ path = session["last_node_path"]
1106
+ node = mindmap
1107
+ for i, idx in enumerate(path):
1108
+ if 0 <= idx < len(node.children):
1109
+ node = node.children[idx]
1110
+ else:
1111
+ raise IndexError
1112
+ state.path = path
1113
+ state.selected_index = path[-1] if path else 0
1114
+ except Exception:
1115
+ state.path = []
1116
+ state.selected_index = 0
1117
+
1118
+ # Anchor-style deep link: search for label containing [id:path/to/me]
1119
+ def find_node_with_anchor(root: Node, anchor: str) -> Optional[List[int]]:
1120
+ stack: List[Any] = [(root, [])] # Start with root node and empty path
1121
+ anchor_str = f"[id:{anchor}]"
1122
+ while stack:
1123
+ node, path = stack.pop()
1124
+ if anchor_str in node.label:
1125
+ if isinstance(path, list) and all(isinstance(i, int) for i in path):
1126
+ return path
1127
+ else:
1128
+ return None
1129
+ for idx, child in enumerate(node.children):
1130
+ stack.append((child, path + [idx]))
1131
+ return None
1132
+
1133
+ if deep_link_path:
1134
+ anchor_path = deep_link_path
1135
+ result_path = find_node_with_anchor(mindmap, anchor_path)
1136
+ if result_path is not None:
1137
+ state.path = result_path
1138
+ state.selected_index = 0 if not result_path else result_path[-1]
1139
+ else:
1140
+ # Show temp warning if anchor not found
1141
+ anchor_str = "/".join(anchor_path)
1142
+ state.set_message(
1143
+ f"Link id /{anchor_str} not found",
1144
+ "yellow",
1145
+ temporary=True,
1146
+ timeout=3.0,
1147
+ )
1148
+ # Remain at root
1149
+
1150
+ # Initialize pre-search position to the root
1151
+ # This ensures ESC after search will return to root even on first launch
1152
+ state.pre_search_path = []
1153
+ state.pre_search_index = 0
1154
+
1155
+ # Initialize UI
1156
+ # ui = QuickModeUI(state) # Removed unused variable
1157
+
1158
+ # Set up autosave thread
1159
+ autosave_thread = threading.Thread(
1160
+ target=auto_save, args=(state, file), daemon=True
1161
+ )
1162
+ autosave_thread.start()
1163
+
1164
+ # Set up signal handler for clean exit
1165
+ def handle_sigint(sig: int, frame: Any) -> None:
1166
+ state.should_quit = True
1167
+ return
1168
+
1169
+ signal.signal(signal.SIGINT, handle_sigint)
1170
+
1171
+ # Main UI loop - minimal UI approach
1172
+ should_quit = False
1173
+ key = "" # Initialize key variable
1174
+ while not should_quit and not state.should_quit:
1175
+ try:
1176
+ # Save session state on every navigation loop
1177
+ save_session(file.name, state.path)
1178
+ # Check for expired temporary messages
1179
+ state.check_expired_message()
1180
+
1181
+ # Clear screen and display current state
1182
+ print(COLOR_PRINTS_CLEAR, end="") # ANSI escape to clear screen
1183
+
1184
+ # Print only minimal info - no debugging clutter
1185
+ # Intentionally left blank to make UI super minimal
1186
+
1187
+ # Display breadcrumbs
1188
+ if not state.path:
1189
+ # When at the root, show just the root symbol
1190
+ print("\033[36m<no parents>\033[0m")
1191
+ else:
1192
+ # Build the path starting with the root node's label
1193
+ current_path = [state.mindmap.label] # Start with root node's label
1194
+ node = state.mindmap
1195
+ for i, idx in enumerate(state.path):
1196
+ if idx < len(node.children):
1197
+ node = node.children[idx]
1198
+ current_path.append(node.label)
1199
+
1200
+ # Format the breadcrumb path with the root node's text
1201
+ # Remove the current node from the breadcrumb (last element)
1202
+
1203
+ # Truncate long node names
1204
+ def truncate_node_name(name: str) -> str:
1205
+ if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
1206
+ return name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3] + "..."
1207
+ return name
1208
+
1209
+ # Truncate root node name if needed
1210
+ root_name = truncate_node_name(current_path[0])
1211
+ breadcrumb_path = f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{root_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}" # Root node's label
1212
+
1213
+ if len(current_path) > 3: # Root + more than 2 levels deep
1214
+ # Truncate the first level node name if needed
1215
+ first_level_name = truncate_node_name(current_path[1])
1216
+ breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
1217
+ if len(current_path) > 4: # Root + more than 3 levels deep
1218
+ # Add '... >' for each hidden level
1219
+ hidden_levels = (
1220
+ len(current_path) - 4
1221
+ ) # -4 because we show root, first, parent and current
1222
+
1223
+ # Truncate long node names
1224
+ def truncate_node_name(name: str) -> str:
1225
+ if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
1226
+ return (
1227
+ name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3]
1228
+ + "..."
1229
+ )
1230
+ return name
1231
+
1232
+ # First add the > after the first visible node
1233
+ breadcrumb_path += " >"
1234
+
1235
+ ellipsis_arrows = ""
1236
+ for _ in range(hidden_levels):
1237
+ ellipsis_arrows += " ... >"
1238
+
1239
+ # Truncate the parent node name if needed
1240
+ parent_name = truncate_node_name(current_path[-2])
1241
+ breadcrumb_path += f"{ellipsis_arrows} {SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
1242
+ else:
1243
+ # Truncate parent node name if needed
1244
+ parent_name = truncate_node_name(current_path[-2])
1245
+ breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
1246
+ elif len(current_path) > 2: # Root + 1 child level
1247
+ # Truncate the first level node name if needed
1248
+ first_level_name = truncate_node_name(current_path[1])
1249
+ breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
1250
+
1251
+ print(
1252
+ f"{COLOR_PRINTS_BREADCRUMB_BAR_BG}{COLOR_PRINTS_BREADCRUMB_BAR_FG}{breadcrumb_path}{COLOR_PRINTS_RESET}"
1253
+ ) # Breadcrumb bar color
1254
+
1255
+ # Display current node
1256
+ if state.editing:
1257
+ # Use the same bullet regardless of position when editing
1258
+ print(
1259
+ f"● {COLOR_PRINTS_TEXT_BG}{COLOR_PRINTS_TEXT_FG}{state.input_buffer}|{COLOR_PRINTS_RESET}"
1260
+ ) # Main text with cursor
1261
+ else:
1262
+ # Use root symbol when at root, regular bullet otherwise
1263
+ bullet = SYMBOL_ROOT if not state.path else SYMBOL_BULLET
1264
+ print(
1265
+ f"{bullet} {COLOR_PRINTS_TEXT_BG}{COLOR_PRINTS_TEXT_FG}{state.current_node.label}{COLOR_PRINTS_RESET}"
1266
+ ) # Main text
1267
+
1268
+ # Display children if any
1269
+ children = state.current_node.children
1270
+ if children:
1271
+ child_text = "└─ "
1272
+ total_children = len(children)
1273
+ selected_idx = state.selected_index
1274
+
1275
+ # Show pagination if needed
1276
+ window_size = min(MAX_VISIBLE_CHILDREN, total_children)
1277
+ window_half = window_size // 2
1278
+ window_start = max(
1279
+ 0, min(selected_idx - window_half, total_children - window_size)
1280
+ )
1281
+ window_end = min(window_start + window_size, total_children)
1282
+
1283
+ if window_start > 0:
1284
+ child_text += "< "
1285
+
1286
+ # Helper function to truncate long child names
1287
+ def truncate_child_name(name: str) -> str:
1288
+ if len(name) > MAX_CHILDNODE_VIZ_LENGTH:
1289
+ return name[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
1290
+ return name
1291
+
1292
+ for i in range(window_start, window_end):
1293
+ # Truncate child name if needed
1294
+ child_name = truncate_child_name(children[i].label)
1295
+ if i == selected_idx:
1296
+ # Selected child: use highlight FG/BG
1297
+ child_text += f"{COLOR_PRINTS_CHILD_HIGHLIGHT_BG}{COLOR_PRINTS_CHILD_HIGHLIGHT_FG}{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP}{COLOR_PRINTS_RESET} "
1298
+ else:
1299
+ child_text += f"{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP} "
1300
+
1301
+ if window_end < total_children:
1302
+ child_text += "> "
1303
+
1304
+ if total_children > window_size:
1305
+ child_text += f"({selected_idx + 1}/{total_children})"
1306
+
1307
+ print(child_text)
1308
+ else:
1309
+ print("└─ No children (press Tab to add one)")
1310
+
1311
+ # Display status bar
1312
+ status_text = ""
1313
+
1314
+ # Always prioritize displaying message from state if available, regardless of mode
1315
+ if state.message[0]:
1316
+ # Use the message color if provided, otherwise use default
1317
+ color_code = f"{COLOR_PRINTS_STATUS_BAR_BG}{COLOR_PRINTS_STATUS_BAR_FG}" # Default to warning/yellow
1318
+ if state.message[1] == "green":
1319
+ color_code = (
1320
+ f"{COLOR_PRINTS_SUCCESS_BG}{COLOR_PRINTS_SUCCESS_FG}"
1321
+ )
1322
+ elif state.message[1] == "red":
1323
+ color_code = f"{COLOR_PRINTS_ERROR_BG}{COLOR_PRINTS_ERROR_FG}"
1324
+
1325
+ status_text = f"{color_code}{state.message[0]}{COLOR_PRINTS_RESET}"
1326
+ # If no message, show appropriate status based on mode
1327
+ elif state.delete_confirm:
1328
+ status_text = f"{COLOR_PRINTS_WARNING_BG}{COLOR_PRINTS_WARNING_FG}Delete? (y/n){COLOR_PRINTS_RESET}"
1329
+ elif state.editing:
1330
+ # Use more verbose format that's easier to understand
1331
+ status_text = f"{COLOR_PRINTS_SUCCESS_BG}{COLOR_PRINTS_SUCCESS_FG}Editing: Enter to save, Esc to cancel{COLOR_PRINTS_RESET}"
1332
+ elif state.search_mode:
1333
+ # Display search info with match count if we have results
1334
+ if state.search_results:
1335
+ current_pos = state.search_result_index + 1
1336
+ total = len(state.search_results)
1337
+ status_text = f"{COLOR_PRINTS_ACCENT_BG}{COLOR_PRINTS_ACCENT_FG}Search: {state.search_term}| ({current_pos}/{total}) \u2190\u2192:navigate Enter:select ESC:exit{COLOR_PRINTS_RESET}"
1338
+ else:
1339
+ status_text = f"{COLOR_PRINTS_ACCENT_BG}{COLOR_PRINTS_ACCENT_FG}Search: {state.search_term}| Enter:search ESC:cancel{COLOR_PRINTS_RESET}"
1340
+ else:
1341
+ status_text = f"{COLOR_PRINTS_STATUS_BAR_BG}{COLOR_PRINTS_STATUS_BAR_FG}^C/^D:quit ↑↓←→:nav tab:add-child enter:add-sibling del:delete /=search{COLOR_PRINTS_RESET}"
1342
+ print("\n" + status_text)
1343
+
1344
+ # Get key with timeout to allow message expiration
1345
+ key = get_key(timeout=0.5) # Shorter timeout for more responsive UI
1346
+
1347
+ # If no key was pressed (timeout), check for expired messages and continue the loop
1348
+ if key == "":
1349
+ state.check_expired_message()
1350
+ continue
1351
+
1352
+ # Debug disabled
1353
+ # if key in [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT]:
1354
+ # print(f"\nDEBUG: Arrow key pressed: {repr(key)}")
1355
+ # time.sleep(0.2) # Less delay for better responsiveness
1356
+
1357
+ # Process input based on current state
1358
+ if key in (KEY_CTRL_C, KEY_CTRL_D): # Ctrl+C or Ctrl+D
1359
+ should_quit = True
1360
+ state.should_quit = True
1361
+ elif state.delete_confirm:
1362
+ # Delete confirmation mode
1363
+ if key.lower() == "y":
1364
+ state.delete_node()
1365
+ state.delete_confirm = False
1366
+ elif key.lower() == "n" or key == KEY_ESC: # n or Escape
1367
+ state.delete_confirm = False
1368
+ elif state.editing:
1369
+ # Editing mode
1370
+ if key == KEY_ENTER: # Enter
1371
+ state.save_edit()
1372
+ elif key == KEY_ESC: # Escape
1373
+ state.cancel_edit()
1374
+ elif key == KEY_BACKSPACE: # Backspace
1375
+ state.input_buffer = state.input_buffer[:-1]
1376
+ else:
1377
+ # Add character to input buffer if printable
1378
+ if len(key) == 1 and key.isprintable():
1379
+ state.input_buffer += key
1380
+ elif state.search_mode:
1381
+ # Search mode
1382
+ if key == KEY_ENTER: # Enter - execute search or select result
1383
+ if state.search_result_mode:
1384
+ # We're browsing results, so select the current one
1385
+ state.select_search_result()
1386
+ elif state.search_term:
1387
+ # We're typing a search, so execute it
1388
+ state.perform_search()
1389
+ else:
1390
+ # Empty search, just cancel
1391
+ state.cancel_search()
1392
+ elif key == KEY_ESC: # Escape - cancel search
1393
+ state.cancel_search()
1394
+ elif (
1395
+ key == KEY_BACKSPACE and not state.search_result_mode
1396
+ ): # Backspace - delete last character (only if not in result mode)
1397
+ if state.search_term:
1398
+ state.search_term = state.search_term[:-1]
1399
+ elif key == KEY_LEFT: # Left arrow - previous result
1400
+ if state.search_results:
1401
+ state.prev_search_result()
1402
+ elif key == KEY_RIGHT: # Right arrow - next result
1403
+ if state.search_results:
1404
+ state.next_search_result()
1405
+ elif (
1406
+ len(key) == 1
1407
+ and key.isprintable()
1408
+ and not state.search_result_mode
1409
+ ): # Only add characters if not in result mode
1410
+ # Add character to search term if printable
1411
+ state.search_term += key
1412
+ else:
1413
+ # Navigation mode with correctly named methods
1414
+ if key in (KEY_SLASH, KEY_CTRL_F): # Slash or Ctrl+F - start search
1415
+ state.start_search()
1416
+ elif key == KEY_UP: # Up arrow - navigate up to parent
1417
+ state.navigate_to_parent() # Move up the tree
1418
+ elif key == KEY_DOWN: # Down arrow - navigate down to child
1419
+ state.navigate_to_child() # Move down the tree
1420
+ elif key == KEY_LEFT: # Left arrow - previous sibling
1421
+ state.navigate_prev_sibling() # Move left horizontally
1422
+ elif key == KEY_RIGHT: # Right arrow - next sibling
1423
+ state.navigate_next_sibling() # Move right horizontally
1424
+ elif key == KEY_TAB: # Tab
1425
+ state.add_child()
1426
+ elif key == KEY_ENTER: # Enter
1427
+ if state.path: # Not at root
1428
+ state.start_editing()
1429
+ state.input_buffer = ""
1430
+ state.add_sibling()
1431
+ elif key == KEY_BACKSPACE: # Delete
1432
+ state.delete_node()
1433
+ elif key == KEY_SLASH: # Search
1434
+ state.search_mode = True
1435
+ state.search_term = ""
1436
+ elif key in (
1437
+ KEY_CTRL_I,
1438
+ KEY_INSERT,
1439
+ KEY_CTRL_E,
1440
+ ): # Insert key - edit current label in-place
1441
+ state.start_inserting() # input_buffer is set to current label by start_editing
1442
+ elif len(key) == 1 and key.isprintable():
1443
+ # Start editing and add the pressed key
1444
+ state.start_editing()
1445
+ state.input_buffer = key
1446
+ except Exception as e:
1447
+ print(f"\n\033[31mError: {e}\033[0m")
1448
+ print("Press any key to continue or Ctrl+C to quit...")
1449
+ try:
1450
+ err_key = get_key()
1451
+ if err_key in (KEY_CTRL_C, KEY_CTRL_D): # Ctrl+C or Ctrl+D
1452
+ should_quit = True
1453
+ state.should_quit = True
1454
+ except Exception:
1455
+ should_quit = True
1456
+ state.should_quit = True
1457
+
1458
+ # Final save if needed
1459
+ if state.dirty:
1460
+ file.seek(0)
1461
+ file.truncate()
1462
+ file.write(mindmap_to_markdown(state.mindmap))
1463
+ # Always save session on quit
1464
+ save_session(file.name, state.path)
1465
+ return 0
1466
+ except Exception as e:
1467
+ print(f"Error in quick mode: {e}", file=sys.stderr)
1468
+ import traceback
1469
+
1470
+ traceback.print_exc()
1471
+ return 1