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.
Files changed (86) hide show
  1. nc1709/__init__.py +13 -0
  2. nc1709/agent/__init__.py +36 -0
  3. nc1709/agent/core.py +505 -0
  4. nc1709/agent/mcp_bridge.py +245 -0
  5. nc1709/agent/permissions.py +298 -0
  6. nc1709/agent/tools/__init__.py +21 -0
  7. nc1709/agent/tools/base.py +440 -0
  8. nc1709/agent/tools/bash_tool.py +367 -0
  9. nc1709/agent/tools/file_tools.py +454 -0
  10. nc1709/agent/tools/notebook_tools.py +516 -0
  11. nc1709/agent/tools/search_tools.py +322 -0
  12. nc1709/agent/tools/task_tool.py +284 -0
  13. nc1709/agent/tools/web_tools.py +555 -0
  14. nc1709/agents/__init__.py +17 -0
  15. nc1709/agents/auto_fix.py +506 -0
  16. nc1709/agents/test_generator.py +507 -0
  17. nc1709/checkpoints.py +372 -0
  18. nc1709/cli.py +3380 -0
  19. nc1709/cli_ui.py +1080 -0
  20. nc1709/cognitive/__init__.py +149 -0
  21. nc1709/cognitive/anticipation.py +594 -0
  22. nc1709/cognitive/context_engine.py +1046 -0
  23. nc1709/cognitive/council.py +824 -0
  24. nc1709/cognitive/learning.py +761 -0
  25. nc1709/cognitive/router.py +583 -0
  26. nc1709/cognitive/system.py +519 -0
  27. nc1709/config.py +155 -0
  28. nc1709/custom_commands.py +300 -0
  29. nc1709/executor.py +333 -0
  30. nc1709/file_controller.py +354 -0
  31. nc1709/git_integration.py +308 -0
  32. nc1709/github_integration.py +477 -0
  33. nc1709/image_input.py +446 -0
  34. nc1709/linting.py +519 -0
  35. nc1709/llm_adapter.py +667 -0
  36. nc1709/logger.py +192 -0
  37. nc1709/mcp/__init__.py +18 -0
  38. nc1709/mcp/client.py +370 -0
  39. nc1709/mcp/manager.py +407 -0
  40. nc1709/mcp/protocol.py +210 -0
  41. nc1709/mcp/server.py +473 -0
  42. nc1709/memory/__init__.py +20 -0
  43. nc1709/memory/embeddings.py +325 -0
  44. nc1709/memory/indexer.py +474 -0
  45. nc1709/memory/sessions.py +432 -0
  46. nc1709/memory/vector_store.py +451 -0
  47. nc1709/models/__init__.py +86 -0
  48. nc1709/models/detector.py +377 -0
  49. nc1709/models/formats.py +315 -0
  50. nc1709/models/manager.py +438 -0
  51. nc1709/models/registry.py +497 -0
  52. nc1709/performance/__init__.py +343 -0
  53. nc1709/performance/cache.py +705 -0
  54. nc1709/performance/pipeline.py +611 -0
  55. nc1709/performance/tiering.py +543 -0
  56. nc1709/plan_mode.py +362 -0
  57. nc1709/plugins/__init__.py +17 -0
  58. nc1709/plugins/agents/__init__.py +18 -0
  59. nc1709/plugins/agents/django_agent.py +912 -0
  60. nc1709/plugins/agents/docker_agent.py +623 -0
  61. nc1709/plugins/agents/fastapi_agent.py +887 -0
  62. nc1709/plugins/agents/git_agent.py +731 -0
  63. nc1709/plugins/agents/nextjs_agent.py +867 -0
  64. nc1709/plugins/base.py +359 -0
  65. nc1709/plugins/manager.py +411 -0
  66. nc1709/plugins/registry.py +337 -0
  67. nc1709/progress.py +443 -0
  68. nc1709/prompts/__init__.py +22 -0
  69. nc1709/prompts/agent_system.py +180 -0
  70. nc1709/prompts/task_prompts.py +340 -0
  71. nc1709/prompts/unified_prompt.py +133 -0
  72. nc1709/reasoning_engine.py +541 -0
  73. nc1709/remote_client.py +266 -0
  74. nc1709/shell_completions.py +349 -0
  75. nc1709/slash_commands.py +649 -0
  76. nc1709/task_classifier.py +408 -0
  77. nc1709/version_check.py +177 -0
  78. nc1709/web/__init__.py +8 -0
  79. nc1709/web/server.py +950 -0
  80. nc1709/web/templates/index.html +1127 -0
  81. nc1709-1.15.4.dist-info/METADATA +858 -0
  82. nc1709-1.15.4.dist-info/RECORD +86 -0
  83. nc1709-1.15.4.dist-info/WHEEL +5 -0
  84. nc1709-1.15.4.dist-info/entry_points.txt +2 -0
  85. nc1709-1.15.4.dist-info/licenses/LICENSE +9 -0
  86. 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()