hcom 0.5.0__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hcom might be problematic. Click here for more details.

hcom/ui.py ADDED
@@ -0,0 +1,2965 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ HCOM TUI - Interactive Menu Interface
4
+ Part of HCOM (Claude Hook Comms)
5
+ """
6
+ import os
7
+ import sys
8
+ import re
9
+ import select
10
+ import shlex
11
+ import shutil
12
+ import subprocess
13
+ import textwrap
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from enum import Enum
18
+ from typing import List, Optional, Tuple, Literal
19
+
20
+ # Import from shared module (constants and pure utilities)
21
+ try:
22
+ from .shared import (
23
+ # ANSI codes
24
+ RESET, BOLD, DIM, REVERSE,
25
+ FG_GREEN, FG_CYAN, FG_WHITE, FG_BLACK, FG_GRAY, FG_YELLOW, FG_RED, FG_BLUE,
26
+ FG_ORANGE, FG_GOLD, FG_LIGHTGRAY,
27
+ BG_GREEN, BG_CYAN, BG_YELLOW, BG_RED, BG_BLUE, BG_GRAY,
28
+ BG_ORANGE, BG_CHARCOAL,
29
+ CLEAR_SCREEN, CURSOR_HOME, HIDE_CURSOR, SHOW_CURSOR,
30
+ BOX_H,
31
+ # Config
32
+ DEFAULT_CONFIG_HEADER, DEFAULT_CONFIG_DEFAULTS,
33
+ # Status configuration
34
+ STATUS_MAP, STATUS_ORDER, STATUS_FG, STATUS_BG_MAP,
35
+ # Utilities
36
+ format_timestamp, get_status_counts,
37
+ # Claude args parsing
38
+ resolve_claude_args,
39
+ )
40
+ # Import from cli module (functions and data)
41
+ from .cli import (
42
+ # Commands
43
+ cmd_launch, cmd_start, cmd_stop, cmd_reset, cmd_send,
44
+ # Instance operations
45
+ get_instance_status, parse_log_messages, load_all_positions, should_show_in_watch,
46
+ # Path utilities
47
+ hcom_path,
48
+ # Configuration
49
+ get_config, HcomConfig, reload_config,
50
+ ConfigSnapshot, load_config_snapshot, save_config,
51
+ dict_to_hcom_config, HcomConfigError,
52
+ # Utilities
53
+ ensure_hcom_directories, list_available_agents,
54
+ )
55
+ except ImportError as e:
56
+ sys.stderr.write(f"Error: Cannot import required modules.\n")
57
+ sys.stderr.write(f"Make sure hcom package is installed.\n")
58
+ sys.stderr.write(f"Details: {e}\n")
59
+ sys.exit(1)
60
+
61
+ IS_WINDOWS = os.name == 'nt'
62
+
63
+ # All ANSI codes and STATUS configs now imported from shared.py
64
+ # Only need ANSI regex for local use
65
+ ANSI_RE = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
66
+
67
+ # UI-specific colors (not in shared.py)
68
+ FG_CLAUDE_ORANGE = '\033[38;5;214m' # Light orange for Claude section
69
+ FG_CUSTOM_ENV = '\033[38;5;141m' # Light purple for Custom Env section
70
+
71
+ # Parse config defaults from shared.py
72
+ CONFIG_DEFAULTS = {}
73
+ for line in DEFAULT_CONFIG_DEFAULTS:
74
+ if '=' in line:
75
+ key, value = line.split('=', 1)
76
+ value = value.strip()
77
+ # Remove only outer layer of quotes (preserve inner quotes for HCOM_CLAUDE_ARGS)
78
+ if (value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'")):
79
+ value = value[1:-1]
80
+ CONFIG_DEFAULTS[key.strip()] = value
81
+
82
+ # TUI Layout Constants
83
+ MESSAGE_PREVIEW_LIMIT = 100 # Keep last N messages in message preview
84
+ MAX_INPUT_ROWS = 8 # Cap input area at N rows
85
+
86
+ # Config field special handlers (automatic for all, enhanced UX for specific vars)
87
+ CONFIG_FIELD_OVERRIDES = {
88
+ 'HCOM_TIMEOUT': {
89
+ 'type': 'numeric',
90
+ 'min': 1,
91
+ 'max': 86400,
92
+ 'hint': '1-86400 seconds',
93
+ },
94
+ 'HCOM_SUBAGENT_TIMEOUT': {
95
+ 'type': 'numeric',
96
+ 'min': 1,
97
+ 'max': 86400,
98
+ 'hint': '1-86400 seconds',
99
+ },
100
+ 'HCOM_TERMINAL': {
101
+ 'type': 'text',
102
+ 'hint': 'new | here | "custom {script}"',
103
+ },
104
+ 'HCOM_HINTS': {
105
+ 'type': 'text',
106
+ 'hint': 'text string',
107
+ },
108
+ 'HCOM_TAG': {
109
+ 'type': 'text',
110
+ 'allowed_chars': r'^[a-zA-Z0-9-]*$', # Only letters, numbers, hyphens (no spaces)
111
+ 'hint': 'letters/numbers/hyphens only',
112
+ },
113
+ 'HCOM_AGENT': {
114
+ 'type': 'cycle',
115
+ 'options': lambda: list_available_agents(), # Dynamic discovery
116
+ 'hint': '←→ cycle options',
117
+ },
118
+ }
119
+
120
+
121
+ @dataclass
122
+ class Field:
123
+ """Field representation for rendering expandable sections"""
124
+ key: str
125
+ display_name: str
126
+ field_type: Literal['checkbox', 'text', 'cycle', 'numeric']
127
+ value: str | bool
128
+ options: List[str] | None = None
129
+ hint: str = ""
130
+
131
+
132
+ class Mode(Enum):
133
+ MANAGE = "manage"
134
+ LAUNCH = "launch"
135
+
136
+
137
+ class LaunchField(Enum):
138
+ COUNT = 0
139
+ LAUNCH_BTN = 1
140
+ CLAUDE_SECTION = 2
141
+ HCOM_SECTION = 3
142
+ CUSTOM_ENV_SECTION = 4
143
+ OPEN_EDITOR = 5
144
+
145
+
146
+ def ansi_len(text: str) -> int:
147
+ """Get visible length of text (excluding ANSI codes), accounting for wide chars"""
148
+ import unicodedata
149
+ visible = ANSI_RE.sub('', text)
150
+ width = 0
151
+ for char in visible:
152
+ ea_width = unicodedata.east_asian_width(char)
153
+ if ea_width in ('F', 'W'): # Fullwidth or Wide
154
+ width += 2
155
+ elif ea_width in ('Na', 'H', 'N', 'A'): # Narrow, Half-width, Neutral, Ambiguous
156
+ width += 1
157
+ # else: zero-width characters (combining marks, etc.)
158
+ return width
159
+
160
+
161
+ def ansi_ljust(text: str, width: int) -> str:
162
+ """Left-justify text to width, accounting for ANSI codes"""
163
+ visible = ansi_len(text)
164
+ return text + (' ' * (width - visible)) if visible < width else text
165
+
166
+
167
+ def bg_ljust(text: str, width: int, bg_color: str) -> str:
168
+ """Left-justify text with background color padding"""
169
+ visible = ansi_len(text)
170
+ if visible < width:
171
+ padding = ' ' * (width - visible)
172
+ return f"{text}{bg_color}{padding}{RESET}"
173
+ return text
174
+
175
+
176
+ def truncate_ansi(text: str, width: int) -> str:
177
+ """Truncate text to width, preserving ANSI codes, accounting for wide chars"""
178
+ import unicodedata
179
+ if width <= 0:
180
+ return ''
181
+ visible_len = ansi_len(text)
182
+ if visible_len <= width:
183
+ return text
184
+
185
+ visible = 0
186
+ result = []
187
+ i = 0
188
+ target = width - 1 # Reserve space for ellipsis
189
+
190
+ while i < len(text) and visible < target:
191
+ if text[i] == '\033':
192
+ match = ANSI_RE.match(text, i)
193
+ if match:
194
+ result.append(match.group())
195
+ i = match.end()
196
+ continue
197
+
198
+ # Check character width
199
+ char_width = 1
200
+ ea_width = unicodedata.east_asian_width(text[i])
201
+ if ea_width in ('F', 'W'): # Fullwidth or Wide
202
+ char_width = 2
203
+
204
+ # Only add if it fits
205
+ if visible + char_width <= target:
206
+ result.append(text[i])
207
+ visible += char_width
208
+ else:
209
+ break # No more space
210
+ i += 1
211
+
212
+ result.append('…')
213
+ result.append(RESET)
214
+ return ''.join(result)
215
+
216
+
217
+ def smart_truncate_name(name: str, width: int) -> str:
218
+ """
219
+ Intelligently truncate name keeping prefix and suffix with middle ellipsis.
220
+ Example: "bees_general-purpose_2" (21 chars) → "bees…pose_2" (11 chars)
221
+ """
222
+ if len(name) <= width:
223
+ return name
224
+ if width < 5:
225
+ return name[:width]
226
+
227
+ # Keep prefix and suffix, put ellipsis in middle
228
+ # Reserve 1 char for ellipsis
229
+ available = width - 1
230
+ prefix_len = (available + 1) // 2 # Round up for prefix
231
+ suffix_len = available - prefix_len
232
+
233
+ return name[:prefix_len] + '…' + name[-suffix_len:] if suffix_len > 0 else name[:prefix_len] + '…'
234
+
235
+
236
+ class AnsiTextWrapper(textwrap.TextWrapper):
237
+ """TextWrapper that handles ANSI escape codes correctly"""
238
+
239
+ def _wrap_chunks(self, chunks):
240
+ """Override to use visible length for width calculations"""
241
+ lines = []
242
+ if self.width <= 0:
243
+ raise ValueError("invalid width %r (must be > 0)" % self.width)
244
+
245
+ chunks.reverse()
246
+ while chunks:
247
+ cur_line = []
248
+ cur_len = 0
249
+ indent = self.subsequent_indent if lines else self.initial_indent
250
+ width = self.width - ansi_len(indent)
251
+
252
+ while chunks:
253
+ l = ansi_len(chunks[-1])
254
+ if cur_len + l <= width:
255
+ cur_line.append(chunks.pop())
256
+ cur_len += l
257
+ else:
258
+ break
259
+
260
+ if chunks and ansi_len(chunks[-1]) > width:
261
+ if not cur_line:
262
+ cur_line.append(chunks.pop())
263
+
264
+ if cur_line:
265
+ lines.append(indent + ''.join(cur_line))
266
+
267
+ return lines
268
+
269
+
270
+ def get_terminal_size() -> Tuple[int, int]:
271
+ """Get terminal dimensions (cols, rows)"""
272
+ size = shutil.get_terminal_size(fallback=(100, 30))
273
+ return size.columns, size.lines
274
+
275
+
276
+ # format_age() imported from hcom.py (was duplicate at line 320-332)
277
+
278
+
279
+ class KeyboardInput:
280
+ """Cross-platform keyboard input handler"""
281
+
282
+ def __init__(self):
283
+ self.is_windows = IS_WINDOWS
284
+ if not self.is_windows:
285
+ import termios
286
+ import tty
287
+ self.termios = termios
288
+ self.tty = tty
289
+ self.fd = sys.stdin.fileno()
290
+ self.old_settings = None
291
+
292
+ def __enter__(self):
293
+ if not self.is_windows:
294
+ try:
295
+ self.old_settings = self.termios.tcgetattr(self.fd)
296
+ self.tty.setcbreak(self.fd)
297
+ except Exception:
298
+ self.old_settings = None
299
+ sys.stdout.write(HIDE_CURSOR)
300
+ sys.stdout.flush()
301
+ return self
302
+
303
+ def __exit__(self, *args):
304
+ if not self.is_windows and self.old_settings:
305
+ self.termios.tcsetattr(self.fd, self.termios.TCSADRAIN, self.old_settings)
306
+ sys.stdout.write(SHOW_CURSOR)
307
+ sys.stdout.flush()
308
+
309
+ def has_input(self) -> bool:
310
+ """Check if input is available without blocking"""
311
+ if self.is_windows:
312
+ import msvcrt
313
+ return msvcrt.kbhit() # type: ignore[attr-defined]
314
+ else:
315
+ try:
316
+ return bool(select.select([self.fd], [], [], 0.0)[0])
317
+ except (InterruptedError, OSError):
318
+ return False
319
+
320
+ def get_key(self) -> Optional[str]:
321
+ """Read single key press, return special key name or character"""
322
+ if self.is_windows:
323
+ import msvcrt
324
+ if not msvcrt.kbhit(): # type: ignore[attr-defined]
325
+ return None
326
+ ch = msvcrt.getwch() # type: ignore[attr-defined]
327
+ if ch in ('\x00', '\xe0'):
328
+ ch2 = msvcrt.getwch() # type: ignore[attr-defined]
329
+ keys = {'H': 'UP', 'P': 'DOWN', 'K': 'LEFT', 'M': 'RIGHT'}
330
+ return keys.get(ch2, None)
331
+ # Distinguish manual Enter from pasted newlines (Windows)
332
+ if ch in ('\r', '\n'):
333
+ # If more input is immediately available, it's likely a paste
334
+ if msvcrt.kbhit(): # type: ignore[attr-defined]
335
+ return '\n' # Pasted newline, keep as literal
336
+ else:
337
+ return 'ENTER' # Manual Enter key press
338
+ if ch == '\x1b': return 'ESC'
339
+ if ch in ('\x08', '\x7f'): return 'BACKSPACE'
340
+ if ch == ' ': return 'SPACE'
341
+ if ch == '\t': return 'TAB'
342
+ return ch if ch else None
343
+ else:
344
+ try:
345
+ has_data = select.select([self.fd], [], [], 0.0)[0]
346
+ except (InterruptedError, OSError):
347
+ return None
348
+ if not has_data:
349
+ return None
350
+ try:
351
+ ch = os.read(self.fd, 1).decode('utf-8', errors='ignore')
352
+ except OSError:
353
+ return None
354
+ if ch == '\x1b':
355
+ try:
356
+ has_escape_data = select.select([self.fd], [], [], 0.1)[0]
357
+ except (InterruptedError, OSError):
358
+ return 'ESC'
359
+ if has_escape_data:
360
+ try:
361
+ next1 = os.read(self.fd, 1).decode('utf-8', errors='ignore')
362
+ if next1 == '[':
363
+ next2 = os.read(self.fd, 1).decode('utf-8', errors='ignore')
364
+ keys = {'A': 'UP', 'B': 'DOWN', 'C': 'RIGHT', 'D': 'LEFT'}
365
+ if next2 in keys:
366
+ return keys[next2]
367
+ except (OSError, UnicodeDecodeError):
368
+ pass
369
+ return 'ESC'
370
+ # Distinguish manual Enter from pasted newlines
371
+ if ch in ('\r', '\n'):
372
+ # If more input is immediately available, it's likely a paste
373
+ try:
374
+ has_paste_data = select.select([self.fd], [], [], 0.0)[0]
375
+ except (InterruptedError, OSError):
376
+ return 'ENTER'
377
+ if has_paste_data:
378
+ return '\n' # Pasted newline, keep as literal
379
+ else:
380
+ return 'ENTER' # Manual Enter key press
381
+ if ch in ('\x7f', '\x08'): return 'BACKSPACE'
382
+ if ch == ' ': return 'SPACE'
383
+ if ch == '\t': return 'TAB'
384
+ if ch == '\x03': return 'CTRL_C'
385
+ if ch == '\x04': return 'CTRL_D'
386
+ if ch == '\x01': return 'CTRL_A'
387
+ if ch == '\x12': return 'CTRL_R'
388
+ return ch
389
+
390
+
391
+ # Text input helper functions (shared between MANAGE and LAUNCH)
392
+
393
+ def text_input_insert(buffer: str, cursor: int, text: str) -> tuple[str, int]:
394
+ """Insert text at cursor position, return (new_buffer, new_cursor)"""
395
+ new_buffer = buffer[:cursor] + text + buffer[cursor:]
396
+ new_cursor = cursor + len(text)
397
+ return new_buffer, new_cursor
398
+
399
+ def text_input_backspace(buffer: str, cursor: int) -> tuple[str, int]:
400
+ """Delete char before cursor, return (new_buffer, new_cursor)"""
401
+ if cursor > 0:
402
+ new_buffer = buffer[:cursor-1] + buffer[cursor:]
403
+ new_cursor = cursor - 1
404
+ return new_buffer, new_cursor
405
+ return buffer, cursor
406
+
407
+ def text_input_move_left(cursor: int) -> int:
408
+ """Move cursor left, return new position"""
409
+ return max(0, cursor - 1)
410
+
411
+ def text_input_move_right(buffer: str, cursor: int) -> int:
412
+ """Move cursor right, return new position"""
413
+ return min(len(buffer), cursor + 1)
414
+
415
+ def calculate_text_input_rows(text: str, width: int, max_rows: int = MAX_INPUT_ROWS) -> int:
416
+ """Calculate rows needed for wrapped text with literal newlines"""
417
+ if not text:
418
+ return 1
419
+
420
+ lines = text.split('\n')
421
+ total_rows = 0
422
+ for line in lines:
423
+ if not line:
424
+ total_rows += 1
425
+ else:
426
+ total_rows += max(1, (len(line) + width - 1) // width)
427
+ return min(total_rows, max_rows)
428
+
429
+
430
+ def render_text_input(buffer: str, cursor: int, width: int, max_rows: int, prefix: str = "> ") -> List[str]:
431
+ """
432
+ Render text input with cursor, wrapping, and literal newlines.
433
+
434
+ Args:
435
+ buffer: Text content
436
+ cursor: Cursor position (0 to len(buffer))
437
+ width: Terminal width
438
+ max_rows: Maximum rows to render
439
+ prefix: First line prefix (e.g., "> " or "")
440
+
441
+ Returns:
442
+ List of formatted lines with cursor (█)
443
+ """
444
+ if not buffer:
445
+ return [f"{FG_GRAY}{prefix}█{RESET}"]
446
+
447
+ line_width = width - len(prefix)
448
+ before = buffer[:cursor]
449
+ after = buffer[cursor:]
450
+ full = before + '█' + after
451
+
452
+ # Split on literal newlines first
453
+ lines = full.split('\n')
454
+
455
+ # Wrap each line if needed
456
+ wrapped = []
457
+ for line_idx, line in enumerate(lines):
458
+ if not line:
459
+ # Empty line (from consecutive newlines or trailing newline)
460
+ line_prefix = prefix if line_idx == 0 else " " * len(prefix)
461
+ wrapped.append(f"{FG_WHITE}{line_prefix}{RESET}")
462
+ else:
463
+ # Wrap long lines
464
+ for chunk_idx in range(0, len(line), line_width):
465
+ chunk = line[chunk_idx:chunk_idx+line_width]
466
+ line_prefix = prefix if line_idx == 0 and chunk_idx == 0 else " " * len(prefix)
467
+ wrapped.append(f"{FG_WHITE}{line_prefix}{RESET}{FG_WHITE}{chunk}{RESET}")
468
+
469
+ # Pad or truncate to max_rows
470
+ result = wrapped + [''] * max(0, max_rows - len(wrapped))
471
+ return result[:max_rows]
472
+
473
+
474
+ def ease_out_quad(t: float) -> float:
475
+ """Ease-out quadratic curve (fast start, slow end)"""
476
+ return 1 - (1 - t) ** 2
477
+
478
+
479
+ def interpolate_color_index(start: int, end: int, progress: float) -> int:
480
+ """Interpolate between two 256-color palette indices with ease-out
481
+
482
+ Args:
483
+ start: Starting color index (0-255)
484
+ end: Ending color index (0-255)
485
+ progress: Progress from 0.0 to 1.0
486
+
487
+ Returns:
488
+ Interpolated color index (0-255)
489
+ """
490
+ # Clamp progress to [0, 1]
491
+ progress = max(0.0, min(1.0, progress))
492
+
493
+ # Apply ease-out curve (50% fade in first 10s)
494
+ eased = ease_out_quad(progress)
495
+
496
+ # Linear interpolation between indices
497
+ return int(start + (end - start) * eased)
498
+
499
+
500
+ def get_message_pulse_colors(seconds_since: float) -> tuple[str, str]:
501
+ """Get background and foreground colors for LOG tab based on message recency
502
+
503
+ Args:
504
+ seconds_since: Seconds since last message (0 = just now, 5+ = quiet)
505
+
506
+ Returns:
507
+ (bg_color, fg_color) tuple of ANSI escape codes
508
+ """
509
+ # At rest (5s+), use exact same colors as LAUNCH tab
510
+ if seconds_since >= 5.0:
511
+ return BG_CHARCOAL, FG_WHITE
512
+
513
+ # Clamp to 5s range
514
+ seconds = max(0.0, min(5.0, seconds_since))
515
+
516
+ # Progress: 0.0 = recent (white), 1.0 = quiet (charcoal)
517
+ progress = seconds / 5.0
518
+
519
+ # Interpolate background: 255 (white) → 236 (charcoal)
520
+ bg_index = interpolate_color_index(255, 236, progress)
521
+
522
+ # Interpolate foreground: 232 (darkest gray) → 250 (light gray matching FG_WHITE)
523
+ # Don't overshoot to 255 (pure white) - normal FG_WHITE is dimmer
524
+ fg_index = interpolate_color_index(232, 250, progress)
525
+
526
+ return f'\033[48;5;{bg_index}m', f'\033[38;5;{fg_index}m'
527
+
528
+
529
+ class HcomTUI:
530
+ """Main TUI application"""
531
+
532
+ # Confirmation timeout constants
533
+ CONFIRMATION_TIMEOUT = 10.0 # State cleared after this
534
+ CONFIRMATION_FLASH_DURATION = 10.0 # Flash duration matches timeout
535
+
536
+ def __init__(self, hcom_dir: Path):
537
+ self.hcom_dir = hcom_dir
538
+ self.mode = Mode.MANAGE
539
+
540
+ # State
541
+ self.cursor = 0 # Current selection in lists
542
+ self.cursor_instance_name: Optional[str] = None # Stable cursor tracking by name
543
+ self.instances = {} # {name: {status, age_text, data}}
544
+ self.status_counts = {s: 0 for s in STATUS_ORDER}
545
+ self.messages = [] # [(timestamp, sender, message)] - Recent messages for preview
546
+ self.message_buffer: str = "" # Message input buffer
547
+ self.message_cursor_pos: int = 0 # Cursor position in message buffer
548
+
549
+ # Toggle confirmation state (two-step)
550
+ self.pending_toggle: Optional[str] = None # Instance name pending confirmation
551
+ self.pending_toggle_time: float = 0.0 # When confirmation started
552
+
553
+ # Toggle completion state (temporary display)
554
+ self.completed_toggle: Optional[str] = None # Instance that just completed
555
+ self.completed_toggle_time: float = 0.0 # When it completed
556
+
557
+ # Stop all confirmation state
558
+ self.pending_stop_all: bool = False
559
+ self.pending_stop_all_time: float = 0.0
560
+
561
+ # Reset confirmation state
562
+ self.pending_reset: bool = False
563
+ self.pending_reset_time: float = 0.0
564
+
565
+ # Instance scrolling
566
+ self.instance_scroll_pos: int = 0 # Top visible instance index
567
+
568
+ # Launch screen scrolling
569
+ self.launch_scroll_pos: int = 0 # Top visible form line
570
+
571
+ # Flash notifications
572
+ self.flash_message = None
573
+ self.flash_until = 0.0
574
+ self.flash_color = 'orange' # 'red', 'white', or 'orange'
575
+
576
+ # Validation errors
577
+ self.validation_errors = {} # {field_key: error_message}
578
+
579
+ # Launch form state
580
+ self.launch_count = "1"
581
+ self.launch_prompt = ""
582
+ self.launch_system_prompt = ""
583
+ self.launch_background = False
584
+ self.launch_field = LaunchField.COUNT # Currently selected field
585
+ self.available_agents = [] # List of available agents from .claude/agents
586
+
587
+ # Cursor positions for editable fields (bottom bar editor)
588
+ self.launch_prompt_cursor = 0
589
+ self.launch_system_prompt_cursor = 0
590
+ self.config_field_cursors = {} # {field_key: cursor_pos} for config.env fields
591
+
592
+ # Dynamic config state (loaded from ~/.hcom/config.env)
593
+ self.config_snapshot: ConfigSnapshot | None = None
594
+ self.config_edit: dict[str, str] = {} # Combined HCOM_* and extra env vars
595
+
596
+ # Section expansion state (UI only)
597
+ self.claude_expanded = False
598
+ self.hcom_expanded = False
599
+ self.custom_env_expanded = False
600
+
601
+ # Section cursors (-1 = on header, 0+ = field index)
602
+ self.claude_cursor = -1
603
+ self.hcom_cursor = -1
604
+ self.custom_env_cursor = -1
605
+
606
+ # Rendering
607
+ self.last_frame = []
608
+ self.last_status_update = 0.0
609
+ self.first_render = True
610
+
611
+ # File size caching for efficient log loading
612
+ self.last_log_size = 0
613
+
614
+ # Message activity tracking for LOG tab pulse
615
+ self.last_message_time: float = 0.0 # Timestamp of most recent message
616
+
617
+ def flash(self, msg: str, duration: float = 2.0, color: str = 'orange'):
618
+ """Show temporary flash message
619
+
620
+ Args:
621
+ msg: Message text
622
+ duration: Display time in seconds
623
+ color: 'red', 'white', or 'orange' (default)
624
+ """
625
+ self.flash_message = msg
626
+ self.flash_until = time.time() + duration
627
+ self.flash_color = color
628
+
629
+ def flash_error(self, msg: str, duration: float = 10.0):
630
+ """Show error flash in red"""
631
+ self.flash_message = msg
632
+ self.flash_until = time.time() + duration
633
+ self.flash_color = 'red'
634
+
635
+ def parse_validation_errors(self, error_str: str):
636
+ """Parse ValueError message from HcomConfig into field-specific errors"""
637
+ self.validation_errors.clear()
638
+
639
+ # Parse multi-line error format:
640
+ # "Invalid config:\n - timeout must be...\n - terminal cannot..."
641
+ for line in error_str.split('\n'):
642
+ line = line.strip()
643
+ if not line or line == 'Invalid config:':
644
+ continue
645
+
646
+ # Remove leading "- " from error lines
647
+ if line.startswith('- '):
648
+ line = line[2:]
649
+
650
+ # Match error to field based on keywords
651
+ # For fields with multiple possible errors, only store first error seen
652
+ line_lower = line.lower()
653
+ if 'timeout must be' in line_lower and 'subagent' not in line_lower:
654
+ if 'HCOM_TIMEOUT' not in self.validation_errors:
655
+ self.validation_errors['HCOM_TIMEOUT'] = line
656
+ elif 'subagent_timeout' in line_lower or 'subagent timeout' in line_lower:
657
+ if 'HCOM_SUBAGENT_TIMEOUT' not in self.validation_errors:
658
+ self.validation_errors['HCOM_SUBAGENT_TIMEOUT'] = line
659
+ elif 'terminal' in line_lower:
660
+ if 'HCOM_TERMINAL' not in self.validation_errors:
661
+ self.validation_errors['HCOM_TERMINAL'] = line
662
+ elif 'tag' in line_lower:
663
+ if 'HCOM_TAG' not in self.validation_errors:
664
+ self.validation_errors['HCOM_TAG'] = line
665
+ elif 'agent' in line_lower and 'subagent' not in line_lower:
666
+ # Agent can have multiple errors - store first one
667
+ if 'HCOM_AGENT' not in self.validation_errors:
668
+ self.validation_errors['HCOM_AGENT'] = line
669
+ elif 'claude_args' in line_lower:
670
+ if 'HCOM_CLAUDE_ARGS' not in self.validation_errors:
671
+ self.validation_errors['HCOM_CLAUDE_ARGS'] = line
672
+ elif 'hints' in line_lower:
673
+ if 'HCOM_HINTS' not in self.validation_errors:
674
+ self.validation_errors['HCOM_HINTS'] = line
675
+
676
+ def clear_all_pending_confirmations(self):
677
+ """Clear all pending confirmation states and flash if any were active"""
678
+ had_pending = self.pending_toggle or self.pending_stop_all or self.pending_reset
679
+
680
+ self.pending_toggle = None
681
+ self.pending_stop_all = False
682
+ self.pending_reset = False
683
+
684
+ if had_pending:
685
+ self.flash_message = None
686
+
687
+ def clear_pending_confirmations_except(self, keep: str):
688
+ """Clear all pending confirmations except the specified one ('toggle', 'stop_all', 'reset')"""
689
+ had_pending = False
690
+
691
+ if keep != 'toggle' and self.pending_toggle:
692
+ self.pending_toggle = None
693
+ had_pending = True
694
+ if keep != 'stop_all' and self.pending_stop_all:
695
+ self.pending_stop_all = False
696
+ had_pending = True
697
+ if keep != 'reset' and self.pending_reset:
698
+ self.pending_reset = False
699
+ had_pending = True
700
+
701
+ if had_pending:
702
+ self.flash_message = None
703
+
704
+ def calculate_manage_layout(self, height: int, width: int) -> tuple[int, int, int]:
705
+ """Calculate instance and message rows for MANAGE screen layout"""
706
+ # Calculate input rows based on buffer length (auto-wrap + literal newlines)
707
+ line_width = width - 2 # Account for "> " prefix
708
+ input_rows = calculate_text_input_rows(self.message_buffer, line_width)
709
+
710
+ separator_rows = 3 # One separator between instances and messages, one before input, one after input
711
+ min_instance_rows = 3
712
+ min_message_rows = 3
713
+
714
+ available = height - input_rows - separator_rows
715
+ # Instance rows = num instances (capped at 60% of available)
716
+ instance_count = len(self.instances)
717
+ max_instance_rows = int(available * 0.6)
718
+ instance_rows = max(min_instance_rows, min(instance_count, max_instance_rows))
719
+ message_rows = available - instance_rows
720
+
721
+ return instance_rows, message_rows, input_rows
722
+
723
+ def render_wrapped_input(self, width: int, input_rows: int) -> List[str]:
724
+ """Render message input (delegates to shared helper)"""
725
+ return render_text_input(
726
+ self.message_buffer,
727
+ self.message_cursor_pos,
728
+ width,
729
+ input_rows,
730
+ prefix="> "
731
+ )
732
+
733
+ def sync_scroll_to_cursor(self):
734
+ """Sync instance scroll position to keep cursor visible"""
735
+ # Calculate visible rows using shared layout function
736
+ width, rows = get_terminal_size()
737
+ body_height = max(10, rows - 3) # Header, flash, footer
738
+ instance_rows, _, _ = self.calculate_manage_layout(body_height, width)
739
+ visible_instance_rows = instance_rows # Full instance section is visible
740
+
741
+ # Scroll up if cursor moved above visible window
742
+ if self.cursor < self.instance_scroll_pos:
743
+ self.instance_scroll_pos = self.cursor
744
+ # Scroll down if cursor moved below visible window
745
+ elif self.cursor >= self.instance_scroll_pos + visible_instance_rows:
746
+ self.instance_scroll_pos = self.cursor - visible_instance_rows + 1
747
+
748
+ def stop_all_instances(self):
749
+ """Stop all enabled instances"""
750
+ try:
751
+ stopped_count = 0
752
+ for name, info in self.instances.items():
753
+ if info['data'].get('enabled', False):
754
+ cmd_stop([name])
755
+ stopped_count += 1
756
+
757
+ if stopped_count > 0:
758
+ self.flash(f"Stopped all ({stopped_count} instances)")
759
+ else:
760
+ self.flash("No instances to stop")
761
+
762
+ self.load_status()
763
+ except Exception as e:
764
+ self.flash_error(f"Error: {str(e)}")
765
+
766
+ def reset_logs(self):
767
+ """Reset logs (archive and clear)"""
768
+ try:
769
+ cmd_reset(['logs'])
770
+ # Reload to clear instance list from display
771
+ self.load_status()
772
+ archive_path = f"{Path.home()}/.hcom/archive/"
773
+ self.flash(f"Logs and instance list archived to {archive_path}", duration=10.0)
774
+ except Exception as e:
775
+ self.flash_error(f"Error: {str(e)}")
776
+
777
+ def run(self) -> int:
778
+ """Main event loop"""
779
+ # Initialize
780
+ ensure_hcom_directories()
781
+
782
+ # Load saved states (config.env first, then launch state reads from it)
783
+ self.load_config_from_file()
784
+ self.load_launch_state()
785
+
786
+ # Enter alternate screen
787
+ sys.stdout.write('\033[?1049h')
788
+ sys.stdout.flush()
789
+
790
+ try:
791
+ with KeyboardInput() as kbd:
792
+ while True:
793
+ # Only update/render if no pending input (paste optimization)
794
+ if not kbd.has_input():
795
+ self.update()
796
+ self.render()
797
+ time.sleep(0.01) # Only sleep when idle
798
+
799
+ key = kbd.get_key()
800
+ if not key:
801
+ time.sleep(0.01) # Also sleep when no key available
802
+ continue
803
+
804
+ if key == 'CTRL_D':
805
+ # Save state before exit
806
+ self.save_launch_state()
807
+ break
808
+ elif key == 'TAB':
809
+ # Save state when switching modes
810
+ if self.mode == Mode.LAUNCH:
811
+ self.save_launch_state()
812
+ self.handle_tab()
813
+ else:
814
+ self.handle_key(key)
815
+
816
+ return 0
817
+ except KeyboardInterrupt:
818
+ # Ctrl+C - clean exit
819
+ self.save_launch_state()
820
+ return 0
821
+ except Exception as e:
822
+ sys.stderr.write(f"Error: {e}\n")
823
+ return 1
824
+ finally:
825
+ # Exit alternate screen
826
+ sys.stdout.write('\033[?1049l')
827
+ sys.stdout.flush()
828
+
829
+ def load_status(self):
830
+ """Load instance status from ~/.hcom/instances/"""
831
+ all_instances = load_all_positions()
832
+
833
+ # Filter using same logic as watch
834
+ instances = {
835
+ name: data for name, data in all_instances.items()
836
+ if should_show_in_watch(data)
837
+ }
838
+
839
+ # Build instance info dict (replace old instances, don't just add)
840
+ new_instances = {}
841
+ for name, data in instances.items():
842
+ enabled, status_type, age_text, description = get_instance_status(data)
843
+
844
+ last_time = data.get('status_time', 0.0)
845
+ age_seconds = time.time() - last_time if last_time > 0 else 999.0
846
+
847
+ new_instances[name] = {
848
+ 'enabled': enabled,
849
+ 'status': status_type,
850
+ 'age_text': age_text,
851
+ 'description': description,
852
+ 'age_seconds': age_seconds,
853
+ 'last_time': last_time,
854
+ 'data': data,
855
+ }
856
+
857
+ self.instances = new_instances
858
+ self.status_counts = get_status_counts(self.instances)
859
+
860
+ def save_launch_state(self):
861
+ """Save launch form values to config.env via claude args parser"""
862
+ # Phase 3: Save Claude args to HCOM_CLAUDE_ARGS in config.env
863
+ try:
864
+ # Load current spec
865
+ claude_args_str = self.config_edit.get('HCOM_CLAUDE_ARGS', '')
866
+ spec = resolve_claude_args(None, claude_args_str if claude_args_str else None)
867
+
868
+ # System flag matches background mode
869
+ system_flag = None
870
+ system_value = None
871
+ if self.launch_system_prompt:
872
+ system_flag = "--system-prompt" if self.launch_background else "--append-system-prompt"
873
+ system_value = self.launch_system_prompt
874
+ else:
875
+ system_value = ""
876
+
877
+ # Update spec with form values
878
+ spec = spec.update(
879
+ background=self.launch_background,
880
+ system_flag=system_flag,
881
+ system_value=system_value,
882
+ prompt=self.launch_prompt, # Always pass value (empty string deletes)
883
+ )
884
+
885
+ # Persist to in-memory edits
886
+ self.config_edit['HCOM_CLAUDE_ARGS'] = spec.to_env_string()
887
+
888
+ # Write config.env
889
+ # Note: HCOM_TAG and HCOM_AGENT are already saved directly when edited in UI
890
+ self.save_config_to_file()
891
+ except Exception as e:
892
+ # Don't crash on save failure, but log to stderr
893
+ sys.stderr.write(f"Warning: Failed to save launch state: {e}\n")
894
+
895
+ def load_launch_state(self):
896
+ """Load launch form values from config.env via claude args parser"""
897
+ # Phase 3: Load Claude args from HCOM_CLAUDE_ARGS in config.env
898
+ try:
899
+ claude_args_str = self.config_edit.get('HCOM_CLAUDE_ARGS', '')
900
+ spec = resolve_claude_args(None, claude_args_str if claude_args_str else None)
901
+
902
+ # Check for parse errors and surface them
903
+ if spec.errors:
904
+ self.flash_error(f"Parse error: {spec.errors[0]}")
905
+
906
+ # Extract Claude-related fields from spec
907
+ self.launch_background = spec.is_background
908
+ self.launch_prompt = spec.positional_tokens[0] if spec.positional_tokens else ""
909
+
910
+ # Extract system prompt (prefer user_system, fallback to user_append)
911
+ if spec.user_system:
912
+ self.launch_system_prompt = spec.user_system
913
+ elif spec.user_append:
914
+ self.launch_system_prompt = spec.user_append
915
+ else:
916
+ self.launch_system_prompt = ""
917
+
918
+ # Initialize cursors to end of text for first-time navigation
919
+ self.launch_prompt_cursor = len(self.launch_prompt)
920
+ self.launch_system_prompt_cursor = len(self.launch_system_prompt)
921
+ except Exception as e:
922
+ # Failed to parse - use defaults and log warning
923
+ sys.stderr.write(f"Warning: Failed to load launch state (using defaults): {e}\n")
924
+
925
+ def load_config_from_file(self, *, raise_on_error: bool = False):
926
+ """Load all vars from ~/.hcom/config.env into editable dict"""
927
+ config_path = Path.home() / '.hcom' / 'config.env'
928
+ try:
929
+ snapshot = load_config_snapshot()
930
+ self.config_snapshot = snapshot
931
+ combined: dict[str, str] = {}
932
+ combined.update(snapshot.values)
933
+ combined.update(snapshot.extras)
934
+ self.config_edit = combined
935
+ self.validation_errors.clear()
936
+ # Track mtime for external change detection
937
+ try:
938
+ self.config_mtime = config_path.stat().st_mtime
939
+ except FileNotFoundError:
940
+ self.config_mtime = 0.0
941
+ except Exception as e:
942
+ if raise_on_error:
943
+ raise
944
+ sys.stderr.write(f"Warning: Failed to load config.env (using defaults): {e}\n")
945
+ self.config_snapshot = None
946
+ self.config_edit = dict(CONFIG_DEFAULTS)
947
+ for line in DEFAULT_CONFIG_HEADER:
948
+ stripped = line.strip()
949
+ if stripped and not stripped.startswith('#') and '=' in line:
950
+ key, _, value = line.partition('=')
951
+ key = key.strip()
952
+ raw = value.strip()
953
+ if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
954
+ raw = raw[1:-1]
955
+ self.config_edit.setdefault(key, raw)
956
+ self.config_mtime = 0.0
957
+
958
+ def save_config_to_file(self):
959
+ """Write current config edits back to ~/.hcom/config.env using canonical writer."""
960
+ known_values = {key: self.config_edit.get(key, '') for key in CONFIG_DEFAULTS.keys()}
961
+ extras = {
962
+ key: value
963
+ for key, value in self.config_edit.items()
964
+ if key not in CONFIG_DEFAULTS
965
+ }
966
+
967
+ field_map = {
968
+ 'timeout': 'HCOM_TIMEOUT',
969
+ 'subagent_timeout': 'HCOM_SUBAGENT_TIMEOUT',
970
+ 'terminal': 'HCOM_TERMINAL',
971
+ 'tag': 'HCOM_TAG',
972
+ 'agent': 'HCOM_AGENT',
973
+ 'claude_args': 'HCOM_CLAUDE_ARGS',
974
+ 'hints': 'HCOM_HINTS',
975
+ }
976
+
977
+ try:
978
+ core = dict_to_hcom_config(known_values)
979
+ except HcomConfigError as exc:
980
+ self.validation_errors.clear()
981
+ for field, message in exc.errors.items():
982
+ env_key = field_map.get(field, field.upper())
983
+ self.validation_errors[env_key] = message
984
+ first_error = next(iter(self.validation_errors.values()), "Invalid config")
985
+ self.flash_error(first_error)
986
+ return
987
+ except Exception as exc:
988
+ self.flash_error(f"Validation error: {exc}")
989
+ return
990
+
991
+ try:
992
+ save_config(core, extras)
993
+ self.validation_errors.clear()
994
+ self.flash_message = None
995
+ # Reload snapshot to pick up canonical formatting
996
+ self.load_config_from_file()
997
+ self.load_launch_state()
998
+ except Exception as exc:
999
+ self.flash_error(f"Save failed: {exc}")
1000
+
1001
+ def check_external_config_changes(self):
1002
+ """Reload config.env if changed on disk, preserving active edits."""
1003
+ config_path = Path.home() / '.hcom' / 'config.env'
1004
+ try:
1005
+ mtime = config_path.stat().st_mtime
1006
+ except FileNotFoundError:
1007
+ return
1008
+
1009
+ if mtime <= self.config_mtime:
1010
+ return # No change
1011
+
1012
+ # Save what's currently being edited
1013
+ active_field = self.get_current_launch_field_info()
1014
+
1015
+ # Backup current edits
1016
+ old_edit = dict(self.config_edit)
1017
+
1018
+ # Reload from disk
1019
+ try:
1020
+ self.load_config_from_file()
1021
+ self.load_launch_state()
1022
+ except Exception as exc:
1023
+ self.flash_error(f"Failed to reload config.env: {exc}")
1024
+ return
1025
+
1026
+ # Update mtime
1027
+ try:
1028
+ self.config_mtime = config_path.stat().st_mtime
1029
+ except FileNotFoundError:
1030
+ self.config_mtime = 0.0
1031
+
1032
+ # Restore in-progress edit if field changed externally
1033
+ if active_field and active_field[0]:
1034
+ key, value, cursor = active_field
1035
+ # Check if the field we're editing changed externally
1036
+ if key in old_edit and old_edit.get(key) != self.config_edit.get(key):
1037
+ # External change to field you're editing - keep your version
1038
+ self.config_edit[key] = value
1039
+ if key in self.config_field_cursors:
1040
+ self.config_field_cursors[key] = cursor
1041
+ self.flash(f"Kept in-progress {key} edit (external change ignored)")
1042
+
1043
+ def resolve_editor_command(self) -> tuple[list[str] | None, str | None]:
1044
+ """Resolve preferred editor command and display label for config edits."""
1045
+ config_path = Path.home() / '.hcom' / 'config.env'
1046
+ editor = os.environ.get('VISUAL') or os.environ.get('EDITOR')
1047
+ pretty_names = {
1048
+ 'code': 'VS Code',
1049
+ 'code-insiders': 'VS Code Insiders',
1050
+ 'hx': 'Helix',
1051
+ 'helix': 'Helix',
1052
+ 'nvim': 'Neovim',
1053
+ 'vim': 'Vim',
1054
+ 'nano': 'nano',
1055
+ }
1056
+
1057
+ if editor:
1058
+ try:
1059
+ parts = shlex.split(editor)
1060
+ except ValueError:
1061
+ parts = []
1062
+ if parts:
1063
+ command = parts[0]
1064
+ base_name = Path(command).name or command
1065
+ normalized = base_name.lower()
1066
+ if normalized.endswith('.exe'):
1067
+ normalized = normalized[:-4]
1068
+ label = pretty_names.get(normalized, base_name)
1069
+ return parts + [str(config_path)], label
1070
+
1071
+ if code_bin := shutil.which('code'):
1072
+ return [code_bin, str(config_path)], 'VS Code'
1073
+ if nano_bin := shutil.which('nano'):
1074
+ return [nano_bin, str(config_path)], 'nano'
1075
+ if vim_bin := shutil.which('vim'):
1076
+ return [vim_bin, str(config_path)], 'vim'
1077
+ return None, None
1078
+
1079
+ def open_config_in_editor(self):
1080
+ """Open config.env in the resolved editor."""
1081
+ cmd, label = self.resolve_editor_command()
1082
+ if not cmd:
1083
+ self.flash_error("No external editor found")
1084
+ return
1085
+
1086
+ # Ensure latest in-memory edits are persisted before handing off
1087
+ self.save_config_to_file()
1088
+
1089
+ try:
1090
+ subprocess.Popen(cmd)
1091
+ self.flash(f"Opening config.env in {label or 'VS Code'}...")
1092
+ except Exception as exc:
1093
+ self.flash_error(f"Failed to launch {label or 'editor'}: {exc}")
1094
+
1095
+
1096
+ def update(self):
1097
+ """Update state (status, messages)"""
1098
+ now = time.time()
1099
+
1100
+ # Update status every 0.5 seconds
1101
+ if now - self.last_status_update >= 0.5:
1102
+ self.load_status()
1103
+ self.last_status_update = now
1104
+
1105
+ # Clear pending toggle after timeout
1106
+ if self.pending_toggle and (now - self.pending_toggle_time) > self.CONFIRMATION_TIMEOUT:
1107
+ self.pending_toggle = None
1108
+
1109
+ # Clear completed toggle display after 2s (match flash default)
1110
+ if self.completed_toggle and (now - self.completed_toggle_time) >= 2.0:
1111
+ self.completed_toggle = None
1112
+
1113
+ # Clear pending stop all after timeout
1114
+ if self.pending_stop_all and (now - self.pending_stop_all_time) > self.CONFIRMATION_TIMEOUT:
1115
+ self.pending_stop_all = False
1116
+
1117
+ # Clear pending reset after timeout
1118
+ if self.pending_reset and (now - self.pending_reset_time) > self.CONFIRMATION_TIMEOUT:
1119
+ self.pending_reset = False
1120
+
1121
+ # Load available agents if on LAUNCH screen
1122
+ if self.mode == Mode.LAUNCH and not self.available_agents:
1123
+ self.available_agents = list_available_agents()
1124
+
1125
+ # Periodic config reload check (only in Launch mode)
1126
+ if self.mode == Mode.LAUNCH:
1127
+ if not hasattr(self, 'last_config_check'):
1128
+ self.last_config_check = 0.0
1129
+ if not hasattr(self, 'config_mtime'):
1130
+ self.config_mtime = 0.0
1131
+
1132
+ if (now - self.last_config_check) >= 0.5:
1133
+ self.last_config_check = now
1134
+ self.check_external_config_changes()
1135
+
1136
+ # Load messages for MANAGE screen preview (with file size caching)
1137
+ if self.mode == Mode.MANAGE:
1138
+ log_file = self.hcom_dir / 'hcom.log'
1139
+ if log_file.exists():
1140
+ current_size = log_file.stat().st_size
1141
+ # Only re-parse if file size changed (handles truncation too)
1142
+ if current_size != self.last_log_size:
1143
+ try:
1144
+ result = parse_log_messages(log_file)
1145
+ if result and hasattr(result, 'messages') and result.messages:
1146
+ # Convert from dict format to tuple format (time, sender, message)
1147
+ all_messages = [
1148
+ (msg.get('timestamp', ''), msg.get('from', ''), msg.get('message', ''))
1149
+ for msg in result.messages
1150
+ if isinstance(msg, dict) and 'from' in msg and 'message' in msg
1151
+ ]
1152
+ # Update preview (last N)
1153
+ self.messages = all_messages[-MESSAGE_PREVIEW_LIMIT:] if len(all_messages) > MESSAGE_PREVIEW_LIMIT else all_messages
1154
+ # Update last message time for LOG tab pulse
1155
+ if all_messages:
1156
+ last_msg_timestamp = all_messages[-1][0] # timestamp string
1157
+ try:
1158
+ from datetime import datetime
1159
+ if 'T' in last_msg_timestamp:
1160
+ dt = datetime.fromisoformat(last_msg_timestamp.replace('Z', '+00:00'))
1161
+ self.last_message_time = dt.timestamp()
1162
+ except Exception:
1163
+ pass # Keep previous time if parse fails
1164
+ else:
1165
+ self.messages = []
1166
+ self.last_message_time = 0.0
1167
+ self.last_log_size = current_size
1168
+ except Exception:
1169
+ # Parse failed - keep existing messages
1170
+ pass
1171
+ elif current_size == 0:
1172
+ # File is empty - clear messages
1173
+ self.messages = []
1174
+ self.last_message_time = 0.0
1175
+ self.last_log_size = 0
1176
+ else:
1177
+ # File doesn't exist - clear messages
1178
+ self.messages = []
1179
+ self.last_message_time = 0.0
1180
+ self.last_log_size = 0
1181
+
1182
+ def build_status_bar(self, highlight_tab: str | None = None) -> str:
1183
+ """Build status bar with tabs - shared by TUI header and native log view
1184
+ Args:
1185
+ highlight_tab: Which tab to highlight ("MANAGE", "LAUNCH", or "LOG")
1186
+ If None, uses self.mode
1187
+ """
1188
+ # Determine which tab to highlight
1189
+ if highlight_tab is None:
1190
+ highlight_tab = self.mode.value.upper()
1191
+
1192
+ # Calculate message pulse colors for LOG tab
1193
+ if self.last_message_time > 0:
1194
+ seconds_since_msg = time.time() - self.last_message_time
1195
+ else:
1196
+ seconds_since_msg = 9999.0 # No messages yet - use quiet state
1197
+ log_bg_color, log_fg_color = get_message_pulse_colors(seconds_since_msg)
1198
+
1199
+ # Build status display (colored blocks for unselected, orange for selected)
1200
+ is_manage_selected = (highlight_tab == "MANAGE")
1201
+ status_parts = []
1202
+
1203
+ # Use shared status configuration (background colors for statusline blocks)
1204
+ for status_type in STATUS_ORDER:
1205
+ count = self.status_counts.get(status_type, 0)
1206
+ if count > 0:
1207
+ color, symbol = STATUS_BG_MAP[status_type]
1208
+ if is_manage_selected:
1209
+ # Selected: orange bg + black text (v1 style)
1210
+ part = f"{FG_BLACK}{BOLD}{BG_ORANGE} {count} {symbol} {RESET}"
1211
+ else:
1212
+ # Unselected: colored blocks (hcom watch style)
1213
+ text_color = FG_BLACK if color == BG_YELLOW else FG_WHITE
1214
+ part = f"{text_color}{BOLD}{color} {count} {symbol} {RESET}"
1215
+ status_parts.append(part)
1216
+
1217
+ # No instances - use orange if selected, charcoal if not
1218
+ if status_parts:
1219
+ status_display = "".join(status_parts)
1220
+ elif is_manage_selected:
1221
+ status_display = f"{FG_BLACK}{BOLD}{BG_ORANGE} 0 {RESET}"
1222
+ else:
1223
+ status_display = f"{BG_CHARCOAL}{FG_WHITE} 0 {RESET}"
1224
+
1225
+ # Build tabs: MANAGE, LAUNCH, and LOG (LOG only shown in native view)
1226
+ tab_names = ["MANAGE", "LAUNCH", "LOG"]
1227
+ tabs = []
1228
+
1229
+ for tab_name in tab_names:
1230
+ # MANAGE tab shows status counts instead of text
1231
+ if tab_name == "MANAGE":
1232
+ label = status_display
1233
+ else:
1234
+ label = tab_name
1235
+
1236
+ # Highlight current tab (non-MANAGE tabs get orange bg)
1237
+ if tab_name == highlight_tab and tab_name != "MANAGE":
1238
+ # Selected tab: always orange bg + black fg (LOG and LAUNCH same)
1239
+ tabs.append(f"{BG_ORANGE}{FG_BLACK}{BOLD} {label} {RESET}")
1240
+ elif tab_name == "MANAGE":
1241
+ # MANAGE tab is just status blocks (already has color/bg)
1242
+ tabs.append(f" {label}")
1243
+ elif tab_name == "LOG":
1244
+ # LOG tab when not selected: use pulse colors (white→charcoal fade)
1245
+ tabs.append(f"{log_bg_color}{log_fg_color} {label} {RESET}")
1246
+ else:
1247
+ # LAUNCH when not selected: charcoal bg (milder than black)
1248
+ tabs.append(f"{BG_CHARCOAL}{FG_WHITE} {label} {RESET}")
1249
+
1250
+ tab_display = " ".join(tabs)
1251
+
1252
+ return f"{BOLD}hcom{RESET} {tab_display}"
1253
+
1254
+ def build_flash(self) -> Optional[str]:
1255
+ """Build flash notification if active"""
1256
+ if self.flash_message and time.time() < self.flash_until:
1257
+ color_map = {
1258
+ 'red': FG_RED,
1259
+ 'white': FG_WHITE,
1260
+ 'orange': FG_ORANGE
1261
+ }
1262
+ color_code = color_map.get(self.flash_color, FG_ORANGE)
1263
+ cols, _ = get_terminal_size()
1264
+ # Reserve space for "• " prefix and separator/padding
1265
+ max_msg_width = cols - 10
1266
+ msg = truncate_ansi(self.flash_message, max_msg_width) if len(self.flash_message) > max_msg_width else self.flash_message
1267
+ return f"{BOLD}{color_code}• {msg}{RESET}"
1268
+ return None
1269
+
1270
+ def build_manage_screen(self, height: int, width: int) -> List[str]:
1271
+ """Build compact Manage screen"""
1272
+ # Use minimum height for layout calculation to maintain structure
1273
+ layout_height = max(10, height)
1274
+
1275
+ lines = []
1276
+
1277
+ # Calculate layout using shared function
1278
+ instance_rows, message_rows, input_rows = self.calculate_manage_layout(layout_height, width)
1279
+
1280
+ # Sort instances by creation time (newest first) - stable, no jumping
1281
+ sorted_instances = sorted(
1282
+ self.instances.items(),
1283
+ key=lambda x: -x[1]['data'].get('created_at', 0.0)
1284
+ )
1285
+
1286
+ total_instances = len(sorted_instances)
1287
+
1288
+ # Restore cursor position by instance name (stable across sorts)
1289
+ if self.cursor_instance_name and sorted_instances:
1290
+ found = False
1291
+ for i, (name, _) in enumerate(sorted_instances):
1292
+ if name == self.cursor_instance_name:
1293
+ self.cursor = i
1294
+ found = True
1295
+ break
1296
+ if not found:
1297
+ # Instance disappeared, reset cursor
1298
+ self.cursor = 0
1299
+ self.cursor_instance_name = None
1300
+ self.sync_scroll_to_cursor()
1301
+
1302
+ # Ensure cursor is valid
1303
+ if sorted_instances:
1304
+ self.cursor = max(0, min(self.cursor, total_instances - 1))
1305
+ # Update tracked instance name
1306
+ if self.cursor < len(sorted_instances):
1307
+ self.cursor_instance_name = sorted_instances[self.cursor][0]
1308
+ else:
1309
+ self.cursor = 0
1310
+ self.cursor_instance_name = None
1311
+
1312
+ # Empty state - no instances
1313
+ if total_instances == 0:
1314
+ lines.append('')
1315
+ lines.append(f"{FG_GRAY}No instances running{RESET}")
1316
+ lines.append('')
1317
+ lines.append(f"{FG_GRAY}Press Tab → LAUNCH to create instances{RESET}")
1318
+ # Pad to instance_rows
1319
+ while len(lines) < instance_rows:
1320
+ lines.append('')
1321
+ # Skip to message section
1322
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1323
+ # No messages to show either
1324
+ lines.append(f"{FG_GRAY}(no messages){RESET}")
1325
+ while len(lines) < instance_rows + message_rows + 1:
1326
+ lines.append('')
1327
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1328
+ # Input area (auto-wrapped)
1329
+ input_lines = self.render_wrapped_input(width, input_rows)
1330
+ lines.extend(input_lines)
1331
+ # Separator after input (before footer)
1332
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1333
+ while len(lines) < height:
1334
+ lines.append('')
1335
+ return lines[:height]
1336
+
1337
+ # Calculate visible window
1338
+ max_scroll = max(0, total_instances - instance_rows)
1339
+ self.instance_scroll_pos = max(0, min(self.instance_scroll_pos, max_scroll))
1340
+
1341
+ visible_start = self.instance_scroll_pos
1342
+ visible_end = min(visible_start + instance_rows, total_instances)
1343
+ visible_instances = sorted_instances[visible_start:visible_end]
1344
+
1345
+ # Render instances - compact one-line format
1346
+ for i, (name, info) in enumerate(visible_instances):
1347
+ absolute_idx = visible_start + i
1348
+
1349
+ enabled = info.get('enabled', False)
1350
+ status = info.get('status', "unknown")
1351
+ _, icon = STATUS_MAP.get(status, (BG_GRAY, '?'))
1352
+ color = STATUS_FG.get(status, FG_WHITE)
1353
+
1354
+ # Always show description if non-empty
1355
+ display_text = info.get('description', '')
1356
+
1357
+ # Use age_text from get_instance_status (clean format: "16m", no parens)
1358
+ age_text = info.get('age_text', '')
1359
+ age_str = f"{age_text} ago" if age_text else ""
1360
+ # Right-align age in fixed width column (e.g., " 16m ago")
1361
+ age_width = 10
1362
+ age_padded = age_str.rjust(age_width)
1363
+
1364
+ # Background indicator - include in name before padding
1365
+ is_background = info.get('data', {}).get('background', False)
1366
+ bg_marker_text = " [headless]" if is_background else ""
1367
+ bg_marker_visible_len = 11 if is_background else 0 # " [headless]" = 11 chars
1368
+
1369
+ # Timeout warning indicator
1370
+ timeout_marker = ""
1371
+ if enabled and status == "waiting":
1372
+ age_seconds = info.get('age_seconds', 0)
1373
+ data = info.get('data', {})
1374
+ is_subagent = bool(data.get('parent_session_id'))
1375
+
1376
+ if is_subagent:
1377
+ timeout = get_config().subagent_timeout
1378
+ remaining = timeout - age_seconds
1379
+ if 0 < remaining < 10:
1380
+ timeout_marker = f" {FG_YELLOW}⏱ {int(remaining)}s{RESET}"
1381
+ else:
1382
+ timeout = data.get('wait_timeout', get_config().timeout)
1383
+ remaining = timeout - age_seconds
1384
+ if 0 < remaining < 60:
1385
+ timeout_marker = f" {FG_YELLOW}⏱ {int(remaining)}s{RESET}"
1386
+
1387
+ # Smart truncate name to fit in 24 chars including [headless] and state symbol
1388
+ # Available: 24 - bg_marker_len - (2 for " +/-" on cursor row)
1389
+ max_name_len = 22 - bg_marker_visible_len # Leave 2 chars for " +" or " -"
1390
+ display_name = smart_truncate_name(name, max_name_len)
1391
+
1392
+ # State indicator (only on cursor row)
1393
+ if absolute_idx == self.cursor:
1394
+ is_pending = self.pending_toggle == name and (time.time() - self.pending_toggle_time) <= self.CONFIRMATION_TIMEOUT
1395
+ if is_pending:
1396
+ state_symbol = "±"
1397
+ state_color = FG_GOLD
1398
+ elif enabled:
1399
+ state_symbol = "+"
1400
+ state_color = color
1401
+ else:
1402
+ state_symbol = "-"
1403
+ state_color = color
1404
+ # Format: name [headless] +/- (total 24 chars)
1405
+ name_with_marker = f"{display_name}{bg_marker_text} {state_symbol}"
1406
+ name_padded = ansi_ljust(name_with_marker, 24)
1407
+ else:
1408
+ # Format: name [headless] (total 24 chars)
1409
+ name_with_marker = f"{display_name}{bg_marker_text}"
1410
+ name_padded = ansi_ljust(name_with_marker, 24)
1411
+
1412
+ # Description separator - only show if description exists
1413
+ desc_sep = ": " if display_text else ""
1414
+
1415
+ # Bold if enabled, dim if disabled
1416
+ weight = BOLD if enabled else DIM
1417
+
1418
+ if absolute_idx == self.cursor:
1419
+ # Highlighted row - Format: icon name [headless] +/- age ago: description [timeout]
1420
+ line = f"{BG_CHARCOAL}{color}{icon} {weight}{color}{name_padded}{RESET}{BG_CHARCOAL}{weight}{FG_GRAY}{age_padded}{desc_sep}{display_text}{timeout_marker}{RESET}"
1421
+ line = truncate_ansi(line, width)
1422
+ line = bg_ljust(line, width, BG_CHARCOAL)
1423
+ else:
1424
+ # Normal row - Format: icon name [headless] age ago: description [timeout]
1425
+ line = f"{color}{icon}{RESET} {weight}{color}{name_padded}{RESET}{weight}{FG_GRAY}{age_padded}{desc_sep}{display_text}{timeout_marker}{RESET}"
1426
+ line = truncate_ansi(line, width)
1427
+
1428
+ lines.append(line)
1429
+
1430
+ # Add scroll indicators if needed (indicator stays at edge, cursor moves if conflict)
1431
+ if total_instances > instance_rows:
1432
+ # If cursor will conflict with indicator, move cursor line first
1433
+ if visible_start > 0 and self.cursor == visible_start:
1434
+ # Save cursor line (at position 0), move to position 1
1435
+ cursor_line = lines[0]
1436
+ lines[0] = lines[1] if len(lines) > 1 else ""
1437
+ if len(lines) > 1:
1438
+ lines[1] = cursor_line
1439
+
1440
+ if visible_end < total_instances and self.cursor == visible_end - 1:
1441
+ # Save cursor line (at position -1), move to position -2
1442
+ cursor_line = lines[-1]
1443
+ lines[-1] = lines[-2] if len(lines) > 1 else ""
1444
+ if len(lines) > 1:
1445
+ lines[-2] = cursor_line
1446
+
1447
+ # Now add indicators at edges (may overwrite moved content, that's fine)
1448
+ if visible_start > 0:
1449
+ count_above = visible_start
1450
+ indicator = f"{FG_GRAY}↑ {count_above} more{RESET}"
1451
+ lines[0] = ansi_ljust(indicator, width)
1452
+
1453
+ if visible_end < total_instances:
1454
+ count_below = total_instances - visible_end
1455
+ indicator = f"{FG_GRAY}↓ {count_below} more{RESET}"
1456
+ lines[-1] = ansi_ljust(indicator, width)
1457
+
1458
+ # Pad instances
1459
+ while len(lines) < instance_rows:
1460
+ lines.append('')
1461
+
1462
+ # Separator
1463
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1464
+
1465
+ # Messages - compact format with word wrap
1466
+ if self.messages:
1467
+ all_wrapped_lines = []
1468
+
1469
+ # Find longest sender name for alignment
1470
+ max_sender_len = max(len(sender) for _, sender, _ in self.messages) if self.messages else 12
1471
+ max_sender_len = min(max_sender_len, 12) # Cap at reasonable width
1472
+
1473
+ for time_str, sender, message in self.messages:
1474
+ # Format timestamp
1475
+ try:
1476
+ from datetime import datetime
1477
+ if 'T' in time_str:
1478
+ dt = datetime.fromisoformat(time_str.replace('Z', '+00:00'))
1479
+ display_time = dt.strftime('%H:%M')
1480
+ else:
1481
+ display_time = time_str
1482
+ except Exception:
1483
+ display_time = time_str[:5] if len(time_str) >= 5 else time_str
1484
+
1485
+ # Smart truncate sender (prefix + suffix with middle ellipsis)
1486
+ sender_display = smart_truncate_name(sender, max_sender_len)
1487
+
1488
+ # Replace literal newlines with space for preview
1489
+ display_message = message.replace('\n', ' ')
1490
+
1491
+ # Bold @mentions in message (e.g., @name becomes **@name**)
1492
+ if '@' in display_message:
1493
+ import re
1494
+ display_message = re.sub(r'(@[\w\-_]+)', f'{BOLD}\\1{RESET}{FG_LIGHTGRAY}', display_message)
1495
+
1496
+ # Calculate available width for message (reserve space for time + sender + spacing)
1497
+ # Format: "HH:MM sender message"
1498
+ prefix_len = 5 + 1 + max_sender_len + 1 # time + space + sender + space
1499
+ max_msg_len = width - prefix_len
1500
+
1501
+ # Wrap message text
1502
+ if max_msg_len > 0:
1503
+ wrapper = AnsiTextWrapper(width=max_msg_len)
1504
+ wrapped = wrapper.wrap(display_message)
1505
+
1506
+ # Add timestamp/sender to first line, indent continuation lines manually
1507
+ # Add color to each line so truncation doesn't lose formatting
1508
+ indent = ' ' * prefix_len
1509
+ for i, wrapped_line in enumerate(wrapped):
1510
+ if i == 0:
1511
+ line = f"{FG_GRAY}{display_time}{RESET} {sender_display:<{max_sender_len}} {FG_LIGHTGRAY}{wrapped_line}{RESET}"
1512
+ else:
1513
+ line = f"{indent}{FG_LIGHTGRAY}{wrapped_line}{RESET}"
1514
+ all_wrapped_lines.append(line)
1515
+ else:
1516
+ # Fallback if width too small
1517
+ all_wrapped_lines.append(f"{FG_GRAY}{display_time}{RESET} {sender_display:<{max_sender_len}}")
1518
+
1519
+ # Take last N lines to fit available space (mid-message truncation)
1520
+ visible_lines = all_wrapped_lines[-message_rows:] if len(all_wrapped_lines) > message_rows else all_wrapped_lines
1521
+ lines.extend(visible_lines)
1522
+ else:
1523
+ # ASCII art logo
1524
+ lines.append(f"{FG_GRAY}No messages - Tab to LAUNCH to create instances{RESET}")
1525
+ lines.append('')
1526
+ lines.append(f"{FG_ORANGE} ╦ ╦╔═╗╔═╗╔╦╗{RESET}")
1527
+ lines.append(f"{FG_ORANGE} ╠═╣║ ║ ║║║║{RESET}")
1528
+ lines.append(f"{FG_ORANGE} ╩ ╩╚═╝╚═╝╩ ╩{RESET}")
1529
+
1530
+ # Pad messages
1531
+ while len(lines) < instance_rows + message_rows + 1: # +1 for separator
1532
+ lines.append('')
1533
+
1534
+ # Separator before input
1535
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1536
+
1537
+ # Input area (auto-wrapped)
1538
+ input_lines = self.render_wrapped_input(width, input_rows)
1539
+ lines.extend(input_lines)
1540
+
1541
+ # Separator after input (before footer)
1542
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
1543
+
1544
+ # Pad to fill height
1545
+ while len(lines) < height:
1546
+ lines.append('')
1547
+
1548
+ return lines[:height]
1549
+
1550
+ def get_launch_command_preview(self) -> str:
1551
+ """Build preview using spec (matches exactly what will be launched)"""
1552
+ try:
1553
+ # Load spec and update with form values (same logic as do_launch)
1554
+ claude_args_str = self.config_edit.get('HCOM_CLAUDE_ARGS', '')
1555
+ spec = resolve_claude_args(None, claude_args_str if claude_args_str else None)
1556
+
1557
+ # Update spec with form values
1558
+ system_flag = None
1559
+ system_value = None
1560
+ if self.launch_system_prompt:
1561
+ system_flag = "--system-prompt" if self.launch_background else "--append-system-prompt"
1562
+ system_value = self.launch_system_prompt
1563
+
1564
+ spec = spec.update(
1565
+ background=self.launch_background,
1566
+ system_flag=system_flag,
1567
+ system_value=system_value,
1568
+ prompt=self.launch_prompt,
1569
+ )
1570
+
1571
+ # Build preview
1572
+ parts = []
1573
+
1574
+ # Environment variables (read from config_fields - source of truth)
1575
+ env_parts = []
1576
+ agent = self.config_edit.get('HCOM_AGENT', '')
1577
+ if agent:
1578
+ agent_display = agent if len(agent) <= 15 else agent[:12] + "..."
1579
+ env_parts.append(f"HCOM_AGENT={agent_display}")
1580
+ tag = self.config_edit.get('HCOM_TAG', '')
1581
+ if tag:
1582
+ tag_display = tag if len(tag) <= 15 else tag[:12] + "..."
1583
+ env_parts.append(f"HCOM_TAG={tag_display}")
1584
+ if env_parts:
1585
+ parts.append(" ".join(env_parts))
1586
+
1587
+ # Base command
1588
+ count = self.launch_count if self.launch_count else "1"
1589
+ parts.append(f"hcom {count}")
1590
+
1591
+ # Claude args from spec (truncate long values for preview)
1592
+ tokens = spec.rebuild_tokens(include_system=True)
1593
+ if tokens:
1594
+ preview_tokens = []
1595
+ for token in tokens:
1596
+ if len(token) > 30:
1597
+ preview_tokens.append(f'"{token[:27]}..."')
1598
+ elif ' ' in token:
1599
+ preview_tokens.append(f'"{token}"')
1600
+ else:
1601
+ preview_tokens.append(token)
1602
+ parts.append("claude " + " ".join(preview_tokens))
1603
+
1604
+ return " ".join(parts)
1605
+ except Exception:
1606
+ return "(preview unavailable - check HCOM_CLAUDE_ARGS)"
1607
+
1608
+ def get_current_launch_field_info(self) -> tuple[str, str, int] | None:
1609
+ """Get (field_key, field_value, cursor_pos) for currently selected field, or None"""
1610
+ if self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor >= 0:
1611
+ fields = self.build_claude_fields()
1612
+ if self.claude_cursor < len(fields):
1613
+ field = fields[self.claude_cursor]
1614
+ if field.key == 'prompt':
1615
+ # Default cursor to end if not set or invalid
1616
+ if self.launch_prompt_cursor > len(self.launch_prompt):
1617
+ self.launch_prompt_cursor = len(self.launch_prompt)
1618
+ return ('prompt', self.launch_prompt, self.launch_prompt_cursor)
1619
+ elif field.key == 'system_prompt':
1620
+ if self.launch_system_prompt_cursor > len(self.launch_system_prompt):
1621
+ self.launch_system_prompt_cursor = len(self.launch_system_prompt)
1622
+ return ('system_prompt', self.launch_system_prompt, self.launch_system_prompt_cursor)
1623
+ elif field.key == 'claude_args':
1624
+ value = self.config_edit.get('HCOM_CLAUDE_ARGS', '')
1625
+ cursor = self.config_field_cursors.get('HCOM_CLAUDE_ARGS', len(value))
1626
+ cursor = min(cursor, len(value))
1627
+ self.config_field_cursors['HCOM_CLAUDE_ARGS'] = cursor
1628
+ return ('HCOM_CLAUDE_ARGS', value, cursor)
1629
+ elif self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor >= 0:
1630
+ fields = self.build_hcom_fields()
1631
+ if self.hcom_cursor < len(fields):
1632
+ field = fields[self.hcom_cursor]
1633
+ value = self.config_edit.get(field.key, '')
1634
+ cursor = self.config_field_cursors.get(field.key, len(value))
1635
+ cursor = min(cursor, len(value))
1636
+ self.config_field_cursors[field.key] = cursor
1637
+ return (field.key, value, cursor)
1638
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION and self.custom_env_cursor >= 0:
1639
+ fields = self.build_custom_env_fields()
1640
+ if self.custom_env_cursor < len(fields):
1641
+ field = fields[self.custom_env_cursor]
1642
+ value = self.config_edit.get(field.key, '')
1643
+ cursor = self.config_field_cursors.get(field.key, len(value))
1644
+ cursor = min(cursor, len(value))
1645
+ self.config_field_cursors[field.key] = cursor
1646
+ return (field.key, value, cursor)
1647
+ return None
1648
+
1649
+ def update_launch_field(self, field_key: str, new_value: str, new_cursor: int):
1650
+ """Update a launch field with new value and cursor position (extracted helper)"""
1651
+ if field_key == 'prompt':
1652
+ self.launch_prompt = new_value
1653
+ self.launch_prompt_cursor = new_cursor
1654
+ self.save_launch_state()
1655
+ elif field_key == 'system_prompt':
1656
+ self.launch_system_prompt = new_value
1657
+ self.launch_system_prompt_cursor = new_cursor
1658
+ self.save_launch_state()
1659
+ elif field_key == 'HCOM_CLAUDE_ARGS':
1660
+ self.config_edit[field_key] = new_value
1661
+ self.config_field_cursors[field_key] = new_cursor
1662
+ self.save_config_to_file()
1663
+ self.load_launch_state()
1664
+ else:
1665
+ self.config_edit[field_key] = new_value
1666
+ self.config_field_cursors[field_key] = new_cursor
1667
+ self.save_config_to_file()
1668
+
1669
+ def build_claude_fields(self) -> List[Field]:
1670
+ """Build Claude section fields from memory vars"""
1671
+ return [
1672
+ Field("prompt", "Prompt", "text", self.launch_prompt, hint="text string"),
1673
+ Field("system_prompt", "System Prompt", "text", self.launch_system_prompt, hint="text string"),
1674
+ Field("background", "Headless", "checkbox", self.launch_background, hint="enter to toggle"),
1675
+ Field("claude_args", "Claude Args", "text", self.config_edit.get('HCOM_CLAUDE_ARGS', ''), hint="flags string"),
1676
+ ]
1677
+
1678
+ def build_hcom_fields(self) -> List[Field]:
1679
+ """Build HCOM section fields - always show all expected HCOM vars"""
1680
+ # Extract expected keys from DEFAULT_CONFIG_DEFAULTS (excluding HCOM_CLAUDE_ARGS)
1681
+ expected_keys = [
1682
+ line.split('=')[0] for line in DEFAULT_CONFIG_DEFAULTS
1683
+ if line.startswith('HCOM_') and not line.startswith('HCOM_CLAUDE_ARGS=')
1684
+ ]
1685
+
1686
+ fields = []
1687
+ for key in expected_keys:
1688
+ display_name = key.replace('HCOM_', '').replace('_', ' ').title()
1689
+ override = CONFIG_FIELD_OVERRIDES.get(key, {})
1690
+ field_type = override.get('type', 'text')
1691
+ options = override.get('options')
1692
+ if callable(options):
1693
+ options = options()
1694
+ hint = override.get('hint', '')
1695
+ value = self.config_edit.get(key, '')
1696
+ fields.append(Field(key, display_name, field_type, value, options if isinstance(options, list) or options is None else None, hint))
1697
+
1698
+ # Also include any extra HCOM_* vars from config_fields (user-added)
1699
+ for key in sorted(self.config_edit.keys()):
1700
+ if key.startswith('HCOM_') and key != 'HCOM_CLAUDE_ARGS' and key not in expected_keys:
1701
+ display_name = key.replace('HCOM_', '').replace('_', ' ').title()
1702
+ override = CONFIG_FIELD_OVERRIDES.get(key, {})
1703
+ field_type = override.get('type', 'text')
1704
+ options = override.get('options')
1705
+ if callable(options):
1706
+ options = options()
1707
+ hint = override.get('hint', '')
1708
+ fields.append(Field(key, display_name, field_type, self.config_edit.get(key, ''), options if isinstance(options, list) or options is None else None, hint))
1709
+
1710
+ return fields
1711
+
1712
+ def build_custom_env_fields(self) -> List[Field]:
1713
+ """Build Custom Env section fields from config_fields"""
1714
+ return [Field(key, key, 'text', self.config_edit.get(key, ''))
1715
+ for key in sorted(self.config_edit.keys())
1716
+ if not key.startswith('HCOM_')]
1717
+
1718
+ def render_section_fields(
1719
+ self,
1720
+ lines: List[str],
1721
+ fields: List[Field],
1722
+ expanded: bool,
1723
+ section_field: LaunchField,
1724
+ section_cursor: int,
1725
+ width: int,
1726
+ color: str
1727
+ ) -> int | None:
1728
+ """Render fields for an expandable section (extracted helper)
1729
+
1730
+ Returns selected_field_start_line if a field is selected, None otherwise.
1731
+ """
1732
+ selected_field_start_line = None
1733
+
1734
+ if expanded or (self.launch_field == section_field and section_cursor >= 0):
1735
+ visible_fields = fields if expanded else fields[:3]
1736
+ for i, field in enumerate(visible_fields):
1737
+ field_selected = (self.launch_field == section_field and section_cursor == i)
1738
+ if field_selected:
1739
+ selected_field_start_line = len(lines)
1740
+ lines.append(self.render_field(field, field_selected, width, color))
1741
+ if not expanded and len(fields) > 3:
1742
+ lines.append(f"{FG_GRAY} +{len(fields) - 3} more (enter to expand){RESET}")
1743
+
1744
+ return selected_field_start_line
1745
+
1746
+ def render_field(self, field: Field, selected: bool, width: int, value_color: str | None = None) -> str:
1747
+ """Render a single field line"""
1748
+ indent = " "
1749
+ # Default to standard orange if not specified
1750
+ if value_color is None:
1751
+ value_color = FG_ORANGE
1752
+
1753
+ # Determine if field is in config (for proper state display)
1754
+ in_config = field.key in self.config_edit
1755
+
1756
+ # Format value based on type
1757
+ # For Claude fields (prompt, system_prompt, background), extract defaults from HCOM_CLAUDE_ARGS
1758
+ if field.key in ('prompt', 'system_prompt', 'background'):
1759
+ claude_args_default = CONFIG_DEFAULTS.get('HCOM_CLAUDE_ARGS', '')
1760
+ default_spec = resolve_claude_args(None, claude_args_default if claude_args_default else None)
1761
+ if field.key == 'prompt':
1762
+ default = default_spec.positional_tokens[0] if default_spec.positional_tokens else ""
1763
+ elif field.key == 'system_prompt':
1764
+ default = default_spec.user_system or default_spec.user_append or ""
1765
+ else: # background
1766
+ default = default_spec.is_background
1767
+ else:
1768
+ default = CONFIG_DEFAULTS.get(field.key, '')
1769
+
1770
+ # Check if field has validation error
1771
+ has_error = field.key in self.validation_errors
1772
+
1773
+ if field.field_type == 'checkbox':
1774
+ check = '●' if field.value else '○'
1775
+ # Color if differs from default (False is default for checkboxes)
1776
+ is_modified = field.value != False
1777
+ value_str = f"{value_color if is_modified else FG_WHITE}{check}{RESET}"
1778
+ elif field.field_type == 'text':
1779
+ if field.value:
1780
+ # Has value - color only if different from default (normalize quotes and whitespace)
1781
+ field_value_normalized = str(field.value).strip().strip("'\"").strip()
1782
+ default_normalized = str(default).strip().strip("'\"").strip()
1783
+ is_modified = field_value_normalized != default_normalized
1784
+ color = value_color if is_modified else FG_WHITE
1785
+ value_str = f"{color}{field.value}{RESET}"
1786
+ else:
1787
+ # Empty - check what runtime will actually use
1788
+ field_value_normalized = str(field.value).strip().strip("'\"").strip()
1789
+ default_normalized = str(default).strip().strip("'\"").strip()
1790
+ # Runtime uses empty if field doesn't auto-revert to default
1791
+ # For HCOM_CLAUDE_ARGS and Prompt, empty stays empty (doesn't use default)
1792
+ runtime_reverts_to_default = field.key not in ('HCOM_CLAUDE_ARGS', 'prompt')
1793
+
1794
+ if runtime_reverts_to_default:
1795
+ # Empty → runtime uses default → NOT modified
1796
+ value_str = f"{FG_WHITE}(default: {default}){RESET}" if default else f"{FG_WHITE}(empty){RESET}"
1797
+ else:
1798
+ # Empty → runtime uses "" → IS modified if default is non-empty
1799
+ is_modified = bool(default_normalized) # Modified if default exists
1800
+ if is_modified:
1801
+ # Colored with default hint (no RESET between to preserve background when selected)
1802
+ value_str = f"{value_color}(empty) {FG_GRAY}default: {default}{RESET}"
1803
+ else:
1804
+ # Empty and no default
1805
+ value_str = f"{FG_WHITE}(empty){RESET}"
1806
+ else: # cycle, numeric
1807
+ if field.value:
1808
+ # Has value - color only if different from default (normalize quotes)
1809
+ field_value_normalized = str(field.value).strip().strip("'\"")
1810
+ default_normalized = default.strip().strip("'\"")
1811
+ is_modified = field_value_normalized != default_normalized
1812
+ color = value_color if is_modified else FG_WHITE
1813
+ value_str = f"{color}{field.value}{RESET}"
1814
+ else:
1815
+ # Empty - check what runtime will actually use
1816
+ if field.field_type == 'numeric':
1817
+ # Timeout fields: empty → runtime uses default → NOT modified
1818
+ value_str = f"{FG_WHITE}(default: {default}){RESET}" if default else f"{FG_WHITE}(empty){RESET}"
1819
+ else:
1820
+ # Cycle fields: empty → runtime uses default → NOT modified
1821
+ value_str = f"{FG_WHITE}(default: {default}){RESET}" if default else f"{FG_WHITE}(empty){RESET}"
1822
+
1823
+ if field.hint and selected:
1824
+ value_str += f"{BG_CHARCOAL} {FG_GRAY}• {field.hint}{RESET}"
1825
+
1826
+ # Build line
1827
+ if selected:
1828
+ arrow_color = FG_RED if has_error else FG_WHITE
1829
+ line = f"{indent}{BG_CHARCOAL}{arrow_color}{BOLD}▸ {field.display_name}:{RESET}{BG_CHARCOAL} {value_str}"
1830
+ return bg_ljust(truncate_ansi(line, width), width, BG_CHARCOAL)
1831
+ else:
1832
+ return truncate_ansi(f"{indent}{FG_WHITE}{field.display_name}:{RESET} {value_str}", width)
1833
+
1834
+ def build_launch_screen(self, height: int, width: int) -> List[str]:
1835
+ """Build launch screen with expandable sections"""
1836
+ # Calculate editor space upfront (reserves bottom of screen)
1837
+ field_info = self.get_current_launch_field_info()
1838
+
1839
+ # Calculate dynamic editor rows (like manage screen)
1840
+ if field_info:
1841
+ field_key, field_value, cursor_pos = field_info
1842
+ editor_content_rows = calculate_text_input_rows(field_value, width)
1843
+ editor_rows = editor_content_rows + 4 # +4 for separator, header, blank line, separator
1844
+ separator_rows = 0 # Editor includes separator
1845
+ else:
1846
+ editor_rows = 0
1847
+ editor_content_rows = 0
1848
+ separator_rows = 1 # Need separator when no editor
1849
+
1850
+ form_height = height - editor_rows - separator_rows
1851
+
1852
+ lines = []
1853
+ selected_field_start_line = None # Track which line has the selected field
1854
+
1855
+ lines.append('') # Top padding
1856
+
1857
+ # Count field (with left padding)
1858
+ count_selected = (self.launch_field == LaunchField.COUNT)
1859
+ if count_selected:
1860
+ selected_field_start_line = len(lines)
1861
+ line = f" {BG_CHARCOAL}{FG_WHITE}{BOLD}\u25b8 Count:{RESET}{BG_CHARCOAL} {FG_ORANGE}{self.launch_count}{RESET}{BG_CHARCOAL} {FG_GRAY}\u2022 \u2190\u2192 adjust{RESET}"
1862
+ lines.append(bg_ljust(line, width, BG_CHARCOAL))
1863
+ else:
1864
+ lines.append(f" {FG_WHITE}Count:{RESET} {FG_ORANGE}{self.launch_count}{RESET}")
1865
+
1866
+ # Launch button (with left padding)
1867
+ launch_selected = (self.launch_field == LaunchField.LAUNCH_BTN)
1868
+ if launch_selected:
1869
+ selected_field_start_line = len(lines)
1870
+ lines.append(f" {BG_ORANGE}{FG_BLACK}{BOLD} \u25b6 Launch \u23ce {RESET}")
1871
+ # Show cwd when launch button is selected
1872
+ import os
1873
+ cwd = os.getcwd()
1874
+ max_cwd_width = width - 10 # Leave margin
1875
+ if len(cwd) > max_cwd_width:
1876
+ cwd = '\u2026' + cwd[-(max_cwd_width - 1):]
1877
+ lines.append(f" {BG_CHARCOAL}{FG_GRAY} \u2022 {FG_WHITE}{cwd} {RESET}")
1878
+ else:
1879
+ lines.append(f" {FG_GRAY}\u25b6{RESET} {FG_ORANGE}{BOLD}Launch{RESET}")
1880
+
1881
+ lines.append('') # Spacer
1882
+ lines.append(f"{DIM}{FG_GRAY}{BOX_H * width}{RESET}") # Separator (dim)
1883
+ lines.append('') # Spacer
1884
+
1885
+ # Claude section header (with left padding)
1886
+ claude_selected = (self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor == -1)
1887
+ expand_marker = '\u25bc' if self.claude_expanded else '\u25b6'
1888
+ claude_fields = self.build_claude_fields()
1889
+ # Count fields modified from defaults by comparing with parsed default spec
1890
+ claude_set = 0
1891
+
1892
+ # Parse default HCOM_CLAUDE_ARGS to get default prompt/system/background
1893
+ claude_args_default = CONFIG_DEFAULTS.get('HCOM_CLAUDE_ARGS', '')
1894
+ default_spec = resolve_claude_args(None, claude_args_default if claude_args_default else None)
1895
+ default_prompt = default_spec.positional_tokens[0] if default_spec.positional_tokens else ""
1896
+ default_system = default_spec.user_system or default_spec.user_append or ""
1897
+ default_background = default_spec.is_background
1898
+
1899
+ if self.launch_background != default_background:
1900
+ claude_set += 1
1901
+ if self.launch_prompt != default_prompt:
1902
+ claude_set += 1
1903
+ if self.launch_system_prompt != default_system:
1904
+ claude_set += 1
1905
+ # claude_args: check if raw value differs from default (normalize quotes)
1906
+ claude_args_val = self.config_edit.get('HCOM_CLAUDE_ARGS', '').strip().strip("'\"")
1907
+ claude_args_default_normalized = claude_args_default.strip().strip("'\"")
1908
+ if claude_args_val != claude_args_default_normalized:
1909
+ claude_set += 1
1910
+ claude_total = len(claude_fields)
1911
+ claude_count = f" \u2022 {claude_set}/{claude_total}"
1912
+ if claude_selected:
1913
+ selected_field_start_line = len(lines)
1914
+ claude_action = "\u2190 collapse" if self.claude_expanded else "\u2192 expand"
1915
+ claude_hint = f"{claude_count} \u2022 {claude_action}"
1916
+ line = f" {BG_CHARCOAL}{FG_CLAUDE_ORANGE}{BOLD}{expand_marker} Claude{RESET}{BG_CHARCOAL} {FG_GRAY}{claude_hint}{RESET}"
1917
+ lines.append(bg_ljust(line, width, BG_CHARCOAL))
1918
+ else:
1919
+ lines.append(f" {FG_CLAUDE_ORANGE}{BOLD}{expand_marker} Claude{RESET}{FG_GRAY}{claude_count}{RESET}")
1920
+
1921
+ # Preview modified fields when collapsed, or show description if none
1922
+ if not self.claude_expanded:
1923
+ if claude_set > 0:
1924
+ previews = []
1925
+ if self.launch_background != default_background:
1926
+ previews.append("background: true" if self.launch_background else "background: false")
1927
+ if self.launch_prompt != default_prompt:
1928
+ prompt_str = str(self.launch_prompt) if self.launch_prompt else ""
1929
+ prompt_preview = prompt_str[:20] + "..." if len(prompt_str) > 20 else prompt_str
1930
+ previews.append(f'prompt: "{prompt_preview}"')
1931
+ if self.launch_system_prompt != default_system:
1932
+ sys_str = str(self.launch_system_prompt) if self.launch_system_prompt else ""
1933
+ sys_preview = sys_str[:20] + "..." if len(sys_str) > 20 else sys_str
1934
+ previews.append(f'system: "{sys_preview}"')
1935
+ if claude_args_val != claude_args_default_normalized:
1936
+ args_str = str(claude_args_val) if claude_args_val else ""
1937
+ args_preview = args_str[:25] + "..." if len(args_str) > 25 else args_str
1938
+ previews.append(f'args: "{args_preview}"')
1939
+ if previews:
1940
+ preview_text = ", ".join(previews)
1941
+ lines.append(f" {DIM}{FG_GRAY}{truncate_ansi(preview_text, width - 4)}{RESET}")
1942
+ else:
1943
+ lines.append(f" {DIM}{FG_GRAY}prompt, system, headless, args{RESET}")
1944
+
1945
+ # Claude fields (if expanded or cursor inside)
1946
+ result = self.render_section_fields(
1947
+ lines, claude_fields, self.claude_expanded,
1948
+ LaunchField.CLAUDE_SECTION, self.claude_cursor, width, FG_CLAUDE_ORANGE
1949
+ )
1950
+ if result is not None:
1951
+ selected_field_start_line = result
1952
+
1953
+ # HCOM section header (with left padding)
1954
+ hcom_selected = (self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor == -1)
1955
+ expand_marker = '\u25bc' if self.hcom_expanded else '\u25b6'
1956
+ hcom_fields = self.build_hcom_fields()
1957
+ # Count fields modified from defaults (considering runtime behavior)
1958
+ def is_field_modified(f):
1959
+ default = CONFIG_DEFAULTS.get(f.key, '')
1960
+ if not f.value: # Empty
1961
+ # Fields where empty reverts to default at runtime
1962
+ if f.key in ('HCOM_TERMINAL', 'HCOM_HINTS', 'HCOM_TAG', 'HCOM_AGENT', 'HCOM_TIMEOUT', 'HCOM_SUBAGENT_TIMEOUT'):
1963
+ return False # Empty → uses default → NOT modified
1964
+ # Fields where empty stays empty (different from default if default is non-empty)
1965
+ # HCOM_CLAUDE_ARGS: empty → "" (not default "'say hi...'") → IS modified
1966
+ return bool(default.strip().strip("'\"")) # Modified if default is non-empty
1967
+ # Has value - check if different from default
1968
+ return f.value.strip().strip("'\"") != default.strip().strip("'\"")
1969
+ hcom_set = sum(1 for f in hcom_fields if is_field_modified(f))
1970
+ hcom_total = len(hcom_fields)
1971
+ hcom_count = f" \u2022 {hcom_set}/{hcom_total}"
1972
+ if hcom_selected:
1973
+ selected_field_start_line = len(lines)
1974
+ hcom_action = "\u2190 collapse" if self.hcom_expanded else "\u2192 expand"
1975
+ hcom_hint = f"{hcom_count} \u2022 {hcom_action}"
1976
+ line = f" {BG_CHARCOAL}{FG_CYAN}{BOLD}{expand_marker} HCOM{RESET}{BG_CHARCOAL} {FG_GRAY}{hcom_hint}{RESET}"
1977
+ lines.append(bg_ljust(line, width, BG_CHARCOAL))
1978
+ else:
1979
+ lines.append(f" {FG_CYAN}{BOLD}{expand_marker} HCOM{RESET}{FG_GRAY}{hcom_count}{RESET}")
1980
+
1981
+ # Preview modified fields when collapsed, or show description if none
1982
+ if not self.hcom_expanded:
1983
+ if hcom_set > 0:
1984
+ previews = []
1985
+ for field in hcom_fields:
1986
+ if is_field_modified(field):
1987
+ val = field.value or ""
1988
+ if hasattr(field, 'type') and field.type == 'bool':
1989
+ val_str = "true" if val == "true" else "false"
1990
+ else:
1991
+ val = str(val) if val else ""
1992
+ val_str = val[:15] + "..." if len(val) > 15 else val
1993
+ # Shorten field names
1994
+ short_name = field.display_name.lower().replace("hcom ", "")
1995
+ previews.append(f'{short_name}: {val_str}')
1996
+ if previews:
1997
+ preview_text = ", ".join(previews)
1998
+ lines.append(f" {DIM}{FG_GRAY}{truncate_ansi(preview_text, width - 4)}{RESET}")
1999
+ else:
2000
+ lines.append(f" {DIM}{FG_GRAY}agent, tag, hints, timeout, terminal{RESET}")
2001
+
2002
+ # HCOM fields
2003
+ result = self.render_section_fields(
2004
+ lines, hcom_fields, self.hcom_expanded,
2005
+ LaunchField.HCOM_SECTION, self.hcom_cursor, width, FG_CYAN
2006
+ )
2007
+ if result is not None:
2008
+ selected_field_start_line = result
2009
+
2010
+ # Custom Env section header (with left padding)
2011
+ custom_selected = (self.launch_field == LaunchField.CUSTOM_ENV_SECTION and self.custom_env_cursor == -1)
2012
+ expand_marker = '\u25bc' if self.custom_env_expanded else '\u25b6'
2013
+ custom_fields = self.build_custom_env_fields()
2014
+ custom_set = sum(1 for f in custom_fields if f.value)
2015
+ custom_total = len(custom_fields)
2016
+ custom_count = f" \u2022 {custom_set}/{custom_total}"
2017
+ if custom_selected:
2018
+ selected_field_start_line = len(lines)
2019
+ custom_action = "\u2190 collapse" if self.custom_env_expanded else "\u2192 expand"
2020
+ custom_hint = f"{custom_count} \u2022 {custom_action}"
2021
+ line = f" {BG_CHARCOAL}{FG_CUSTOM_ENV}{BOLD}{expand_marker} Custom Env{RESET}{BG_CHARCOAL} {FG_GRAY}{custom_hint}{RESET}"
2022
+ lines.append(bg_ljust(line, width, BG_CHARCOAL))
2023
+ else:
2024
+ lines.append(f" {FG_CUSTOM_ENV}{BOLD}{expand_marker} Custom Env{RESET}{FG_GRAY}{custom_count}{RESET}")
2025
+
2026
+ # Preview modified fields when collapsed, or show description if none
2027
+ if not self.custom_env_expanded:
2028
+ if custom_set > 0:
2029
+ previews = []
2030
+ for field in custom_fields:
2031
+ if field.value:
2032
+ val = str(field.value) if field.value else ""
2033
+ val_str = val[:15] + "..." if len(val) > 15 else val
2034
+ previews.append(f'{field.key}: {val_str}')
2035
+ if previews:
2036
+ preview_text = ", ".join(previews)
2037
+ lines.append(f" {DIM}{FG_GRAY}{truncate_ansi(preview_text, width - 4)}{RESET}")
2038
+ else:
2039
+ lines.append(f" {DIM}{FG_GRAY}arbitrary environment variables{RESET}")
2040
+
2041
+ # Custom Env fields
2042
+ result = self.render_section_fields(
2043
+ lines, custom_fields, self.custom_env_expanded,
2044
+ LaunchField.CUSTOM_ENV_SECTION, self.custom_env_cursor, width, FG_CUSTOM_ENV
2045
+ )
2046
+ if result is not None:
2047
+ selected_field_start_line = result
2048
+
2049
+ # Open config in editor entry (at bottom, less prominent)
2050
+ lines.append('') # Spacer
2051
+ editor_cmd, editor_label = self.resolve_editor_command()
2052
+ editor_label_display = editor_label or 'VS Code'
2053
+ editor_available = editor_cmd is not None
2054
+ editor_selected = (self.launch_field == LaunchField.OPEN_EDITOR)
2055
+
2056
+ if editor_selected:
2057
+ selected_field_start_line = len(lines)
2058
+ lines.append(
2059
+ bg_ljust(
2060
+ f" {BG_CHARCOAL}{FG_WHITE}\u2197 Open config in {editor_label_display}{RESET}"
2061
+ f"{BG_CHARCOAL} "
2062
+ f"{(FG_GRAY if editor_available else FG_RED)}\u2022 "
2063
+ f"{'enter: open' if editor_available else 'code CLI not found / set $EDITOR'}{RESET}",
2064
+ width,
2065
+ BG_CHARCOAL,
2066
+ )
2067
+ )
2068
+ else:
2069
+ # Less prominent when not selected
2070
+ if editor_available:
2071
+ lines.append(f" {FG_GRAY}\u2197 Open config in {editor_label_display}{RESET}")
2072
+ else:
2073
+ lines.append(f" {FG_GRAY}\u2197 Open config in {editor_label_display} {FG_RED}(not found){RESET}")
2074
+
2075
+ # Auto-scroll to keep selected field visible
2076
+ if selected_field_start_line is not None:
2077
+ max_scroll = max(0, len(lines) - form_height)
2078
+
2079
+ # Scroll up if selected field is above visible window
2080
+ if selected_field_start_line < self.launch_scroll_pos:
2081
+ self.launch_scroll_pos = selected_field_start_line
2082
+ # Scroll down if selected field is below visible window
2083
+ elif selected_field_start_line >= self.launch_scroll_pos + form_height:
2084
+ self.launch_scroll_pos = selected_field_start_line - form_height + 1
2085
+
2086
+ # Clamp scroll position
2087
+ self.launch_scroll_pos = max(0, min(self.launch_scroll_pos, max_scroll))
2088
+
2089
+ # Render visible window instead of truncating
2090
+ if len(lines) > form_height:
2091
+ # Extract visible slice based on scroll position
2092
+ visible_lines = lines[self.launch_scroll_pos:self.launch_scroll_pos + form_height]
2093
+ # Pad if needed (shouldn't happen, but for safety)
2094
+ while len(visible_lines) < form_height:
2095
+ visible_lines.append('')
2096
+ lines = visible_lines
2097
+ else:
2098
+ # Form fits entirely, no scrolling needed
2099
+ while len(lines) < form_height:
2100
+ lines.append('')
2101
+
2102
+ # Editor (if active) - always fits because we reserved space
2103
+ if field_info:
2104
+ field_key, field_value, cursor_pos = field_info
2105
+
2106
+ # Build descriptive header for each field with background
2107
+ if field_key == 'prompt':
2108
+ editor_color = FG_CLAUDE_ORANGE
2109
+ field_name = "Prompt"
2110
+ help_text = "initial prompt sent on launch"
2111
+ elif field_key == 'system_prompt':
2112
+ editor_color = FG_CLAUDE_ORANGE
2113
+ field_name = "System Prompt"
2114
+ help_text = "instructions that guide behavior"
2115
+ elif field_key == 'HCOM_CLAUDE_ARGS':
2116
+ editor_color = FG_CLAUDE_ORANGE
2117
+ field_name = "Claude Args"
2118
+ help_text = "raw flags passed to Claude CLI"
2119
+ elif field_key == 'HCOM_TIMEOUT':
2120
+ editor_color = FG_CYAN
2121
+ field_name = "Timeout"
2122
+ help_text = "seconds before disconnecting idle instance"
2123
+ elif field_key == 'HCOM_SUBAGENT_TIMEOUT':
2124
+ editor_color = FG_CYAN
2125
+ field_name = "Subagent Timeout"
2126
+ help_text = "seconds before disconnecting idle subagent"
2127
+ elif field_key == 'HCOM_TERMINAL':
2128
+ editor_color = FG_CYAN
2129
+ field_name = "Terminal"
2130
+ help_text = "launch in new window, current window, or custom terminal"
2131
+ elif field_key == 'HCOM_HINTS':
2132
+ editor_color = FG_CYAN
2133
+ field_name = "Hints"
2134
+ help_text = "text appended to all messages this instance receives"
2135
+ elif field_key == 'HCOM_TAG':
2136
+ editor_color = FG_CYAN
2137
+ field_name = "Tag"
2138
+ help_text = "identifier to create groups with @-mention"
2139
+ elif field_key == 'HCOM_AGENT':
2140
+ editor_color = FG_CYAN
2141
+ field_name = "Agent"
2142
+ help_text = "agent from .claude/agents • comma-separated for multiple"
2143
+ elif field_key.startswith('HCOM_'):
2144
+ # Other HCOM fields
2145
+ editor_color = FG_CYAN
2146
+ field_name = field_key.replace('HCOM_', '').replace('_', ' ').title()
2147
+ help_text = "HCOM configuration variable"
2148
+ else:
2149
+ # Custom env vars
2150
+ editor_color = FG_CUSTOM_ENV
2151
+ field_name = field_key
2152
+ help_text = "custom environment variable"
2153
+
2154
+ # Header line - bold field name, regular help text
2155
+ header = f"{editor_color}{BOLD}{field_name}:{RESET} {FG_GRAY}{help_text}{RESET}"
2156
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
2157
+ lines.append(header)
2158
+ lines.append('') # Blank line between header and input
2159
+ # Render editor with wrapping support
2160
+ editor_lines = render_text_input(field_value, cursor_pos, width, editor_content_rows, prefix="")
2161
+ lines.extend(editor_lines)
2162
+ # Separator after editor input
2163
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
2164
+ else:
2165
+ # Separator at bottom when no editor
2166
+ lines.append(f"{FG_GRAY}{'─' * width}{RESET}")
2167
+
2168
+ return lines[:height]
2169
+
2170
+ def get_launch_footer(self) -> str:
2171
+ """Return context-sensitive footer for Launch screen"""
2172
+ # Count field
2173
+ if self.launch_field == LaunchField.COUNT:
2174
+ return f"{FG_GRAY}tab: switch ←→: adjust esc: reset to 1 ctrl+r: reset config{RESET}"
2175
+
2176
+ # Launch button
2177
+ elif self.launch_field == LaunchField.LAUNCH_BTN:
2178
+ return f"{FG_GRAY}tab: switch enter: launch ctrl+r: reset config{RESET}"
2179
+ elif self.launch_field == LaunchField.OPEN_EDITOR:
2180
+ cmd, label = self.resolve_editor_command()
2181
+ if cmd:
2182
+ friendly = label or 'VS Code'
2183
+ return f"{FG_GRAY}tab: switch enter: open {friendly}{RESET}"
2184
+ return f"{FG_GRAY}tab: switch enter: install code CLI or set $EDITOR{RESET}"
2185
+
2186
+ # Section headers (cursor == -1)
2187
+ elif self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor == -1:
2188
+ return f"{FG_GRAY}tab: switch enter: expand/collapse ctrl+r: reset config{RESET}"
2189
+ elif self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor == -1:
2190
+ return f"{FG_GRAY}tab: switch enter: expand/collapse ctrl+r: reset config{RESET}"
2191
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION and self.custom_env_cursor == -1:
2192
+ return f"{FG_GRAY}tab: switch enter: expand/collapse ctrl+r: reset config{RESET}"
2193
+
2194
+ # Fields within sections (cursor >= 0)
2195
+ elif self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor >= 0:
2196
+ fields = self.build_claude_fields()
2197
+ if self.claude_cursor < len(fields):
2198
+ field = fields[self.claude_cursor]
2199
+ if field.field_type == 'checkbox':
2200
+ return f"{FG_GRAY}tab: switch enter: toggle ctrl+r: reset config{RESET}"
2201
+ else: # text fields
2202
+ return f"{FG_GRAY}tab: switch type: edit ←→: cursor esc: clear ctrl+r: reset config{RESET}"
2203
+
2204
+ elif self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor >= 0:
2205
+ fields = self.build_hcom_fields()
2206
+ if self.hcom_cursor < len(fields):
2207
+ field = fields[self.hcom_cursor]
2208
+ if field.field_type == 'cycle':
2209
+ return f"{FG_GRAY}tab: switch ←→: cycle options esc: clear ctrl+r: reset config{RESET}"
2210
+ elif field.field_type == 'numeric':
2211
+ return f"{FG_GRAY}tab: switch type: digits ←→: cursor esc: clear ctrl+r: reset config{RESET}"
2212
+ else: # text fields
2213
+ return f"{FG_GRAY}tab: switch type: edit ←→: cursor esc: clear ctrl+r: reset config{RESET}"
2214
+
2215
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION and self.custom_env_cursor >= 0:
2216
+ return f"{FG_GRAY}tab: switch type: edit ←→: cursor esc: clear ctrl+r: reset config{RESET}"
2217
+
2218
+ # Fallback (should not happen)
2219
+ return f"{FG_GRAY}tab: switch ctrl+r: reset config{RESET}"
2220
+
2221
+ def render(self):
2222
+ """Render current screen"""
2223
+ cols, rows = get_terminal_size()
2224
+ # Adapt to any terminal size
2225
+ rows = max(10, rows)
2226
+
2227
+ frame = []
2228
+
2229
+ # Header (compact - no separator)
2230
+ header = self.build_status_bar()
2231
+ frame.append(ansi_ljust(header, cols))
2232
+
2233
+ # Flash row with separator line
2234
+ flash = self.build_flash()
2235
+ if flash:
2236
+ # Flash message on left, separator line fills rest of row
2237
+ flash_len = ansi_len(flash)
2238
+ remaining = cols - flash_len - 1 # -1 for space
2239
+ separator = f"{FG_GRAY}{'─' * remaining}{RESET}" if remaining > 0 else ""
2240
+ frame.append(f"{flash} {separator}")
2241
+ else:
2242
+ # Just separator line when no flash message
2243
+ frame.append(f"{FG_GRAY}{'─' * cols}{RESET}")
2244
+
2245
+ # Welcome message on first render
2246
+ if self.first_render:
2247
+ self.flash("Welcome! Tab to switch screens")
2248
+ self.first_render = False
2249
+
2250
+ # Body (subtract 3: header, flash, footer)
2251
+ body_rows = rows - 3
2252
+
2253
+ if self.mode == Mode.MANAGE:
2254
+ manage_lines = self.build_manage_screen(body_rows, cols)
2255
+ for line in manage_lines:
2256
+ frame.append(ansi_ljust(line, cols))
2257
+ elif self.mode == Mode.LAUNCH:
2258
+ form_lines = self.build_launch_screen(body_rows, cols)
2259
+ for line in form_lines:
2260
+ frame.append(ansi_ljust(line, cols))
2261
+
2262
+ # Footer - compact help text
2263
+ if self.mode == Mode.MANAGE:
2264
+ # Contextual footer based on state
2265
+ if self.message_buffer.strip():
2266
+ footer = f"{FG_GRAY}tab: switch @: mention enter: send esc: clear{RESET}"
2267
+ elif self.pending_stop_all:
2268
+ footer = f"{FG_GRAY}ctrl+a: confirm stop all esc: cancel{RESET}"
2269
+ elif self.pending_reset:
2270
+ footer = f"{FG_GRAY}ctrl+r: confirm reset esc: cancel{RESET}"
2271
+ elif self.pending_toggle:
2272
+ footer = f"{FG_GRAY}enter: confirm esc: cancel{RESET}"
2273
+ else:
2274
+ footer = f"{FG_GRAY}tab: switch @: mention enter: toggle ctrl+a: stop all ctrl+r: reset{RESET}"
2275
+ elif self.mode == Mode.LAUNCH:
2276
+ footer = self.get_launch_footer()
2277
+ frame.append(truncate_ansi(footer, cols))
2278
+
2279
+ # Repaint if changed
2280
+ if frame != self.last_frame:
2281
+ sys.stdout.write(CLEAR_SCREEN + CURSOR_HOME)
2282
+ for i, line in enumerate(frame):
2283
+ sys.stdout.write(line)
2284
+ if i < len(frame) - 1:
2285
+ sys.stdout.write('\n')
2286
+ sys.stdout.flush()
2287
+ self.last_frame = frame
2288
+
2289
+ def handle_tab(self):
2290
+ """Cycle between Manage, Launch, and native Log view"""
2291
+ if self.mode == Mode.MANAGE:
2292
+ self.mode = Mode.LAUNCH
2293
+ self.flash("Launch Instances")
2294
+ elif self.mode == Mode.LAUNCH:
2295
+ # Go directly to native log view instead of LOG mode
2296
+ self.flash("Message History")
2297
+ self.show_log_native()
2298
+ # After returning from native view, go to MANAGE
2299
+ self.mode = Mode.MANAGE
2300
+ self.flash("Manage Instances")
2301
+
2302
+ def handle_manage_key(self, key: str):
2303
+ """Handle keys in Manage mode"""
2304
+ # Sort by creation time (same as display) - stable, no jumping
2305
+ sorted_instances = sorted(
2306
+ self.instances.items(),
2307
+ key=lambda x: -x[1]['data'].get('created_at', 0.0)
2308
+ )
2309
+
2310
+ if key == 'UP':
2311
+ if sorted_instances and self.cursor > 0:
2312
+ self.cursor -= 1
2313
+ # Update tracked instance name
2314
+ if self.cursor < len(sorted_instances):
2315
+ self.cursor_instance_name = sorted_instances[self.cursor][0]
2316
+ self.clear_all_pending_confirmations()
2317
+ self.sync_scroll_to_cursor()
2318
+ elif key == 'DOWN':
2319
+ if sorted_instances and self.cursor < len(sorted_instances) - 1:
2320
+ self.cursor += 1
2321
+ # Update tracked instance name
2322
+ if self.cursor < len(sorted_instances):
2323
+ self.cursor_instance_name = sorted_instances[self.cursor][0]
2324
+ self.clear_all_pending_confirmations()
2325
+ self.sync_scroll_to_cursor()
2326
+ elif key == '@':
2327
+ self.clear_all_pending_confirmations()
2328
+ # Add @mention of highlighted instance at cursor position
2329
+ if sorted_instances and self.cursor < len(sorted_instances):
2330
+ name, _ = sorted_instances[self.cursor]
2331
+ mention = f"@{name} "
2332
+ if mention not in self.message_buffer:
2333
+ self.message_buffer, self.message_cursor_pos = text_input_insert(
2334
+ self.message_buffer, self.message_cursor_pos, mention
2335
+ )
2336
+ elif key == 'SPACE':
2337
+ self.clear_all_pending_confirmations()
2338
+ # Add space to message buffer at cursor position
2339
+ self.message_buffer, self.message_cursor_pos = text_input_insert(
2340
+ self.message_buffer, self.message_cursor_pos, ' '
2341
+ )
2342
+ elif key == 'LEFT':
2343
+ self.clear_all_pending_confirmations()
2344
+ # Move cursor left in message buffer
2345
+ self.message_cursor_pos = text_input_move_left(self.message_cursor_pos)
2346
+ elif key == 'RIGHT':
2347
+ self.clear_all_pending_confirmations()
2348
+ # Move cursor right in message buffer
2349
+ self.message_cursor_pos = text_input_move_right(self.message_buffer, self.message_cursor_pos)
2350
+ elif key == 'ESC':
2351
+ # Clear message buffer first, then cancel all pending confirmations
2352
+ if self.message_buffer:
2353
+ self.message_buffer = ""
2354
+ self.message_cursor_pos = 0
2355
+ else:
2356
+ self.clear_all_pending_confirmations()
2357
+ elif key == 'BACKSPACE':
2358
+ self.clear_all_pending_confirmations()
2359
+ # Delete character before cursor in message buffer
2360
+ self.message_buffer, self.message_cursor_pos = text_input_backspace(
2361
+ self.message_buffer, self.message_cursor_pos
2362
+ )
2363
+ elif key == 'ENTER':
2364
+ # Clear stop all and reset confirmations (toggle handled separately below)
2365
+ self.clear_pending_confirmations_except('toggle')
2366
+
2367
+ # Smart Enter: send message if text exists, otherwise toggle instances
2368
+ if self.message_buffer.strip():
2369
+ # Send message using cmd_send for consistent validation and error handling
2370
+ try:
2371
+ message = self.message_buffer.strip()
2372
+ result = cmd_send([message])
2373
+ if result == 0:
2374
+ self.flash("Sent")
2375
+ # Clear message buffer and cursor
2376
+ self.message_buffer = ""
2377
+ self.message_cursor_pos = 0
2378
+ else:
2379
+ self.flash_error("Send failed")
2380
+ except Exception as e:
2381
+ self.flash_error(f"Error: {str(e)}")
2382
+ else:
2383
+ # No message text - toggle instance with two-step confirmation
2384
+ if not sorted_instances or self.cursor >= len(sorted_instances):
2385
+ return
2386
+
2387
+ name, info = sorted_instances[self.cursor]
2388
+ enabled = info['data'].get('enabled', False)
2389
+ action = "start" if not enabled else "stop"
2390
+
2391
+ # Get status color for name
2392
+ status = info.get('status', "unknown")
2393
+ color = STATUS_FG.get(status, FG_WHITE)
2394
+
2395
+ # Check if confirming previous toggle
2396
+ if self.pending_toggle == name and (time.time() - self.pending_toggle_time) <= self.CONFIRMATION_TIMEOUT:
2397
+ # Execute toggle (confirmation received)
2398
+ try:
2399
+ if enabled:
2400
+ cmd_stop([name])
2401
+ self.flash(f"Stopped hcom for {color}{name}{RESET}")
2402
+ self.completed_toggle = name
2403
+ self.completed_toggle_time = time.time()
2404
+ else:
2405
+ cmd_start([name])
2406
+ self.flash(f"Started hcom for {color}{name}{RESET}")
2407
+ self.completed_toggle = name
2408
+ self.completed_toggle_time = time.time()
2409
+ self.load_status()
2410
+ except Exception as e:
2411
+ self.flash_error(f"Error: {str(e)}")
2412
+ finally:
2413
+ self.pending_toggle = None
2414
+ else:
2415
+ # Show confirmation (first press) - 10s duration
2416
+ self.pending_toggle = name
2417
+ self.pending_toggle_time = time.time()
2418
+ # Name with status color, action is plain text (no color clash)
2419
+ name_colored = f"{color}{name}{FG_WHITE}"
2420
+ self.flash(f"Confirm {action} {name_colored}? (press Enter again)", duration=self.CONFIRMATION_FLASH_DURATION, color='white')
2421
+
2422
+ elif key == 'CTRL_A':
2423
+ # Check state before clearing
2424
+ is_confirming = self.pending_stop_all and (time.time() - self.pending_stop_all_time) <= self.CONFIRMATION_TIMEOUT
2425
+ self.clear_pending_confirmations_except('stop_all')
2426
+
2427
+ # Two-step confirmation for stop all
2428
+ if is_confirming:
2429
+ # Execute stop all (confirmation received)
2430
+ self.stop_all_instances()
2431
+ self.pending_stop_all = False
2432
+ else:
2433
+ # Show confirmation (first press) - 10s duration
2434
+ self.pending_stop_all = True
2435
+ self.pending_stop_all_time = time.time()
2436
+ self.flash(f"{FG_WHITE}Confirm stop all instances? (press Ctrl+A again){RESET}", duration=self.CONFIRMATION_FLASH_DURATION, color='white')
2437
+
2438
+ elif key == 'CTRL_R':
2439
+ # Check state before clearing
2440
+ is_confirming = self.pending_reset and (time.time() - self.pending_reset_time) <= self.CONFIRMATION_TIMEOUT
2441
+ self.clear_pending_confirmations_except('reset')
2442
+
2443
+ # Two-step confirmation for reset
2444
+ if is_confirming:
2445
+ # Execute reset (confirmation received)
2446
+ self.reset_logs()
2447
+ self.pending_reset = False
2448
+ else:
2449
+ # Show confirmation (first press)
2450
+ self.pending_reset = True
2451
+ self.pending_reset_time = time.time()
2452
+ self.flash(f"{FG_WHITE}Confirm clear & archive (log + instance list)? (press Ctrl+R again){RESET}", duration=self.CONFIRMATION_FLASH_DURATION, color='white')
2453
+
2454
+ elif key == '\n':
2455
+ # Handle pasted newlines - insert literally
2456
+ self.clear_all_pending_confirmations()
2457
+ self.message_buffer, self.message_cursor_pos = text_input_insert(
2458
+ self.message_buffer, self.message_cursor_pos, '\n'
2459
+ )
2460
+
2461
+ elif key and len(key) == 1 and key.isprintable():
2462
+ self.clear_all_pending_confirmations()
2463
+ # Insert printable characters at cursor position
2464
+ self.message_buffer, self.message_cursor_pos = text_input_insert(
2465
+ self.message_buffer, self.message_cursor_pos, key
2466
+ )
2467
+
2468
+ def handle_launch_key(self, key: str):
2469
+ """Handle keys in Launch mode - with cursor-based bottom bar editing"""
2470
+
2471
+ # UP/DOWN navigation (unchanged)
2472
+ if key == 'UP':
2473
+ if self.launch_field == LaunchField.CLAUDE_SECTION:
2474
+ if self.claude_cursor > -1:
2475
+ self.claude_cursor -= 1
2476
+ else:
2477
+ self.launch_field = LaunchField.LAUNCH_BTN
2478
+ elif self.launch_field == LaunchField.HCOM_SECTION:
2479
+ if self.hcom_cursor > -1:
2480
+ self.hcom_cursor -= 1
2481
+ else:
2482
+ self.launch_field = LaunchField.CLAUDE_SECTION
2483
+ self.claude_cursor = -1
2484
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION:
2485
+ if self.custom_env_cursor > -1:
2486
+ self.custom_env_cursor -= 1
2487
+ else:
2488
+ self.launch_field = LaunchField.HCOM_SECTION
2489
+ self.hcom_cursor = -1
2490
+ elif self.launch_field == LaunchField.OPEN_EDITOR:
2491
+ self.launch_field = LaunchField.CUSTOM_ENV_SECTION
2492
+ self.custom_env_cursor = -1
2493
+ else:
2494
+ fields = list(LaunchField)
2495
+ idx = fields.index(self.launch_field)
2496
+ self.launch_field = fields[(idx - 1) % len(fields)]
2497
+
2498
+ elif key == 'DOWN':
2499
+ if self.launch_field == LaunchField.CLAUDE_SECTION:
2500
+ if self.claude_cursor == -1 and not self.claude_expanded:
2501
+ self.launch_field = LaunchField.HCOM_SECTION
2502
+ self.hcom_cursor = -1
2503
+ elif self.claude_expanded:
2504
+ max_idx = len(self.build_claude_fields()) - 1
2505
+ if self.claude_cursor < max_idx:
2506
+ self.claude_cursor += 1
2507
+ else:
2508
+ self.launch_field = LaunchField.HCOM_SECTION
2509
+ self.hcom_cursor = -1
2510
+ elif self.launch_field == LaunchField.HCOM_SECTION:
2511
+ if self.hcom_cursor == -1 and not self.hcom_expanded:
2512
+ self.launch_field = LaunchField.CUSTOM_ENV_SECTION
2513
+ self.custom_env_cursor = -1
2514
+ elif self.hcom_expanded:
2515
+ max_idx = len(self.build_hcom_fields()) - 1
2516
+ if self.hcom_cursor < max_idx:
2517
+ self.hcom_cursor += 1
2518
+ else:
2519
+ self.launch_field = LaunchField.CUSTOM_ENV_SECTION
2520
+ self.custom_env_cursor = -1
2521
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION:
2522
+ if self.custom_env_cursor == -1 and not self.custom_env_expanded:
2523
+ self.launch_field = LaunchField.OPEN_EDITOR
2524
+ elif self.custom_env_expanded:
2525
+ max_idx = len(self.build_custom_env_fields()) - 1
2526
+ if self.custom_env_cursor < max_idx:
2527
+ self.custom_env_cursor += 1
2528
+ else:
2529
+ self.launch_field = LaunchField.OPEN_EDITOR
2530
+ else:
2531
+ fields = list(LaunchField)
2532
+ idx = fields.index(self.launch_field)
2533
+ self.launch_field = fields[(idx + 1) % len(fields)]
2534
+ if self.launch_field == LaunchField.CLAUDE_SECTION:
2535
+ self.claude_cursor = -1
2536
+ elif self.launch_field == LaunchField.HCOM_SECTION:
2537
+ self.hcom_cursor = -1
2538
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION:
2539
+ self.custom_env_cursor = -1
2540
+
2541
+ # LEFT/RIGHT: adjust count, cycle for cycle fields, cursor movement for text fields
2542
+ elif key == 'LEFT' or key == 'RIGHT':
2543
+ # COUNT field: adjust by ±1
2544
+ if self.launch_field == LaunchField.COUNT:
2545
+ try:
2546
+ current = int(self.launch_count) if self.launch_count else 1
2547
+ if key == 'RIGHT':
2548
+ current = min(999, current + 1)
2549
+ else: # LEFT
2550
+ current = max(1, current - 1)
2551
+ self.launch_count = str(current)
2552
+ except ValueError:
2553
+ self.launch_count = "1"
2554
+ else:
2555
+ field_info = self.get_current_launch_field_info()
2556
+ if field_info:
2557
+ field_key, field_value, cursor_pos = field_info
2558
+
2559
+ # Get field object to check type
2560
+ field_obj = None
2561
+ if self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor >= 0:
2562
+ fields = self.build_hcom_fields()
2563
+ if self.hcom_cursor < len(fields):
2564
+ field_obj = fields[self.hcom_cursor]
2565
+
2566
+ # Check if it's a cycle field
2567
+ if field_obj and field_obj.field_type == 'cycle':
2568
+ # Cycle through options
2569
+ options = field_obj.options or []
2570
+ if options:
2571
+ if field_value in options:
2572
+ idx = options.index(field_value)
2573
+ new_idx = (idx + 1) if key == 'RIGHT' else (idx - 1)
2574
+ new_idx = new_idx % len(options)
2575
+ else:
2576
+ new_idx = 0
2577
+ self.config_edit[field_key] = options[new_idx]
2578
+ self.config_field_cursors[field_key] = len(options[new_idx])
2579
+ self.save_config_to_file()
2580
+ else:
2581
+ # Text field: move cursor
2582
+ if key == 'LEFT':
2583
+ new_cursor = text_input_move_left(cursor_pos)
2584
+ else:
2585
+ new_cursor = text_input_move_right(field_value, cursor_pos)
2586
+
2587
+ # Update cursor
2588
+ if field_key == 'prompt':
2589
+ self.launch_prompt_cursor = new_cursor
2590
+ elif field_key == 'system_prompt':
2591
+ self.launch_system_prompt_cursor = new_cursor
2592
+ else:
2593
+ self.config_field_cursors[field_key] = new_cursor
2594
+
2595
+ # ENTER: expand/collapse, toggle, cycle, launch
2596
+ elif key == 'ENTER':
2597
+ if self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor == -1:
2598
+ self.claude_expanded = not self.claude_expanded
2599
+ elif self.launch_field == LaunchField.HCOM_SECTION and self.hcom_cursor == -1:
2600
+ self.hcom_expanded = not self.hcom_expanded
2601
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION and self.custom_env_cursor == -1:
2602
+ self.custom_env_expanded = not self.custom_env_expanded
2603
+ elif self.launch_field == LaunchField.CLAUDE_SECTION and self.claude_cursor >= 0:
2604
+ fields = self.build_claude_fields()
2605
+ if self.claude_cursor < len(fields):
2606
+ field = fields[self.claude_cursor]
2607
+ if field.field_type == 'checkbox' and field.key == 'background':
2608
+ self.launch_background = not self.launch_background
2609
+ self.save_launch_state()
2610
+ elif self.launch_field == LaunchField.LAUNCH_BTN:
2611
+ self.do_launch()
2612
+ elif self.launch_field == LaunchField.OPEN_EDITOR:
2613
+ self.open_config_in_editor()
2614
+
2615
+ # BACKSPACE: delete char before cursor
2616
+ elif key == 'BACKSPACE':
2617
+ field_info = self.get_current_launch_field_info()
2618
+ if field_info:
2619
+ field_key, field_value, cursor_pos = field_info
2620
+ new_value, new_cursor = text_input_backspace(field_value, cursor_pos)
2621
+ self.update_launch_field(field_key, new_value, new_cursor)
2622
+
2623
+ # ESC: clear field
2624
+ elif key == 'ESC':
2625
+ if self.launch_field == LaunchField.CLAUDE_SECTION:
2626
+ if self.claude_cursor >= 0:
2627
+ fields = self.build_claude_fields()
2628
+ if self.claude_cursor < len(fields):
2629
+ field = fields[self.claude_cursor]
2630
+ if field.key == 'prompt':
2631
+ self.launch_prompt = ""
2632
+ self.launch_prompt_cursor = 0
2633
+ self.save_launch_state()
2634
+ elif field.key == 'system_prompt':
2635
+ self.launch_system_prompt = ""
2636
+ self.launch_system_prompt_cursor = 0
2637
+ self.save_launch_state()
2638
+ elif field.key == 'claude_args':
2639
+ self.config_edit['HCOM_CLAUDE_ARGS'] = ""
2640
+ self.config_field_cursors['HCOM_CLAUDE_ARGS'] = 0
2641
+ self.save_config_to_file()
2642
+ self.load_launch_state()
2643
+ else:
2644
+ self.claude_expanded = False
2645
+ self.claude_cursor = -1
2646
+ elif self.launch_field == LaunchField.HCOM_SECTION:
2647
+ if self.hcom_cursor >= 0:
2648
+ fields = self.build_hcom_fields()
2649
+ if self.hcom_cursor < len(fields):
2650
+ field = fields[self.hcom_cursor]
2651
+ self.config_edit[field.key] = ""
2652
+ self.config_field_cursors[field.key] = 0
2653
+ self.save_config_to_file()
2654
+ else:
2655
+ self.hcom_expanded = False
2656
+ self.hcom_cursor = -1
2657
+ elif self.launch_field == LaunchField.CUSTOM_ENV_SECTION:
2658
+ if self.custom_env_cursor >= 0:
2659
+ fields = self.build_custom_env_fields()
2660
+ if self.custom_env_cursor < len(fields):
2661
+ field = fields[self.custom_env_cursor]
2662
+ self.config_edit[field.key] = ""
2663
+ self.config_field_cursors[field.key] = 0
2664
+ self.save_config_to_file()
2665
+ else:
2666
+ self.custom_env_expanded = False
2667
+ self.custom_env_cursor = -1
2668
+ elif self.launch_field == LaunchField.COUNT:
2669
+ self.launch_count = "1"
2670
+
2671
+ # CTRL_R: Reset config to defaults (two-step confirmation)
2672
+ elif key == 'CTRL_R':
2673
+ is_confirming = self.pending_reset and (time.time() - self.pending_reset_time) <= self.CONFIRMATION_TIMEOUT
2674
+
2675
+ if is_confirming:
2676
+ # Execute config reset
2677
+ try:
2678
+ cmd_reset(['config'])
2679
+ self.load_config_from_file()
2680
+ self.load_launch_state()
2681
+ self.flash("Config reset to defaults")
2682
+ except Exception as e:
2683
+ self.flash_error(f"Reset failed: {str(e)}")
2684
+ finally:
2685
+ self.pending_reset = False
2686
+ else:
2687
+ # Show confirmation (first press)
2688
+ self.pending_reset = True
2689
+ self.pending_reset_time = time.time()
2690
+ self.flash(f"{FG_WHITE}Backup + reset config to defaults? (Ctrl+R again){RESET}", duration=self.CONFIRMATION_FLASH_DURATION, color='white')
2691
+
2692
+ # SPACE and printable: insert at cursor
2693
+ elif key == 'SPACE' or (key and len(key) == 1 and key.isprintable()):
2694
+ char = ' ' if key == 'SPACE' else key
2695
+ field_info = self.get_current_launch_field_info()
2696
+ if field_info:
2697
+ field_key, field_value, cursor_pos = field_info
2698
+
2699
+ # Validate for special fields
2700
+ if field_key == 'HCOM_TAG':
2701
+ override = CONFIG_FIELD_OVERRIDES.get(field_key, {})
2702
+ allowed_pattern = override.get('allowed_chars')
2703
+ if allowed_pattern:
2704
+ test_value = field_value[:cursor_pos] + char + field_value[cursor_pos:]
2705
+ if not re.match(allowed_pattern, test_value):
2706
+ return
2707
+
2708
+ new_value, new_cursor = text_input_insert(field_value, cursor_pos, char)
2709
+ self.update_launch_field(field_key, new_value, new_cursor)
2710
+
2711
+ def do_launch(self):
2712
+ """Execute launch using full spec integration"""
2713
+ # Check for validation errors first
2714
+ if self.validation_errors:
2715
+ error_fields = ', '.join(self.validation_errors.keys())
2716
+ self.flash_error(f"Fix config errors before launching: {error_fields}", duration=15.0)
2717
+ return
2718
+
2719
+ # Parse count
2720
+ try:
2721
+ count = int(self.launch_count) if self.launch_count else 1
2722
+ except ValueError:
2723
+ self.flash_error("Invalid count - must be number")
2724
+ return
2725
+
2726
+ # Load current spec from config
2727
+ try:
2728
+ claude_args_str = self.config_edit.get('HCOM_CLAUDE_ARGS', '')
2729
+ spec = resolve_claude_args(None, claude_args_str if claude_args_str else None)
2730
+ except Exception as e:
2731
+ self.flash_error(f"Failed to parse HCOM_CLAUDE_ARGS: {e}")
2732
+ return
2733
+
2734
+ # Check for parse errors BEFORE update (update loses original errors)
2735
+ if spec.errors:
2736
+ self.flash_error(f"Invalid HCOM_CLAUDE_ARGS: {'; '.join(spec.errors)}")
2737
+ return
2738
+
2739
+ # System flag matches background mode
2740
+ system_flag = None
2741
+ system_value = None
2742
+ if self.launch_system_prompt:
2743
+ system_flag = "--system-prompt" if self.launch_background else "--append-system-prompt"
2744
+ system_value = self.launch_system_prompt
2745
+
2746
+ spec = spec.update(
2747
+ background=self.launch_background,
2748
+ system_flag=system_flag,
2749
+ system_value=system_value,
2750
+ prompt=self.launch_prompt, # Always pass value (empty string deletes)
2751
+ )
2752
+
2753
+ # Build argv using spec (preserves all flags from HCOM_CLAUDE_ARGS)
2754
+ argv = [str(count), 'claude'] + spec.rebuild_tokens(include_system=True)
2755
+
2756
+ # Set env vars if specified (read from config_fields - source of truth)
2757
+ env_backup = {}
2758
+ try:
2759
+ agent = self.config_edit.get('HCOM_AGENT', '')
2760
+ if agent:
2761
+ env_backup['HCOM_AGENT'] = os.environ.get('HCOM_AGENT')
2762
+ os.environ['HCOM_AGENT'] = agent
2763
+ tag = self.config_edit.get('HCOM_TAG', '')
2764
+ if tag:
2765
+ env_backup['HCOM_TAG'] = os.environ.get('HCOM_TAG')
2766
+ os.environ['HCOM_TAG'] = tag
2767
+
2768
+ # Show launching message
2769
+ self.flash(f"Launching {count} instances...")
2770
+ self.render() # Force update to show message
2771
+
2772
+ # Call hcom.cmd_launch (handles all validation)
2773
+ # Add --no-auto-watch flag to prevent opening another watch window
2774
+ reload_config()
2775
+ result = cmd_launch(argv + ['--no-auto-watch'])
2776
+
2777
+ if result == 0: # Success
2778
+ # Switch to Manage screen to see new instances
2779
+ self.mode = Mode.MANAGE
2780
+ self.flash(f"Launched {count} instances")
2781
+ self.load_status() # Refresh immediately
2782
+ else:
2783
+ self.flash_error("Launch failed - check instances")
2784
+
2785
+ except Exception as e:
2786
+ # cmd_launch raises CLIError for validation failures
2787
+ self.flash_error(str(e))
2788
+ finally:
2789
+ # Restore env (clean up)
2790
+ for key, val in env_backup.items():
2791
+ if val is None:
2792
+ os.environ.pop(key, None)
2793
+ else:
2794
+ os.environ[key] = val
2795
+
2796
+ def format_multiline_log(self, display_time: str, sender: str, message: str) -> List[str]:
2797
+ """Format log message with multiline support (indented continuation lines)"""
2798
+ if '\n' not in message:
2799
+ return [f"{FG_GRAY}{display_time}{RESET} {FG_ORANGE}{sender}{RESET}: {message}"]
2800
+
2801
+ lines = message.split('\n')
2802
+ result = [f"{FG_GRAY}{display_time}{RESET} {FG_ORANGE}{sender}{RESET}: {lines[0]}"]
2803
+ indent = ' ' * (len(display_time) + len(sender) + 2)
2804
+ result.extend(indent + line for line in lines[1:])
2805
+ return result
2806
+
2807
+ def render_log_message(self, msg: dict):
2808
+ """Render a single log message (extracted helper)"""
2809
+ time_str = msg.get('timestamp', '')
2810
+ sender = msg.get('from', '')
2811
+ message = msg.get('message', '')
2812
+ display_time = format_timestamp(time_str)
2813
+
2814
+ for line in self.format_multiline_log(display_time, sender, message):
2815
+ print(line)
2816
+ print() # Empty line between messages
2817
+
2818
+ def render_status_with_separator(self, highlight_tab: str = "LOG"):
2819
+ """Render separator line and status bar (extracted helper)"""
2820
+ cols, _ = get_terminal_size()
2821
+
2822
+ # Separator or flash line
2823
+ flash = self.build_flash()
2824
+ if flash:
2825
+ flash_len = ansi_len(flash)
2826
+ remaining = cols - flash_len - 1
2827
+ separator = f"{FG_GRAY}{'─' * remaining}{RESET}" if remaining > 0 else ""
2828
+ print(f"{flash} {separator}")
2829
+ else:
2830
+ print(f"{FG_GRAY}{'─' * cols}{RESET}")
2831
+
2832
+ # Status line
2833
+ safe_width = cols - 2
2834
+ status = truncate_ansi(self.build_status_bar(highlight_tab=highlight_tab), safe_width)
2835
+ sys.stdout.write(status)
2836
+ sys.stdout.flush()
2837
+
2838
+ def show_log_native(self):
2839
+ """Exit TUI, show streaming log in native buffer with status line"""
2840
+ # Exit alt screen
2841
+ sys.stdout.write('\033[?1049l' + SHOW_CURSOR)
2842
+ sys.stdout.flush()
2843
+
2844
+ log_file = self.hcom_dir / 'hcom.log'
2845
+
2846
+ def redraw_all():
2847
+ """Redraw entire log and status (on entry or resize)"""
2848
+ # Clear screen
2849
+ sys.stdout.write('\033[2J\033[H')
2850
+ sys.stdout.flush()
2851
+
2852
+ # Dump existing log with formatting
2853
+ has_messages = False
2854
+ if log_file.exists():
2855
+ try:
2856
+ result = parse_log_messages(log_file)
2857
+ if result and hasattr(result, 'messages') and result.messages:
2858
+ for msg in result.messages:
2859
+ self.render_log_message(msg)
2860
+ has_messages = True
2861
+ except Exception:
2862
+ pass
2863
+
2864
+ # Separator and status
2865
+ if has_messages:
2866
+ self.render_status_with_separator("LOG")
2867
+ else:
2868
+ # No messages - show placeholder
2869
+ self.render_status_with_separator("LOG")
2870
+ print()
2871
+ print(f"{FG_GRAY}No messages - Tab to LAUNCH to create instances{RESET}")
2872
+
2873
+ cols, _ = get_terminal_size()
2874
+ return log_file.stat().st_size if log_file.exists() else 0, cols
2875
+
2876
+ # Initial draw
2877
+ last_pos, last_width = redraw_all()
2878
+ last_status_update = time.time()
2879
+ has_messages_state = last_pos > 0 # Track if we have messages
2880
+
2881
+ with KeyboardInput() as kbd:
2882
+ while True:
2883
+ key = kbd.get_key()
2884
+ if key == 'TAB':
2885
+ # Tab to exit back to TUI
2886
+ sys.stdout.write('\r\033[K') # Clear status line
2887
+ break
2888
+
2889
+ # Update status every 0.5s - also check for resize
2890
+ now = time.time()
2891
+ if now - last_status_update > 0.5:
2892
+ current_cols, _ = get_terminal_size()
2893
+ self.load_status() # Refresh instance data
2894
+
2895
+ # Check if status line is too long for current terminal width
2896
+ status_line = self.build_status_bar(highlight_tab="LOG")
2897
+ status_len = ansi_len(status_line)
2898
+
2899
+ if status_len >= current_cols - 2:
2900
+ # Status would wrap - need full redraw to fix it
2901
+ last_pos, last_width = redraw_all()
2902
+ has_messages_state = last_pos > 0
2903
+ else:
2904
+ # Status fits - just update it
2905
+ safe_width = current_cols - 2
2906
+ new_status = truncate_ansi(status_line, safe_width)
2907
+
2908
+ # If we were in "no messages" state, cursor is 2 lines below status
2909
+ if not has_messages_state:
2910
+ # Move up 2 lines to status, clear all 3 lines, update status, re-print message
2911
+ sys.stdout.write('\033[A\033[A\r\033[K' + new_status + '\n\033[K\n\033[K')
2912
+ sys.stdout.write(f"{FG_GRAY}No messages - Tab to LAUNCH to create instances{RESET}")
2913
+ else:
2914
+ # Normal update - update separator/flash line and status line
2915
+ # Move up to separator line, update it, then update status
2916
+ flash = self.build_flash()
2917
+ if flash:
2918
+ # Flash message on left, separator fills rest
2919
+ flash_len = ansi_len(flash)
2920
+ remaining = current_cols - flash_len - 1 # -1 for space
2921
+ separator = f"{FG_GRAY}{'─' * remaining}{RESET}" if remaining > 0 else ""
2922
+ separator_line = f"{flash} {separator}"
2923
+ else:
2924
+ separator_line = f"{FG_GRAY}{'─' * current_cols}{RESET}"
2925
+ sys.stdout.write('\r\033[A\033[K' + separator_line + '\n\033[K' + new_status)
2926
+
2927
+ sys.stdout.flush()
2928
+ last_width = current_cols
2929
+
2930
+ last_status_update = now
2931
+
2932
+ # Stream new messages
2933
+ if log_file.exists():
2934
+ current_size = log_file.stat().st_size
2935
+ if current_size > last_pos:
2936
+ try:
2937
+ result = parse_log_messages(log_file, last_pos)
2938
+ if result and hasattr(result, 'messages') and result.messages:
2939
+ # Clear separator and status: move up to separator, clear it and status, return to position
2940
+ sys.stdout.write('\r\033[A\033[K\n\033[K\033[A\r')
2941
+
2942
+ # Render new messages
2943
+ for msg in result.messages:
2944
+ self.render_log_message(msg)
2945
+
2946
+ # Redraw separator and status
2947
+ self.render_status_with_separator("LOG")
2948
+ has_messages_state = True # We now have messages
2949
+ last_pos = current_size
2950
+ except Exception:
2951
+ # Parse failed - skip this update
2952
+ pass
2953
+
2954
+ time.sleep(0.01)
2955
+
2956
+ # Return to TUI
2957
+ sys.stdout.write(HIDE_CURSOR + '\033[?1049h')
2958
+ sys.stdout.flush()
2959
+
2960
+ def handle_key(self, key: str):
2961
+ """Handle key press based on current mode"""
2962
+ if self.mode == Mode.MANAGE:
2963
+ self.handle_manage_key(key)
2964
+ elif self.mode == Mode.LAUNCH:
2965
+ self.handle_launch_key(key)