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/__init__.py +2 -2
- hcom/__main__.py +3 -3683
- hcom/cli.py +4613 -0
- hcom/shared.py +1036 -0
- hcom/ui.py +2965 -0
- {hcom-0.5.0.dist-info → hcom-0.6.0.dist-info}/METADATA +51 -39
- hcom-0.6.0.dist-info/RECORD +10 -0
- hcom-0.5.0.dist-info/RECORD +0 -7
- {hcom-0.5.0.dist-info → hcom-0.6.0.dist-info}/WHEEL +0 -0
- {hcom-0.5.0.dist-info → hcom-0.6.0.dist-info}/entry_points.txt +0 -0
- {hcom-0.5.0.dist-info → hcom-0.6.0.dist-info}/top_level.txt +0 -0
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)
|