nc1709 1.15.4__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.
- nc1709/__init__.py +13 -0
- nc1709/agent/__init__.py +36 -0
- nc1709/agent/core.py +505 -0
- nc1709/agent/mcp_bridge.py +245 -0
- nc1709/agent/permissions.py +298 -0
- nc1709/agent/tools/__init__.py +21 -0
- nc1709/agent/tools/base.py +440 -0
- nc1709/agent/tools/bash_tool.py +367 -0
- nc1709/agent/tools/file_tools.py +454 -0
- nc1709/agent/tools/notebook_tools.py +516 -0
- nc1709/agent/tools/search_tools.py +322 -0
- nc1709/agent/tools/task_tool.py +284 -0
- nc1709/agent/tools/web_tools.py +555 -0
- nc1709/agents/__init__.py +17 -0
- nc1709/agents/auto_fix.py +506 -0
- nc1709/agents/test_generator.py +507 -0
- nc1709/checkpoints.py +372 -0
- nc1709/cli.py +3380 -0
- nc1709/cli_ui.py +1080 -0
- nc1709/cognitive/__init__.py +149 -0
- nc1709/cognitive/anticipation.py +594 -0
- nc1709/cognitive/context_engine.py +1046 -0
- nc1709/cognitive/council.py +824 -0
- nc1709/cognitive/learning.py +761 -0
- nc1709/cognitive/router.py +583 -0
- nc1709/cognitive/system.py +519 -0
- nc1709/config.py +155 -0
- nc1709/custom_commands.py +300 -0
- nc1709/executor.py +333 -0
- nc1709/file_controller.py +354 -0
- nc1709/git_integration.py +308 -0
- nc1709/github_integration.py +477 -0
- nc1709/image_input.py +446 -0
- nc1709/linting.py +519 -0
- nc1709/llm_adapter.py +667 -0
- nc1709/logger.py +192 -0
- nc1709/mcp/__init__.py +18 -0
- nc1709/mcp/client.py +370 -0
- nc1709/mcp/manager.py +407 -0
- nc1709/mcp/protocol.py +210 -0
- nc1709/mcp/server.py +473 -0
- nc1709/memory/__init__.py +20 -0
- nc1709/memory/embeddings.py +325 -0
- nc1709/memory/indexer.py +474 -0
- nc1709/memory/sessions.py +432 -0
- nc1709/memory/vector_store.py +451 -0
- nc1709/models/__init__.py +86 -0
- nc1709/models/detector.py +377 -0
- nc1709/models/formats.py +315 -0
- nc1709/models/manager.py +438 -0
- nc1709/models/registry.py +497 -0
- nc1709/performance/__init__.py +343 -0
- nc1709/performance/cache.py +705 -0
- nc1709/performance/pipeline.py +611 -0
- nc1709/performance/tiering.py +543 -0
- nc1709/plan_mode.py +362 -0
- nc1709/plugins/__init__.py +17 -0
- nc1709/plugins/agents/__init__.py +18 -0
- nc1709/plugins/agents/django_agent.py +912 -0
- nc1709/plugins/agents/docker_agent.py +623 -0
- nc1709/plugins/agents/fastapi_agent.py +887 -0
- nc1709/plugins/agents/git_agent.py +731 -0
- nc1709/plugins/agents/nextjs_agent.py +867 -0
- nc1709/plugins/base.py +359 -0
- nc1709/plugins/manager.py +411 -0
- nc1709/plugins/registry.py +337 -0
- nc1709/progress.py +443 -0
- nc1709/prompts/__init__.py +22 -0
- nc1709/prompts/agent_system.py +180 -0
- nc1709/prompts/task_prompts.py +340 -0
- nc1709/prompts/unified_prompt.py +133 -0
- nc1709/reasoning_engine.py +541 -0
- nc1709/remote_client.py +266 -0
- nc1709/shell_completions.py +349 -0
- nc1709/slash_commands.py +649 -0
- nc1709/task_classifier.py +408 -0
- nc1709/version_check.py +177 -0
- nc1709/web/__init__.py +8 -0
- nc1709/web/server.py +950 -0
- nc1709/web/templates/index.html +1127 -0
- nc1709-1.15.4.dist-info/METADATA +858 -0
- nc1709-1.15.4.dist-info/RECORD +86 -0
- nc1709-1.15.4.dist-info/WHEEL +5 -0
- nc1709-1.15.4.dist-info/entry_points.txt +2 -0
- nc1709-1.15.4.dist-info/licenses/LICENSE +9 -0
- nc1709-1.15.4.dist-info/top_level.txt +1 -0
nc1709/cli_ui.py
ADDED
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI UI - Claude Code-style Interactive Visual Feedback System
|
|
3
|
+
|
|
4
|
+
Provides rich, real-time visual feedback for CLI operations with:
|
|
5
|
+
- Animated spinners with status messages
|
|
6
|
+
- Tool/Action indicators with nested output
|
|
7
|
+
- State transitions (in-progress -> complete/failed)
|
|
8
|
+
- Color coding (blue=active, green=success, red=error, yellow=info)
|
|
9
|
+
- Non-blocking output with line replacement
|
|
10
|
+
- Streaming text support
|
|
11
|
+
- Text wrapping for clean output
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import textwrap
|
|
18
|
+
import time
|
|
19
|
+
import threading
|
|
20
|
+
from typing import Optional, List, Callable, Any, Dict
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# ANSI Color Codes
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
class Color:
|
|
31
|
+
"""ANSI color codes for terminal output"""
|
|
32
|
+
# Reset
|
|
33
|
+
RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
# Regular colors
|
|
36
|
+
BLACK = "\033[30m"
|
|
37
|
+
RED = "\033[31m"
|
|
38
|
+
GREEN = "\033[32m"
|
|
39
|
+
YELLOW = "\033[33m"
|
|
40
|
+
BLUE = "\033[34m"
|
|
41
|
+
MAGENTA = "\033[35m"
|
|
42
|
+
CYAN = "\033[36m"
|
|
43
|
+
WHITE = "\033[37m"
|
|
44
|
+
|
|
45
|
+
# Bright colors
|
|
46
|
+
BRIGHT_BLACK = "\033[90m"
|
|
47
|
+
BRIGHT_RED = "\033[91m"
|
|
48
|
+
BRIGHT_GREEN = "\033[92m"
|
|
49
|
+
BRIGHT_YELLOW = "\033[93m"
|
|
50
|
+
BRIGHT_BLUE = "\033[94m"
|
|
51
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
52
|
+
BRIGHT_CYAN = "\033[96m"
|
|
53
|
+
BRIGHT_WHITE = "\033[97m"
|
|
54
|
+
|
|
55
|
+
# Styles
|
|
56
|
+
BOLD = "\033[1m"
|
|
57
|
+
DIM = "\033[2m"
|
|
58
|
+
ITALIC = "\033[3m"
|
|
59
|
+
UNDERLINE = "\033[4m"
|
|
60
|
+
|
|
61
|
+
# Cursor control
|
|
62
|
+
HIDE_CURSOR = "\033[?25l"
|
|
63
|
+
SHOW_CURSOR = "\033[?25h"
|
|
64
|
+
CLEAR_LINE = "\033[2K"
|
|
65
|
+
MOVE_UP = "\033[1A"
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def disable(cls):
|
|
69
|
+
"""Disable colors (for non-TTY output)"""
|
|
70
|
+
for attr in dir(cls):
|
|
71
|
+
if not attr.startswith('_') and attr.isupper():
|
|
72
|
+
setattr(cls, attr, '')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Check if output is a TTY
|
|
76
|
+
if not sys.stdout.isatty():
|
|
77
|
+
Color.disable()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# =============================================================================
|
|
81
|
+
# Status Icons and Symbols
|
|
82
|
+
# =============================================================================
|
|
83
|
+
|
|
84
|
+
class Icons:
|
|
85
|
+
"""Unicode icons for status display"""
|
|
86
|
+
# Status indicators
|
|
87
|
+
THINKING = "✻"
|
|
88
|
+
SUCCESS = "✓"
|
|
89
|
+
FAILURE = "✗"
|
|
90
|
+
WARNING = "⚠"
|
|
91
|
+
INFO = "ℹ"
|
|
92
|
+
|
|
93
|
+
# Spinners
|
|
94
|
+
DOTS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
95
|
+
BRAILLE = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"]
|
|
96
|
+
|
|
97
|
+
# Tree
|
|
98
|
+
TREE_BRANCH = "└─"
|
|
99
|
+
TREE_VERTICAL = "│"
|
|
100
|
+
TREE_TEE = "├─"
|
|
101
|
+
|
|
102
|
+
# Actions
|
|
103
|
+
READ = "📄"
|
|
104
|
+
WRITE = "✏️"
|
|
105
|
+
EXECUTE = "⚡"
|
|
106
|
+
SEARCH = "🔍"
|
|
107
|
+
ANALYZE = "🧠"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# =============================================================================
|
|
111
|
+
# State Management
|
|
112
|
+
# =============================================================================
|
|
113
|
+
|
|
114
|
+
class ActionState(Enum):
|
|
115
|
+
"""States for action indicators"""
|
|
116
|
+
PENDING = "pending"
|
|
117
|
+
RUNNING = "running"
|
|
118
|
+
SUCCESS = "success"
|
|
119
|
+
FAILED = "failed"
|
|
120
|
+
SKIPPED = "skipped"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class ActionItem:
|
|
125
|
+
"""Represents a single action or tool call"""
|
|
126
|
+
name: str
|
|
127
|
+
target: Optional[str] = None
|
|
128
|
+
state: ActionState = ActionState.PENDING
|
|
129
|
+
message: Optional[str] = None
|
|
130
|
+
children: List["ActionItem"] = field(default_factory=list)
|
|
131
|
+
start_time: Optional[float] = None
|
|
132
|
+
end_time: Optional[float] = None
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def duration(self) -> Optional[float]:
|
|
136
|
+
"""Get action duration in seconds"""
|
|
137
|
+
if self.start_time and self.end_time:
|
|
138
|
+
return self.end_time - self.start_time
|
|
139
|
+
elif self.start_time:
|
|
140
|
+
return time.time() - self.start_time
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def format_duration(self) -> str:
|
|
144
|
+
"""Format duration as human-readable string"""
|
|
145
|
+
d = self.duration
|
|
146
|
+
if d is None:
|
|
147
|
+
return ""
|
|
148
|
+
if d < 1:
|
|
149
|
+
return f"{d*1000:.0f}ms"
|
|
150
|
+
elif d < 60:
|
|
151
|
+
return f"{d:.1f}s"
|
|
152
|
+
else:
|
|
153
|
+
return f"{d/60:.1f}m"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# =============================================================================
|
|
157
|
+
# Action Spinner - Main Visual Component
|
|
158
|
+
# =============================================================================
|
|
159
|
+
|
|
160
|
+
class ActionSpinner:
|
|
161
|
+
"""
|
|
162
|
+
Claude Code-style animated spinner with status messages.
|
|
163
|
+
|
|
164
|
+
Usage:
|
|
165
|
+
spinner = ActionSpinner("Analyzing your request")
|
|
166
|
+
spinner.start()
|
|
167
|
+
spinner.update("Processing code...")
|
|
168
|
+
spinner.add_action("Read", "main.py")
|
|
169
|
+
spinner.complete_action(0)
|
|
170
|
+
spinner.success("Analysis complete")
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
message: str = "Processing",
|
|
176
|
+
spinner_chars: List[str] = None,
|
|
177
|
+
interval: float = 0.08
|
|
178
|
+
):
|
|
179
|
+
"""Initialize the action spinner.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
message: Initial status message
|
|
183
|
+
spinner_chars: Characters for spinner animation
|
|
184
|
+
interval: Animation interval in seconds
|
|
185
|
+
"""
|
|
186
|
+
self.message = message
|
|
187
|
+
self.spinner_chars = spinner_chars or Icons.DOTS
|
|
188
|
+
self.interval = interval
|
|
189
|
+
|
|
190
|
+
self.running = False
|
|
191
|
+
self.frame_index = 0
|
|
192
|
+
self.actions: List[ActionItem] = []
|
|
193
|
+
self.current_action: Optional[int] = None
|
|
194
|
+
|
|
195
|
+
self._thread: Optional[threading.Thread] = None
|
|
196
|
+
self._lock = threading.Lock()
|
|
197
|
+
self._last_render_lines = 0
|
|
198
|
+
|
|
199
|
+
def start(self) -> "ActionSpinner":
|
|
200
|
+
"""Start the spinner animation."""
|
|
201
|
+
self.running = True
|
|
202
|
+
sys.stdout.write(Color.HIDE_CURSOR)
|
|
203
|
+
sys.stdout.flush()
|
|
204
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
205
|
+
self._thread.start()
|
|
206
|
+
return self
|
|
207
|
+
|
|
208
|
+
def stop(self) -> None:
|
|
209
|
+
"""Stop the spinner animation."""
|
|
210
|
+
self.running = False
|
|
211
|
+
if self._thread:
|
|
212
|
+
self._thread.join(timeout=0.5)
|
|
213
|
+
sys.stdout.write(Color.SHOW_CURSOR)
|
|
214
|
+
sys.stdout.flush()
|
|
215
|
+
|
|
216
|
+
def _animate(self) -> None:
|
|
217
|
+
"""Animation loop running in background thread."""
|
|
218
|
+
while self.running:
|
|
219
|
+
self._render()
|
|
220
|
+
self.frame_index = (self.frame_index + 1) % len(self.spinner_chars)
|
|
221
|
+
time.sleep(self.interval)
|
|
222
|
+
|
|
223
|
+
def _render(self) -> None:
|
|
224
|
+
"""Render the current state to terminal."""
|
|
225
|
+
with self._lock:
|
|
226
|
+
# Clear previous output
|
|
227
|
+
if self._last_render_lines > 0:
|
|
228
|
+
sys.stdout.write(f"\033[{self._last_render_lines}A") # Move up
|
|
229
|
+
for _ in range(self._last_render_lines):
|
|
230
|
+
sys.stdout.write(Color.CLEAR_LINE + "\n")
|
|
231
|
+
sys.stdout.write(f"\033[{self._last_render_lines}A") # Move back up
|
|
232
|
+
|
|
233
|
+
lines = []
|
|
234
|
+
|
|
235
|
+
# Main status line with spinner
|
|
236
|
+
spinner = self.spinner_chars[self.frame_index]
|
|
237
|
+
status_line = f"{Color.BLUE}{spinner}{Color.RESET} {Color.BOLD}{Icons.THINKING}{Color.RESET} {self.message}..."
|
|
238
|
+
lines.append(status_line)
|
|
239
|
+
|
|
240
|
+
# Render actions
|
|
241
|
+
for i, action in enumerate(self.actions):
|
|
242
|
+
action_line = self._format_action(action, i == len(self.actions) - 1)
|
|
243
|
+
lines.append(action_line)
|
|
244
|
+
|
|
245
|
+
# Render children
|
|
246
|
+
for j, child in enumerate(action.children):
|
|
247
|
+
child_line = self._format_action(
|
|
248
|
+
child,
|
|
249
|
+
j == len(action.children) - 1,
|
|
250
|
+
indent=2
|
|
251
|
+
)
|
|
252
|
+
lines.append(child_line)
|
|
253
|
+
|
|
254
|
+
# Write all lines
|
|
255
|
+
output = "\n".join(lines) + "\n"
|
|
256
|
+
sys.stdout.write(output)
|
|
257
|
+
sys.stdout.flush()
|
|
258
|
+
|
|
259
|
+
self._last_render_lines = len(lines)
|
|
260
|
+
|
|
261
|
+
def _format_action(self, action: ActionItem, is_last: bool, indent: int = 1) -> str:
|
|
262
|
+
"""Format a single action line."""
|
|
263
|
+
# Choose tree character
|
|
264
|
+
tree_char = Icons.TREE_BRANCH if is_last else Icons.TREE_TEE
|
|
265
|
+
prefix = " " * indent + tree_char + " "
|
|
266
|
+
|
|
267
|
+
# Format action name with target
|
|
268
|
+
if action.target:
|
|
269
|
+
action_text = f"{action.name}({Color.CYAN}{action.target}{Color.RESET})"
|
|
270
|
+
else:
|
|
271
|
+
action_text = action.name
|
|
272
|
+
|
|
273
|
+
# Add state indicator
|
|
274
|
+
if action.state == ActionState.RUNNING:
|
|
275
|
+
state_icon = f"{Color.BLUE}●{Color.RESET}"
|
|
276
|
+
elif action.state == ActionState.SUCCESS:
|
|
277
|
+
state_icon = f"{Color.GREEN}{Icons.SUCCESS}{Color.RESET}"
|
|
278
|
+
elif action.state == ActionState.FAILED:
|
|
279
|
+
state_icon = f"{Color.RED}{Icons.FAILURE}{Color.RESET}"
|
|
280
|
+
elif action.state == ActionState.SKIPPED:
|
|
281
|
+
state_icon = f"{Color.YELLOW}○{Color.RESET}"
|
|
282
|
+
else:
|
|
283
|
+
state_icon = f"{Color.DIM}○{Color.RESET}"
|
|
284
|
+
|
|
285
|
+
# Add duration if complete
|
|
286
|
+
duration = ""
|
|
287
|
+
if action.state in (ActionState.SUCCESS, ActionState.FAILED) and action.duration:
|
|
288
|
+
duration = f" {Color.DIM}({action.format_duration()}){Color.RESET}"
|
|
289
|
+
|
|
290
|
+
return f"{prefix}{state_icon} {action_text}{duration}"
|
|
291
|
+
|
|
292
|
+
def update(self, message: str) -> None:
|
|
293
|
+
"""Update the status message.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
message: New status message
|
|
297
|
+
"""
|
|
298
|
+
with self._lock:
|
|
299
|
+
self.message = message
|
|
300
|
+
|
|
301
|
+
def add_action(
|
|
302
|
+
self,
|
|
303
|
+
name: str,
|
|
304
|
+
target: Optional[str] = None,
|
|
305
|
+
parent_index: Optional[int] = None
|
|
306
|
+
) -> int:
|
|
307
|
+
"""Add a new action indicator.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
name: Action name (e.g., "Read", "Write", "Execute")
|
|
311
|
+
target: Action target (e.g., filename, command)
|
|
312
|
+
parent_index: Index of parent action for nesting
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Index of the new action
|
|
316
|
+
"""
|
|
317
|
+
with self._lock:
|
|
318
|
+
action = ActionItem(
|
|
319
|
+
name=name,
|
|
320
|
+
target=target,
|
|
321
|
+
state=ActionState.RUNNING,
|
|
322
|
+
start_time=time.time()
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if parent_index is not None and parent_index < len(self.actions):
|
|
326
|
+
self.actions[parent_index].children.append(action)
|
|
327
|
+
return len(self.actions[parent_index].children) - 1
|
|
328
|
+
else:
|
|
329
|
+
self.actions.append(action)
|
|
330
|
+
return len(self.actions) - 1
|
|
331
|
+
|
|
332
|
+
def complete_action(self, index: int, parent_index: Optional[int] = None) -> None:
|
|
333
|
+
"""Mark an action as complete.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
index: Action index
|
|
337
|
+
parent_index: Parent index if this is a child action
|
|
338
|
+
"""
|
|
339
|
+
with self._lock:
|
|
340
|
+
if parent_index is not None:
|
|
341
|
+
action = self.actions[parent_index].children[index]
|
|
342
|
+
else:
|
|
343
|
+
action = self.actions[index]
|
|
344
|
+
action.state = ActionState.SUCCESS
|
|
345
|
+
action.end_time = time.time()
|
|
346
|
+
|
|
347
|
+
def fail_action(
|
|
348
|
+
self,
|
|
349
|
+
index: int,
|
|
350
|
+
message: Optional[str] = None,
|
|
351
|
+
parent_index: Optional[int] = None
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Mark an action as failed.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
index: Action index
|
|
357
|
+
message: Error message
|
|
358
|
+
parent_index: Parent index if this is a child action
|
|
359
|
+
"""
|
|
360
|
+
with self._lock:
|
|
361
|
+
if parent_index is not None:
|
|
362
|
+
action = self.actions[parent_index].children[index]
|
|
363
|
+
else:
|
|
364
|
+
action = self.actions[index]
|
|
365
|
+
action.state = ActionState.FAILED
|
|
366
|
+
action.message = message
|
|
367
|
+
action.end_time = time.time()
|
|
368
|
+
|
|
369
|
+
def success(self, message: str) -> None:
|
|
370
|
+
"""Complete spinner with success state.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
message: Success message
|
|
374
|
+
"""
|
|
375
|
+
self.stop()
|
|
376
|
+
self._render_final(ActionState.SUCCESS, message)
|
|
377
|
+
|
|
378
|
+
def failure(self, message: str) -> None:
|
|
379
|
+
"""Complete spinner with failure state.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
message: Failure message
|
|
383
|
+
"""
|
|
384
|
+
self.stop()
|
|
385
|
+
self._render_final(ActionState.FAILED, message)
|
|
386
|
+
|
|
387
|
+
def _render_final(self, state: ActionState, message: str) -> None:
|
|
388
|
+
"""Render final state after spinner stops."""
|
|
389
|
+
# Clear spinner output
|
|
390
|
+
if self._last_render_lines > 0:
|
|
391
|
+
sys.stdout.write(f"\033[{self._last_render_lines}A")
|
|
392
|
+
for _ in range(self._last_render_lines):
|
|
393
|
+
sys.stdout.write(Color.CLEAR_LINE + "\n")
|
|
394
|
+
sys.stdout.write(f"\033[{self._last_render_lines}A")
|
|
395
|
+
|
|
396
|
+
# Render final state
|
|
397
|
+
if state == ActionState.SUCCESS:
|
|
398
|
+
icon = f"{Color.GREEN}{Icons.SUCCESS}{Color.RESET}"
|
|
399
|
+
else:
|
|
400
|
+
icon = f"{Color.RED}{Icons.FAILURE}{Color.RESET}"
|
|
401
|
+
|
|
402
|
+
print(f"{icon} {message}")
|
|
403
|
+
|
|
404
|
+
# Render completed actions
|
|
405
|
+
for i, action in enumerate(self.actions):
|
|
406
|
+
line = self._format_action(action, i == len(self.actions) - 1)
|
|
407
|
+
print(line)
|
|
408
|
+
for j, child in enumerate(action.children):
|
|
409
|
+
child_line = self._format_action(child, j == len(action.children) - 1, indent=2)
|
|
410
|
+
print(child_line)
|
|
411
|
+
|
|
412
|
+
def __enter__(self) -> "ActionSpinner":
|
|
413
|
+
return self.start()
|
|
414
|
+
|
|
415
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
416
|
+
if exc_type:
|
|
417
|
+
self.failure(str(exc_val) if exc_val else "Operation failed")
|
|
418
|
+
else:
|
|
419
|
+
self.stop()
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# =============================================================================
|
|
423
|
+
# Streaming Output Handler
|
|
424
|
+
# =============================================================================
|
|
425
|
+
|
|
426
|
+
class StreamingOutput:
|
|
427
|
+
"""
|
|
428
|
+
Handler for streaming text output with proper terminal management.
|
|
429
|
+
|
|
430
|
+
Usage:
|
|
431
|
+
stream = StreamingOutput()
|
|
432
|
+
stream.start()
|
|
433
|
+
for token in tokens:
|
|
434
|
+
stream.write(token)
|
|
435
|
+
stream.end()
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def __init__(self, prefix: str = ""):
|
|
439
|
+
"""Initialize streaming output.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
prefix: Optional prefix for the stream
|
|
443
|
+
"""
|
|
444
|
+
self.prefix = prefix
|
|
445
|
+
self.buffer = ""
|
|
446
|
+
self.started = False
|
|
447
|
+
self._lock = threading.Lock()
|
|
448
|
+
|
|
449
|
+
def start(self) -> None:
|
|
450
|
+
"""Start streaming output."""
|
|
451
|
+
self.started = True
|
|
452
|
+
if self.prefix:
|
|
453
|
+
sys.stdout.write(f"{Color.DIM}{self.prefix}{Color.RESET}")
|
|
454
|
+
sys.stdout.flush()
|
|
455
|
+
|
|
456
|
+
def write(self, text: str) -> None:
|
|
457
|
+
"""Write text to stream.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
text: Text to output
|
|
461
|
+
"""
|
|
462
|
+
with self._lock:
|
|
463
|
+
self.buffer += text
|
|
464
|
+
sys.stdout.write(text)
|
|
465
|
+
sys.stdout.flush()
|
|
466
|
+
|
|
467
|
+
def writeline(self, text: str) -> None:
|
|
468
|
+
"""Write a complete line.
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
text: Line text
|
|
472
|
+
"""
|
|
473
|
+
self.write(text + "\n")
|
|
474
|
+
|
|
475
|
+
def end(self, newline: bool = True) -> str:
|
|
476
|
+
"""End streaming and return complete output.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
newline: Whether to add trailing newline
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
Complete buffered text
|
|
483
|
+
"""
|
|
484
|
+
if newline and self.buffer and not self.buffer.endswith("\n"):
|
|
485
|
+
sys.stdout.write("\n")
|
|
486
|
+
sys.stdout.flush()
|
|
487
|
+
self.started = False
|
|
488
|
+
return self.buffer
|
|
489
|
+
|
|
490
|
+
def clear(self) -> None:
|
|
491
|
+
"""Clear the current line."""
|
|
492
|
+
sys.stdout.write(Color.CLEAR_LINE + "\r")
|
|
493
|
+
sys.stdout.flush()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# =============================================================================
|
|
497
|
+
# Status Messages
|
|
498
|
+
# =============================================================================
|
|
499
|
+
|
|
500
|
+
def status(message: str, state: str = "info") -> None:
|
|
501
|
+
"""Print a status message with icon.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
message: Status message
|
|
505
|
+
state: Status type (info, success, error, warning, thinking)
|
|
506
|
+
"""
|
|
507
|
+
icons = {
|
|
508
|
+
"info": f"{Color.BLUE}{Icons.INFO}{Color.RESET}",
|
|
509
|
+
"success": f"{Color.GREEN}{Icons.SUCCESS}{Color.RESET}",
|
|
510
|
+
"error": f"{Color.RED}{Icons.FAILURE}{Color.RESET}",
|
|
511
|
+
"warning": f"{Color.YELLOW}{Icons.WARNING}{Color.RESET}",
|
|
512
|
+
"thinking": f"{Color.BLUE}{Icons.THINKING}{Color.RESET}",
|
|
513
|
+
}
|
|
514
|
+
icon = icons.get(state, icons["info"])
|
|
515
|
+
print(f"{icon} {message}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def thinking(message: str) -> None:
|
|
519
|
+
"""Print a thinking/processing status."""
|
|
520
|
+
status(message, "thinking")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def success(message: str) -> None:
|
|
524
|
+
"""Print a success status."""
|
|
525
|
+
status(message, "success")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def error(message: str) -> None:
|
|
529
|
+
"""Print an error status."""
|
|
530
|
+
status(message, "error")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def warning(message: str) -> None:
|
|
534
|
+
"""Print a warning status."""
|
|
535
|
+
status(message, "warning")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def info(message: str) -> None:
|
|
539
|
+
"""Print an info status."""
|
|
540
|
+
status(message, "info")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# =============================================================================
|
|
544
|
+
# Action Logging
|
|
545
|
+
# =============================================================================
|
|
546
|
+
|
|
547
|
+
def log_action(action: str, target: str, state: str = "running") -> None:
|
|
548
|
+
"""Log a tool/action with its target.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
action: Action name (Read, Write, Bash, etc.)
|
|
552
|
+
target: Action target (filename, command, etc.)
|
|
553
|
+
state: Action state (running, success, error)
|
|
554
|
+
"""
|
|
555
|
+
states = {
|
|
556
|
+
"running": f"{Color.BLUE}●{Color.RESET}",
|
|
557
|
+
"success": f"{Color.GREEN}{Icons.SUCCESS}{Color.RESET}",
|
|
558
|
+
"error": f"{Color.RED}{Icons.FAILURE}{Color.RESET}",
|
|
559
|
+
"pending": f"{Color.DIM}○{Color.RESET}",
|
|
560
|
+
}
|
|
561
|
+
icon = states.get(state, states["running"])
|
|
562
|
+
print(f" {Icons.TREE_BRANCH} {icon} {action}({Color.CYAN}{target}{Color.RESET})")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# =============================================================================
|
|
566
|
+
# Progress Indicators
|
|
567
|
+
# =============================================================================
|
|
568
|
+
|
|
569
|
+
class InlineProgress:
|
|
570
|
+
"""Inline progress indicator that updates in place."""
|
|
571
|
+
|
|
572
|
+
def __init__(self, total: int, description: str = ""):
|
|
573
|
+
"""Initialize progress.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
total: Total number of items
|
|
577
|
+
description: Progress description
|
|
578
|
+
"""
|
|
579
|
+
self.total = total
|
|
580
|
+
self.current = 0
|
|
581
|
+
self.description = description
|
|
582
|
+
self.start_time = time.time()
|
|
583
|
+
|
|
584
|
+
def update(self, amount: int = 1) -> None:
|
|
585
|
+
"""Update progress.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
amount: Amount to increment
|
|
589
|
+
"""
|
|
590
|
+
self.current = min(self.current + amount, self.total)
|
|
591
|
+
self._render()
|
|
592
|
+
|
|
593
|
+
def _render(self) -> None:
|
|
594
|
+
"""Render progress line."""
|
|
595
|
+
pct = self.current / self.total if self.total > 0 else 0
|
|
596
|
+
bar_width = 20
|
|
597
|
+
filled = int(bar_width * pct)
|
|
598
|
+
bar = "█" * filled + "░" * (bar_width - filled)
|
|
599
|
+
|
|
600
|
+
elapsed = time.time() - self.start_time
|
|
601
|
+
eta = ""
|
|
602
|
+
if pct > 0:
|
|
603
|
+
remaining = (elapsed / pct) - elapsed
|
|
604
|
+
eta = f" ETA: {remaining:.0f}s" if remaining > 1 else ""
|
|
605
|
+
|
|
606
|
+
desc = f"{self.description}: " if self.description else ""
|
|
607
|
+
line = f"\r{desc}[{bar}] {self.current}/{self.total} ({pct*100:.0f}%){eta}"
|
|
608
|
+
|
|
609
|
+
sys.stdout.write(Color.CLEAR_LINE + line)
|
|
610
|
+
sys.stdout.flush()
|
|
611
|
+
|
|
612
|
+
if self.current >= self.total:
|
|
613
|
+
print() # Newline when done
|
|
614
|
+
|
|
615
|
+
def finish(self) -> None:
|
|
616
|
+
"""Mark as complete."""
|
|
617
|
+
self.current = self.total
|
|
618
|
+
self._render()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# =============================================================================
|
|
622
|
+
# Context Managers
|
|
623
|
+
# =============================================================================
|
|
624
|
+
|
|
625
|
+
@contextmanager
|
|
626
|
+
def action_spinner(message: str = "Processing"):
|
|
627
|
+
"""Context manager for action spinner.
|
|
628
|
+
|
|
629
|
+
Usage:
|
|
630
|
+
with action_spinner("Analyzing code") as spinner:
|
|
631
|
+
spinner.add_action("Read", "main.py")
|
|
632
|
+
do_work()
|
|
633
|
+
spinner.complete_action(0)
|
|
634
|
+
"""
|
|
635
|
+
spinner = ActionSpinner(message)
|
|
636
|
+
try:
|
|
637
|
+
yield spinner.start()
|
|
638
|
+
except Exception as e:
|
|
639
|
+
spinner.failure(str(e))
|
|
640
|
+
raise
|
|
641
|
+
finally:
|
|
642
|
+
if spinner.running:
|
|
643
|
+
spinner.stop()
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@contextmanager
|
|
647
|
+
def progress(total: int, description: str = ""):
|
|
648
|
+
"""Context manager for progress indicator.
|
|
649
|
+
|
|
650
|
+
Usage:
|
|
651
|
+
with progress(100, "Processing files") as p:
|
|
652
|
+
for item in items:
|
|
653
|
+
process(item)
|
|
654
|
+
p.update()
|
|
655
|
+
"""
|
|
656
|
+
prog = InlineProgress(total, description)
|
|
657
|
+
try:
|
|
658
|
+
yield prog
|
|
659
|
+
finally:
|
|
660
|
+
prog.finish()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# =============================================================================
|
|
664
|
+
# Task Display
|
|
665
|
+
# =============================================================================
|
|
666
|
+
|
|
667
|
+
class TaskDisplay:
|
|
668
|
+
"""
|
|
669
|
+
Display for multi-step task execution with Claude Code style.
|
|
670
|
+
|
|
671
|
+
Usage:
|
|
672
|
+
task = TaskDisplay("Implementing feature")
|
|
673
|
+
task.start()
|
|
674
|
+
task.step("Analyzing requirements")
|
|
675
|
+
task.action("Read", "spec.md")
|
|
676
|
+
task.complete_step()
|
|
677
|
+
task.step("Writing code")
|
|
678
|
+
task.action("Write", "feature.py")
|
|
679
|
+
task.complete_step()
|
|
680
|
+
task.finish()
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
def __init__(self, title: str):
|
|
684
|
+
"""Initialize task display.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
title: Task title
|
|
688
|
+
"""
|
|
689
|
+
self.title = title
|
|
690
|
+
self.steps: List[Dict[str, Any]] = []
|
|
691
|
+
self.current_step: Optional[int] = None
|
|
692
|
+
self.start_time: Optional[float] = None
|
|
693
|
+
self._spinner: Optional[ActionSpinner] = None
|
|
694
|
+
|
|
695
|
+
def start(self) -> "TaskDisplay":
|
|
696
|
+
"""Start task execution display."""
|
|
697
|
+
self.start_time = time.time()
|
|
698
|
+
print(f"\n{Color.BOLD}{Icons.THINKING} {self.title}{Color.RESET}")
|
|
699
|
+
print(f"{Color.DIM}{'─' * 50}{Color.RESET}\n")
|
|
700
|
+
return self
|
|
701
|
+
|
|
702
|
+
def step(self, description: str) -> None:
|
|
703
|
+
"""Start a new step.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
description: Step description
|
|
707
|
+
"""
|
|
708
|
+
# Complete previous spinner if any
|
|
709
|
+
if self._spinner and self._spinner.running:
|
|
710
|
+
self._spinner.stop()
|
|
711
|
+
|
|
712
|
+
step_info = {
|
|
713
|
+
"description": description,
|
|
714
|
+
"actions": [],
|
|
715
|
+
"state": ActionState.RUNNING,
|
|
716
|
+
"start_time": time.time()
|
|
717
|
+
}
|
|
718
|
+
self.steps.append(step_info)
|
|
719
|
+
self.current_step = len(self.steps) - 1
|
|
720
|
+
|
|
721
|
+
# Start new spinner for this step
|
|
722
|
+
self._spinner = ActionSpinner(description)
|
|
723
|
+
self._spinner.start()
|
|
724
|
+
|
|
725
|
+
def action(self, name: str, target: str) -> int:
|
|
726
|
+
"""Add an action to current step.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
name: Action name
|
|
730
|
+
target: Action target
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Action index
|
|
734
|
+
"""
|
|
735
|
+
if self._spinner:
|
|
736
|
+
return self._spinner.add_action(name, target)
|
|
737
|
+
return -1
|
|
738
|
+
|
|
739
|
+
def complete_action(self, index: int) -> None:
|
|
740
|
+
"""Mark action as complete.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
index: Action index
|
|
744
|
+
"""
|
|
745
|
+
if self._spinner:
|
|
746
|
+
self._spinner.complete_action(index)
|
|
747
|
+
|
|
748
|
+
def fail_action(self, index: int, message: str = "") -> None:
|
|
749
|
+
"""Mark action as failed.
|
|
750
|
+
|
|
751
|
+
Args:
|
|
752
|
+
index: Action index
|
|
753
|
+
message: Error message
|
|
754
|
+
"""
|
|
755
|
+
if self._spinner:
|
|
756
|
+
self._spinner.fail_action(index, message)
|
|
757
|
+
|
|
758
|
+
def complete_step(self, message: str = "") -> None:
|
|
759
|
+
"""Complete current step.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
message: Optional completion message
|
|
763
|
+
"""
|
|
764
|
+
if self.current_step is not None:
|
|
765
|
+
self.steps[self.current_step]["state"] = ActionState.SUCCESS
|
|
766
|
+
self.steps[self.current_step]["end_time"] = time.time()
|
|
767
|
+
|
|
768
|
+
if self._spinner:
|
|
769
|
+
msg = message or f"Step {self.current_step + 1} complete"
|
|
770
|
+
self._spinner.success(msg)
|
|
771
|
+
self._spinner = None
|
|
772
|
+
|
|
773
|
+
def fail_step(self, message: str) -> None:
|
|
774
|
+
"""Fail current step.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
message: Error message
|
|
778
|
+
"""
|
|
779
|
+
if self.current_step is not None:
|
|
780
|
+
self.steps[self.current_step]["state"] = ActionState.FAILED
|
|
781
|
+
self.steps[self.current_step]["end_time"] = time.time()
|
|
782
|
+
|
|
783
|
+
if self._spinner:
|
|
784
|
+
self._spinner.failure(message)
|
|
785
|
+
self._spinner = None
|
|
786
|
+
|
|
787
|
+
def finish(self, message: str = "") -> None:
|
|
788
|
+
"""Finish task display.
|
|
789
|
+
|
|
790
|
+
Args:
|
|
791
|
+
message: Completion message
|
|
792
|
+
"""
|
|
793
|
+
# Stop any running spinner
|
|
794
|
+
if self._spinner and self._spinner.running:
|
|
795
|
+
self._spinner.stop()
|
|
796
|
+
|
|
797
|
+
duration = time.time() - self.start_time if self.start_time else 0
|
|
798
|
+
|
|
799
|
+
# Summary
|
|
800
|
+
success_count = sum(1 for s in self.steps if s["state"] == ActionState.SUCCESS)
|
|
801
|
+
failed_count = sum(1 for s in self.steps if s["state"] == ActionState.FAILED)
|
|
802
|
+
|
|
803
|
+
print(f"\n{Color.DIM}{'─' * 50}{Color.RESET}")
|
|
804
|
+
|
|
805
|
+
if failed_count == 0:
|
|
806
|
+
icon = f"{Color.GREEN}{Icons.SUCCESS}{Color.RESET}"
|
|
807
|
+
status_text = "Complete"
|
|
808
|
+
else:
|
|
809
|
+
icon = f"{Color.YELLOW}{Icons.WARNING}{Color.RESET}"
|
|
810
|
+
status_text = f"Completed with {failed_count} error(s)"
|
|
811
|
+
|
|
812
|
+
msg = message or f"{self.title} - {status_text}"
|
|
813
|
+
print(f"{icon} {msg}")
|
|
814
|
+
print(f"{Color.DIM} {success_count} steps completed in {duration:.1f}s{Color.RESET}\n")
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# =============================================================================
|
|
818
|
+
# Text Wrapping and Response Formatting
|
|
819
|
+
# =============================================================================
|
|
820
|
+
|
|
821
|
+
def get_terminal_width() -> int:
|
|
822
|
+
"""Get terminal width, with fallback to 80 columns.
|
|
823
|
+
|
|
824
|
+
Returns:
|
|
825
|
+
Terminal width in columns
|
|
826
|
+
"""
|
|
827
|
+
try:
|
|
828
|
+
size = shutil.get_terminal_size()
|
|
829
|
+
return size.columns
|
|
830
|
+
except Exception:
|
|
831
|
+
return 80
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def get_response_width(percentage: float = 0.75) -> int:
|
|
835
|
+
"""Get the width for response output (default 75% of terminal).
|
|
836
|
+
|
|
837
|
+
Args:
|
|
838
|
+
percentage: Fraction of terminal width to use (0.0 to 1.0)
|
|
839
|
+
|
|
840
|
+
Returns:
|
|
841
|
+
Width in columns for response text
|
|
842
|
+
"""
|
|
843
|
+
terminal_width = get_terminal_width()
|
|
844
|
+
width = int(terminal_width * percentage)
|
|
845
|
+
# Ensure minimum width of 40 and max of terminal width
|
|
846
|
+
return max(40, min(width, terminal_width))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def wrap_text(text: str, width: Optional[int] = None, indent: str = "") -> str:
|
|
850
|
+
"""Wrap text to specified width, preserving paragraphs and code blocks.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
text: Text to wrap
|
|
854
|
+
width: Maximum width (default: 75% of terminal)
|
|
855
|
+
indent: Prefix for each line
|
|
856
|
+
|
|
857
|
+
Returns:
|
|
858
|
+
Wrapped text string
|
|
859
|
+
"""
|
|
860
|
+
if width is None:
|
|
861
|
+
width = get_response_width()
|
|
862
|
+
|
|
863
|
+
# Adjust width for indent
|
|
864
|
+
effective_width = width - len(indent)
|
|
865
|
+
if effective_width < 20:
|
|
866
|
+
effective_width = 20
|
|
867
|
+
|
|
868
|
+
lines = []
|
|
869
|
+
in_code_block = False
|
|
870
|
+
code_block_content = []
|
|
871
|
+
|
|
872
|
+
for line in text.split('\n'):
|
|
873
|
+
# Check for code block markers
|
|
874
|
+
if line.strip().startswith('```'):
|
|
875
|
+
if in_code_block:
|
|
876
|
+
# End of code block - add as-is
|
|
877
|
+
code_block_content.append(line)
|
|
878
|
+
lines.extend(code_block_content)
|
|
879
|
+
code_block_content = []
|
|
880
|
+
in_code_block = False
|
|
881
|
+
else:
|
|
882
|
+
# Start of code block
|
|
883
|
+
in_code_block = True
|
|
884
|
+
code_block_content = [line]
|
|
885
|
+
continue
|
|
886
|
+
|
|
887
|
+
if in_code_block:
|
|
888
|
+
# Inside code block - don't wrap
|
|
889
|
+
code_block_content.append(line)
|
|
890
|
+
continue
|
|
891
|
+
|
|
892
|
+
# Empty line - preserve paragraph break
|
|
893
|
+
if not line.strip():
|
|
894
|
+
lines.append("")
|
|
895
|
+
continue
|
|
896
|
+
|
|
897
|
+
# Check for list items or special formatting to preserve
|
|
898
|
+
stripped = line.lstrip()
|
|
899
|
+
leading_spaces = len(line) - len(stripped)
|
|
900
|
+
|
|
901
|
+
# Preserve list items (- or * or numbered)
|
|
902
|
+
if stripped.startswith(('-', '*', '•')) or re.match(r'^\d+\.', stripped):
|
|
903
|
+
# Wrap list items with hanging indent
|
|
904
|
+
list_indent = ' ' * leading_spaces
|
|
905
|
+
# Find the marker and content
|
|
906
|
+
match = re.match(r'^([-*•]|\d+\.)\s*', stripped)
|
|
907
|
+
if match:
|
|
908
|
+
marker = match.group(0)
|
|
909
|
+
content = stripped[len(marker):]
|
|
910
|
+
subsequent_indent = list_indent + ' ' * len(marker)
|
|
911
|
+
wrapped = textwrap.fill(
|
|
912
|
+
content,
|
|
913
|
+
width=effective_width,
|
|
914
|
+
initial_indent=list_indent + marker,
|
|
915
|
+
subsequent_indent=subsequent_indent
|
|
916
|
+
)
|
|
917
|
+
lines.append(wrapped)
|
|
918
|
+
else:
|
|
919
|
+
lines.append(line)
|
|
920
|
+
continue
|
|
921
|
+
|
|
922
|
+
# Check for headers (## style)
|
|
923
|
+
if stripped.startswith('#'):
|
|
924
|
+
lines.append(line)
|
|
925
|
+
continue
|
|
926
|
+
|
|
927
|
+
# Regular paragraph - wrap with preserved leading indent
|
|
928
|
+
if leading_spaces > 0:
|
|
929
|
+
para_indent = ' ' * leading_spaces
|
|
930
|
+
wrapped = textwrap.fill(
|
|
931
|
+
stripped,
|
|
932
|
+
width=effective_width,
|
|
933
|
+
initial_indent=para_indent,
|
|
934
|
+
subsequent_indent=para_indent
|
|
935
|
+
)
|
|
936
|
+
else:
|
|
937
|
+
wrapped = textwrap.fill(stripped, width=effective_width)
|
|
938
|
+
|
|
939
|
+
lines.append(wrapped)
|
|
940
|
+
|
|
941
|
+
# Handle unclosed code block
|
|
942
|
+
if in_code_block:
|
|
943
|
+
lines.extend(code_block_content)
|
|
944
|
+
|
|
945
|
+
# Apply indent to all lines
|
|
946
|
+
if indent:
|
|
947
|
+
lines = [indent + line if line else line for line in lines]
|
|
948
|
+
|
|
949
|
+
return '\n'.join(lines)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def format_response(response: str, width_percentage: float = 0.75) -> str:
|
|
953
|
+
"""Format an AI response for clean terminal display.
|
|
954
|
+
|
|
955
|
+
This wraps text to 75% of terminal width (configurable) while:
|
|
956
|
+
- Preserving code blocks (``` ... ```)
|
|
957
|
+
- Preserving list formatting
|
|
958
|
+
- Preserving headers
|
|
959
|
+
- Maintaining paragraph breaks
|
|
960
|
+
|
|
961
|
+
Args:
|
|
962
|
+
response: The response text to format
|
|
963
|
+
width_percentage: Fraction of terminal width to use
|
|
964
|
+
|
|
965
|
+
Returns:
|
|
966
|
+
Formatted response string
|
|
967
|
+
"""
|
|
968
|
+
if not response:
|
|
969
|
+
return response
|
|
970
|
+
|
|
971
|
+
width = get_response_width(width_percentage)
|
|
972
|
+
return wrap_text(response, width)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def print_response(response: str, width_percentage: float = 0.75) -> None:
|
|
976
|
+
"""Print a formatted response with proper text wrapping.
|
|
977
|
+
|
|
978
|
+
The agent's response is visually distinct from user input with:
|
|
979
|
+
- A colored "NC1709" header
|
|
980
|
+
- Cyan-colored text
|
|
981
|
+
- Left border indicator
|
|
982
|
+
|
|
983
|
+
Args:
|
|
984
|
+
response: Response text to print
|
|
985
|
+
width_percentage: Fraction of terminal width to use
|
|
986
|
+
"""
|
|
987
|
+
# Account for border prefix "│ " (2 chars) in width calculation
|
|
988
|
+
# Reduce percentage slightly to ensure text + border fits within 75% of terminal
|
|
989
|
+
border_width = 2
|
|
990
|
+
terminal_width = get_terminal_width()
|
|
991
|
+
effective_percentage = (width_percentage * terminal_width - border_width) / terminal_width
|
|
992
|
+
|
|
993
|
+
formatted = format_response(response, effective_percentage)
|
|
994
|
+
|
|
995
|
+
# Add visual distinction for agent responses
|
|
996
|
+
# Header with robot icon and name
|
|
997
|
+
header = f"\n{Color.BOLD}{Color.CYAN}◆ NC1709{Color.RESET}"
|
|
998
|
+
|
|
999
|
+
# Add subtle left border by indenting and coloring
|
|
1000
|
+
lines = formatted.split('\n')
|
|
1001
|
+
bordered_lines = []
|
|
1002
|
+
for line in lines:
|
|
1003
|
+
# Use dim cyan vertical bar as left border + agent text in cyan
|
|
1004
|
+
bordered_lines.append(f"{Color.DIM}{Color.CYAN}│{Color.RESET} {Color.CYAN}{line}{Color.RESET}")
|
|
1005
|
+
|
|
1006
|
+
bordered_response = '\n'.join(bordered_lines)
|
|
1007
|
+
|
|
1008
|
+
print(header)
|
|
1009
|
+
print(bordered_response)
|
|
1010
|
+
print() # Extra line for spacing
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
# =============================================================================
|
|
1014
|
+
# Demo / Test
|
|
1015
|
+
# =============================================================================
|
|
1016
|
+
|
|
1017
|
+
def demo():
|
|
1018
|
+
"""Demonstrate CLI UI components."""
|
|
1019
|
+
print("\n" + "=" * 60)
|
|
1020
|
+
print("NC1709 CLI UI Demo")
|
|
1021
|
+
print("=" * 60 + "\n")
|
|
1022
|
+
|
|
1023
|
+
# Demo 1: Simple spinner
|
|
1024
|
+
print("Demo 1: Action Spinner")
|
|
1025
|
+
print("-" * 40)
|
|
1026
|
+
|
|
1027
|
+
with action_spinner("Analyzing your request") as spinner:
|
|
1028
|
+
time.sleep(0.5)
|
|
1029
|
+
spinner.update("Planning implementation steps")
|
|
1030
|
+
time.sleep(0.5)
|
|
1031
|
+
idx = spinner.add_action("Read", "main.py")
|
|
1032
|
+
time.sleep(0.3)
|
|
1033
|
+
spinner.complete_action(idx)
|
|
1034
|
+
idx = spinner.add_action("Write", "feature.py")
|
|
1035
|
+
time.sleep(0.3)
|
|
1036
|
+
spinner.complete_action(idx)
|
|
1037
|
+
time.sleep(0.2)
|
|
1038
|
+
spinner.success("Implementation complete")
|
|
1039
|
+
|
|
1040
|
+
print()
|
|
1041
|
+
|
|
1042
|
+
# Demo 2: Status messages
|
|
1043
|
+
print("Demo 2: Status Messages")
|
|
1044
|
+
print("-" * 40)
|
|
1045
|
+
thinking("Processing your request...")
|
|
1046
|
+
info("Found 5 relevant files")
|
|
1047
|
+
success("Analysis complete")
|
|
1048
|
+
warning("One file needs review")
|
|
1049
|
+
error("Failed to parse config.json")
|
|
1050
|
+
print()
|
|
1051
|
+
|
|
1052
|
+
# Demo 3: Task display
|
|
1053
|
+
print("Demo 3: Task Display")
|
|
1054
|
+
print("-" * 40)
|
|
1055
|
+
|
|
1056
|
+
task = TaskDisplay("Implementing authentication feature")
|
|
1057
|
+
task.start()
|
|
1058
|
+
|
|
1059
|
+
task.step("Analyzing requirements")
|
|
1060
|
+
idx = task.action("Read", "auth_spec.md")
|
|
1061
|
+
time.sleep(0.3)
|
|
1062
|
+
task.complete_action(idx)
|
|
1063
|
+
task.complete_step("Requirements analyzed")
|
|
1064
|
+
|
|
1065
|
+
task.step("Creating auth module")
|
|
1066
|
+
idx1 = task.action("Write", "auth/login.py")
|
|
1067
|
+
time.sleep(0.2)
|
|
1068
|
+
task.complete_action(idx1)
|
|
1069
|
+
idx2 = task.action("Write", "auth/logout.py")
|
|
1070
|
+
time.sleep(0.2)
|
|
1071
|
+
task.complete_action(idx2)
|
|
1072
|
+
task.complete_step("Auth module created")
|
|
1073
|
+
|
|
1074
|
+
task.finish()
|
|
1075
|
+
|
|
1076
|
+
print("Demo complete!")
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
if __name__ == "__main__":
|
|
1080
|
+
demo()
|