deepagent-code 0.1.0__tar.gz → 0.1.2__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 langgraph-utils-cli contributors
3
+ Copyright (c) 2026 deepagent-code contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagent-code
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A Claude Code-style CLI for running LangGraph agents from the terminal
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dkedar7/deepagent-code
8
8
  Project-URL: Repository, https://github.com/dkedar7/deepagent-code
9
9
  Project-URL: Issues, https://github.com/dkedar7/deepagent-code/issues
10
10
  Keywords: langgraph,cli,agents,llm,ai,claude-code
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
@@ -8,16 +8,29 @@ import json
8
8
  import os
9
9
  import re
10
10
  import sys
11
- import termios
12
11
  import threading
13
12
  import time
14
- import tty
15
13
  import uuid
16
14
  from pathlib import Path
17
15
  from typing import Any, Dict, List, Optional, Tuple
18
16
 
17
+ # Platform-specific imports for keyboard input
18
+ IS_WINDOWS = sys.platform == "win32"
19
+ if IS_WINDOWS:
20
+ import msvcrt
21
+ else:
22
+ import termios
23
+ import tty
24
+
19
25
  import click
20
26
 
27
+ # Try to import readline for tab completion (not available on all platforms)
28
+ try:
29
+ import readline
30
+ HAS_READLINE = True
31
+ except ImportError:
32
+ HAS_READLINE = False
33
+
21
34
  from deepagent_code.utils import (
22
35
  prepare_agent_input,
23
36
  stream_graph_updates,
@@ -27,32 +40,134 @@ from deepagent_code.utils import (
27
40
 
28
41
  # ANSI color codes (matching nanocode style)
29
42
  RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
43
+ ITALIC, UNDERLINE = "\033[3m", "\033[4m"
30
44
  BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
45
+ MAGENTA, WHITE, GRAY = "\033[35m", "\033[37m", "\033[90m"
46
+
47
+ # Bright variants for gradient effects
48
+ BRIGHT_CYAN, BRIGHT_BLUE = "\033[96m", "\033[94m"
49
+ BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
31
50
 
32
51
  # Spinner frames for thinking animation
33
52
  SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
34
53
 
35
54
 
55
+ # Version info
56
+ __version__ = "0.1.2"
57
+
58
+
59
+ # Slash command registry
60
+ class SlashCommand:
61
+ """Represents a slash command with its handler and metadata."""
62
+
63
+ def __init__(
64
+ self,
65
+ name: str,
66
+ handler: callable,
67
+ description: str,
68
+ aliases: Optional[List[str]] = None,
69
+ usage: Optional[str] = None,
70
+ ):
71
+ self.name = name
72
+ self.handler = handler
73
+ self.description = description
74
+ self.aliases = aliases or []
75
+ self.usage = usage or f"/{name}"
76
+
77
+ def execute(self, args: str, context: Dict[str, Any]) -> Optional[str]:
78
+ """Execute the command with given arguments and context."""
79
+ return self.handler(args, context)
80
+
81
+
82
+ class CommandRegistry:
83
+ """Registry for slash commands."""
84
+
85
+ def __init__(self):
86
+ self._commands: Dict[str, SlashCommand] = {}
87
+ self._alias_map: Dict[str, str] = {}
88
+
89
+ def register(self, command: SlashCommand):
90
+ """Register a slash command."""
91
+ self._commands[command.name] = command
92
+ for alias in command.aliases:
93
+ self._alias_map[alias] = command.name
94
+
95
+ def get(self, name: str) -> Optional[SlashCommand]:
96
+ """Get a command by name or alias."""
97
+ # Check if it's an alias
98
+ if name in self._alias_map:
99
+ name = self._alias_map[name]
100
+ return self._commands.get(name)
101
+
102
+ def all_commands(self) -> List[SlashCommand]:
103
+ """Get all registered commands."""
104
+ return list(self._commands.values())
105
+
106
+ def parse_input(self, user_input: str) -> Tuple[Optional[str], str]:
107
+ """Parse user input to extract command name and arguments.
108
+
109
+ Returns:
110
+ Tuple of (command_name, arguments) or (None, original_input) if not a command
111
+ """
112
+ if not user_input.startswith("/"):
113
+ return None, user_input
114
+
115
+ # Split into command and args
116
+ parts = user_input[1:].split(maxsplit=1)
117
+ cmd_name = parts[0].lower() if parts else ""
118
+ args = parts[1] if len(parts) > 1 else ""
119
+
120
+ return cmd_name, args
121
+
122
+
123
+ # Global command registry
124
+ command_registry = CommandRegistry()
125
+
126
+
127
+ def register_command(
128
+ name: str,
129
+ description: str,
130
+ aliases: Optional[List[str]] = None,
131
+ usage: Optional[str] = None,
132
+ ):
133
+ """Decorator to register a slash command handler."""
134
+ def decorator(func):
135
+ command = SlashCommand(
136
+ name=name,
137
+ handler=func,
138
+ description=description,
139
+ aliases=aliases or [],
140
+ usage=usage,
141
+ )
142
+ command_registry.register(command)
143
+ return func
144
+ return decorator
145
+
146
+
36
147
  class Spinner:
37
- """A simple terminal spinner for showing activity."""
148
+ """A simple terminal spinner for showing activity with elapsed time."""
38
149
 
39
150
  def __init__(self, message: str = "Thinking"):
40
151
  self.message = message
41
152
  self.running = False
42
153
  self.thread = None
43
154
  self.frame_idx = 0
155
+ self.start_time = None
44
156
 
45
157
  def _spin(self):
46
- """Run the spinner animation."""
158
+ """Run the spinner animation with elapsed time display."""
47
159
  while self.running:
48
160
  frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)]
49
- print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}...{RESET}", end="", flush=True)
161
+ elapsed = time.time() - self.start_time
162
+ elapsed_str = f"{int(elapsed)}s"
163
+ print(f"\r{CYAN}{frame}{RESET} {DIM}{self.message}... {elapsed_str}{RESET}", end="", flush=True)
50
164
  self.frame_idx += 1
51
165
  time.sleep(0.08)
52
166
 
53
167
  def start(self):
54
168
  """Start the spinner."""
55
169
  self.running = True
170
+ self.start_time = time.time()
56
171
  self.thread = threading.Thread(target=self._spin, daemon=True)
57
172
  self.thread.start()
58
173
 
@@ -65,13 +180,44 @@ class Spinner:
65
180
  print("\r\033[2K", end="", flush=True)
66
181
 
67
182
 
68
- def separator() -> str:
69
- """Return a dim separator line."""
183
+ def get_terminal_width() -> int:
184
+ """Get terminal width, capped at 100 for readability."""
70
185
  try:
71
- width = min(os.get_terminal_size().columns, 80)
186
+ return min(os.get_terminal_size().columns, 100)
72
187
  except OSError:
73
- width = 80
74
- return f"{DIM}{'─' * width}{RESET}"
188
+ return 80
189
+
190
+
191
+ def separator(style: str = "light") -> str:
192
+ """Return a styled separator line.
193
+
194
+ Args:
195
+ style: 'light' for thin line, 'heavy' for thick line, 'dots' for dotted
196
+ """
197
+ width = get_terminal_width()
198
+ if style == "heavy":
199
+ return f"{DIM}{'━' * width}{RESET}"
200
+ elif style == "dots":
201
+ return f"{DIM}{'·' * width}{RESET}"
202
+ else:
203
+ return f"{DIM}{'─' * width}{RESET}"
204
+
205
+
206
+ def print_welcome():
207
+ """Print a welcome message with tips."""
208
+ tips = [
209
+ f"Type {CYAN}/help{RESET} for commands",
210
+ f"Use {CYAN}/c{RESET} to clear conversation",
211
+ f"Press {CYAN}Ctrl+C{RESET} to exit",
212
+ f"Press {CYAN}Tab{RESET} to autocomplete commands",
213
+ ]
214
+ tip = tips[int(time.time()) % len(tips)] # Rotate tips
215
+ print(f"\n{DIM}Tip: {tip}{RESET}\n")
216
+
217
+
218
+ def print_goodbye():
219
+ """Print a goodbye message."""
220
+ print(f"\n{DIM}Goodbye!{RESET}\n")
75
221
 
76
222
 
77
223
  def get_agent_name(graph) -> str:
@@ -91,11 +237,8 @@ def get_agent_name(graph) -> str:
91
237
 
92
238
 
93
239
  def print_header_box(agent_name: str, cwd: str):
94
- """Print a box-drawn header with the agent name."""
95
- try:
96
- term_width = min(os.get_terminal_size().columns, 80)
97
- except OSError:
98
- term_width = 80
240
+ """Print an elegant header with the agent name and version."""
241
+ term_width = get_terminal_width()
99
242
 
100
243
  # Box drawing characters
101
244
  TL, TR, BL, BR = "╭", "╮", "╰", "╯" # corners
@@ -109,17 +252,28 @@ def print_header_box(agent_name: str, cwd: str):
109
252
  cwd_display = cwd if len(cwd) <= inner_width else "..." + cwd[-(inner_width - 3):]
110
253
  cwd_line = cwd_display.center(inner_width)
111
254
 
112
- # Print the box
113
- print(f"{CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
114
- print(f"{CYAN}{V}{RESET} {BOLD}{title_line}{RESET} {CYAN}{V}{RESET}")
255
+ # Print the box with gradient-style coloring
256
+ print()
257
+ print(f"{BRIGHT_CYAN}{TL}{H * (term_width - 2)}{TR}{RESET}")
258
+ print(f"{BRIGHT_CYAN}{V}{RESET} {BOLD}{BRIGHT_CYAN}{title_line}{RESET} {BRIGHT_CYAN}{V}{RESET}")
115
259
  print(f"{CYAN}{V}{RESET} {DIM}{cwd_line}{RESET} {CYAN}{V}{RESET}")
116
260
  print(f"{CYAN}{BL}{H * (term_width - 2)}{BR}{RESET}")
117
- print()
118
261
 
119
262
 
120
263
  def render_markdown(text: str) -> str:
121
- """Simple markdown rendering for **bold** text."""
122
- return re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
264
+ """Render markdown formatting for terminal display.
265
+
266
+ Supports: **bold**, *italic*, `code`, [links](url)
267
+ """
268
+ # Bold: **text**
269
+ text = re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
270
+ # Italic: *text* (but not inside **)
271
+ text = re.sub(r"(?<!\*)\*([^*]+?)\*(?!\*)", f"{ITALIC}\\1{RESET}", text)
272
+ # Inline code: `code`
273
+ text = re.sub(r"`([^`]+?)`", f"{CYAN}\\1{RESET}", text)
274
+ # Links: [text](url) - show text in underline
275
+ text = re.sub(r"\[([^\]]+?)\]\([^)]+?\)", f"{UNDERLINE}\\1{RESET}", text)
276
+ return text
123
277
 
124
278
 
125
279
  def parse_agent_spec(agent_spec: str) -> Tuple[str, str]:
@@ -286,6 +440,27 @@ def format_result_preview(result: str) -> str:
286
440
  return preview
287
441
 
288
442
 
443
+ def format_duration(seconds: float) -> str:
444
+ """Format duration in human-readable format."""
445
+ if seconds < 1:
446
+ return f"{seconds * 1000:.0f}ms"
447
+ elif seconds < 60:
448
+ return f"{seconds:.1f}s"
449
+ else:
450
+ minutes = int(seconds // 60)
451
+ secs = seconds % 60
452
+ return f"{minutes}m {secs:.1f}s"
453
+
454
+
455
+ def print_timing(duration: float, verbose: bool = False):
456
+ """Print response timing information."""
457
+ formatted = format_duration(duration)
458
+ if verbose:
459
+ print(f"\n{DIM}Response time: {formatted}{RESET}")
460
+ else:
461
+ print(f"\n{DIM}{formatted}{RESET}")
462
+
463
+
289
464
  def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
290
465
  """
291
466
  Pretty print a chunk from the stream using Claude Code styling.
@@ -304,66 +479,87 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
304
479
  if verbose:
305
480
  print(f"{DIM}[{node}]{RESET} {text}", end="")
306
481
  else:
307
- # Print text output with cyan bullet (only on first chunk or after newline)
482
+ # Print text output with cyan bullet
308
483
  print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
309
484
 
310
- # Handle tool calls - green bullet with tool name
485
+ # Handle tool calls - green tool name
311
486
  elif "tool_calls" in chunk:
312
487
  for tool_call in chunk["tool_calls"]:
313
488
  tool_name = tool_call["name"]
314
489
  args = tool_call.get("args", {})
315
490
  arg_preview = get_tool_arg_preview(args)
316
491
 
317
- print(f"\n{GREEN} {tool_name.capitalize()}{RESET}({DIM}{arg_preview}{RESET})")
492
+ print(f"\n{GREEN} {tool_name}{RESET}")
493
+ if arg_preview:
494
+ print(f" {DIM}└─ {arg_preview}{RESET}")
318
495
 
319
496
  # Handle tool results - indented with result preview
320
497
  elif "tool_result" in chunk:
321
498
  result = chunk.get("tool_result", "")
322
499
  preview = format_result_preview(str(result))
323
- print(f" {DIM}{preview}{RESET}")
500
+ print(f" {DIM}{preview}{RESET}")
324
501
 
325
502
  elif status == "interrupt":
326
503
  interrupt_data = chunk.get("interrupt", {})
327
504
  action_requests = interrupt_data.get("action_requests", [])
328
505
 
329
- print(f"\n{YELLOW} Interrupt{RESET}")
506
+ print(f"\n{YELLOW} Action Required{RESET}")
330
507
  if action_requests:
331
508
  for i, action in enumerate(action_requests):
332
509
  tool = action.get('tool', 'unknown')
333
510
  args_preview = get_tool_arg_preview(action.get('args', {}))
334
- print(f" {DIM}{i + 1}. {tool}({args_preview}){RESET}")
511
+ print(f" {DIM}{i + 1}. {tool}{RESET}")
512
+ if args_preview:
513
+ print(f" {DIM}└─ {args_preview}{RESET}")
335
514
 
336
515
  elif status == "complete":
337
516
  pass # No output on complete (nanocode style)
338
517
 
339
518
  elif status == "error":
340
519
  error_msg = chunk.get("error", "Unknown error")
341
- print(f"\n{RED} Error: {error_msg}{RESET}")
520
+ print(f"\n{RED} Error: {error_msg}{RESET}")
342
521
 
343
522
 
344
523
  def get_key() -> str:
345
- """Read a single keypress from stdin."""
346
- fd = sys.stdin.fileno()
347
- old_settings = termios.tcgetattr(fd)
348
- try:
349
- tty.setraw(fd)
350
- ch = sys.stdin.read(1)
351
- # Handle escape sequences (arrow keys)
352
- if ch == '\x1b':
353
- ch2 = sys.stdin.read(1)
354
- if ch2 == '[':
355
- ch3 = sys.stdin.read(1)
356
- if ch3 == 'A':
357
- return 'up'
358
- elif ch3 == 'B':
359
- return 'down'
360
- elif ch == '\r' or ch == '\n':
524
+ """Read a single keypress from stdin (cross-platform)."""
525
+ if IS_WINDOWS:
526
+ # Windows implementation using msvcrt
527
+ ch = msvcrt.getch()
528
+ if ch in (b'\x00', b'\xe0'): # Special keys (arrows, function keys)
529
+ ch2 = msvcrt.getch()
530
+ if ch2 == b'H':
531
+ return 'up'
532
+ elif ch2 == b'P':
533
+ return 'down'
534
+ return ch2.decode('utf-8', errors='ignore')
535
+ elif ch == b'\r':
361
536
  return 'enter'
362
- elif ch == '\x03': # Ctrl+C
537
+ elif ch == b'\x03': # Ctrl+C
363
538
  return 'ctrl-c'
364
- return ch
365
- finally:
366
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
539
+ return ch.decode('utf-8', errors='ignore')
540
+ else:
541
+ # Unix implementation using termios/tty
542
+ fd = sys.stdin.fileno()
543
+ old_settings = termios.tcgetattr(fd)
544
+ try:
545
+ tty.setraw(fd)
546
+ ch = sys.stdin.read(1)
547
+ # Handle escape sequences (arrow keys)
548
+ if ch == '\x1b':
549
+ ch2 = sys.stdin.read(1)
550
+ if ch2 == '[':
551
+ ch3 = sys.stdin.read(1)
552
+ if ch3 == 'A':
553
+ return 'up'
554
+ elif ch3 == 'B':
555
+ return 'down'
556
+ elif ch == '\r' or ch == '\n':
557
+ return 'enter'
558
+ elif ch == '\x03': # Ctrl+C
559
+ return 'ctrl-c'
560
+ return ch
561
+ finally:
562
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
367
563
 
368
564
 
369
565
  def select_option(options: List[str], prompt: str = "Select an option:") -> int:
@@ -469,9 +665,10 @@ async def run_single_turn_async(
469
665
  interactive: bool = True,
470
666
  verbose: bool = False,
471
667
  stream_mode: str = "updates",
472
- ):
473
- """Run a single turn of an async LangGraph graph."""
668
+ ) -> float:
669
+ """Run a single turn of an async LangGraph graph. Returns total duration in seconds."""
474
670
  input_data = prepare_agent_input(message=message)
671
+ start_time = time.time()
475
672
 
476
673
  while True:
477
674
  has_interrupt = False
@@ -505,6 +702,8 @@ async def run_single_turn_async(
505
702
  else:
506
703
  break
507
704
 
705
+ return time.time() - start_time
706
+
508
707
 
509
708
  def run_single_turn_sync(
510
709
  graph,
@@ -513,9 +712,10 @@ def run_single_turn_sync(
513
712
  interactive: bool = True,
514
713
  verbose: bool = False,
515
714
  stream_mode: str = "updates",
516
- ):
517
- """Run a single turn of a sync LangGraph graph."""
715
+ ) -> float:
716
+ """Run a single turn of a sync LangGraph graph. Returns total duration in seconds."""
518
717
  input_data = prepare_agent_input(message=message)
718
+ start_time = time.time()
519
719
 
520
720
  while True:
521
721
  has_interrupt = False
@@ -549,6 +749,322 @@ def run_single_turn_sync(
549
749
  else:
550
750
  break
551
751
 
752
+ return time.time() - start_time
753
+
754
+
755
+ def print_help():
756
+ """Print formatted help information."""
757
+ print(f"\n{BOLD}{BRIGHT_CYAN}Commands{RESET}")
758
+ print(f"{DIM}{'─' * 40}{RESET}")
759
+
760
+ # Get all registered commands and display them
761
+ commands = command_registry.all_commands()
762
+ for cmd in sorted(commands, key=lambda c: c.name):
763
+ aliases_str = ""
764
+ if cmd.aliases:
765
+ aliases_str = f", {CYAN}/{RESET}, {CYAN}/".join([""] + cmd.aliases)[4:]
766
+ print(f" {CYAN}/{cmd.name}{RESET}{aliases_str}")
767
+ print(f" {DIM}{cmd.description}{RESET}")
768
+
769
+ print()
770
+ print(f"{BOLD}{BRIGHT_CYAN}Shortcuts{RESET}")
771
+ print(f"{DIM}{'─' * 40}{RESET}")
772
+ print(f" {CYAN}Tab{RESET} Autocomplete commands")
773
+ print(f" {CYAN}Ctrl+C{RESET} Exit at any time")
774
+ print(f" {CYAN}↑/↓{RESET} Navigate options")
775
+ print()
776
+
777
+
778
+ # --- Built-in Slash Commands ---
779
+
780
+ @register_command(
781
+ name="help",
782
+ description="Show this help message",
783
+ aliases=["h", "?"],
784
+ )
785
+ def cmd_help(args: str, context: Dict[str, Any]) -> Optional[str]:
786
+ """Display help information."""
787
+ if args:
788
+ # Show help for a specific command
789
+ cmd = command_registry.get(args)
790
+ if cmd:
791
+ print(f"\n{BOLD}{BRIGHT_CYAN}/{cmd.name}{RESET}")
792
+ print(f" {cmd.description}")
793
+ if cmd.aliases:
794
+ print(f" {DIM}Aliases: /{', /'.join(cmd.aliases)}{RESET}")
795
+ if cmd.usage:
796
+ print(f" {DIM}Usage: {cmd.usage}{RESET}")
797
+ print()
798
+ else:
799
+ print(f"{YELLOW}Unknown command: /{args}{RESET}")
800
+ else:
801
+ print_help()
802
+ return None
803
+
804
+
805
+ @register_command(
806
+ name="quit",
807
+ description="Exit the CLI",
808
+ aliases=["q", "exit"],
809
+ )
810
+ def cmd_quit(args: str, context: Dict[str, Any]) -> Optional[str]:
811
+ """Exit the CLI."""
812
+ return "exit" # Special return value to signal exit
813
+
814
+
815
+ @register_command(
816
+ name="clear",
817
+ description="Clear conversation history",
818
+ aliases=["c"],
819
+ )
820
+ def cmd_clear(args: str, context: Dict[str, Any]) -> Optional[str]:
821
+ """Clear the conversation history."""
822
+ context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
823
+ print(f"\n{GREEN}✓ Conversation cleared{RESET}\n")
824
+ return None
825
+
826
+
827
+ @register_command(
828
+ name="version",
829
+ description="Show version information",
830
+ aliases=["v"],
831
+ )
832
+ def cmd_version(args: str, context: Dict[str, Any]) -> Optional[str]:
833
+ """Display version information."""
834
+ print(f"\n{BOLD}{BRIGHT_CYAN}deepagent-code{RESET} v{__version__}")
835
+ agent_name = context.get("agent_name", "Unknown")
836
+ print(f"{DIM}Agent: {agent_name}{RESET}\n")
837
+ return None
838
+
839
+
840
+ @register_command(
841
+ name="status",
842
+ description="Show current session status",
843
+ aliases=["s"],
844
+ )
845
+ def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]:
846
+ """Display current session status."""
847
+ config = context.get("config", {})
848
+ thread_id = config.get("configurable", {}).get("thread_id", "N/A")
849
+ agent_name = context.get("agent_name", "Unknown")
850
+ verbose = context.get("verbose", False)
851
+ use_async = context.get("use_async", False)
852
+ stream_mode = context.get("stream_mode", "updates")
853
+
854
+ print(f"\n{BOLD}{BRIGHT_CYAN}Session Status{RESET}")
855
+ print(f"{DIM}{'─' * 30}{RESET}")
856
+ print(f" {DIM}Agent:{RESET} {agent_name}")
857
+ print(f" {DIM}Thread ID:{RESET} {thread_id[:8]}...")
858
+ print(f" {DIM}Mode:{RESET} {'async' if use_async else 'sync'}")
859
+ print(f" {DIM}Stream:{RESET} {stream_mode}")
860
+ print(f" {DIM}Verbose:{RESET} {'on' if verbose else 'off'}")
861
+ print(f" {DIM}CWD:{RESET} {os.getcwd()}")
862
+ print()
863
+ return None
864
+
865
+
866
+ @register_command(
867
+ name="config",
868
+ description="Show or set configuration",
869
+ aliases=["cfg"],
870
+ usage="/config [key] [value]",
871
+ )
872
+ def cmd_config(args: str, context: Dict[str, Any]) -> Optional[str]:
873
+ """Show or modify configuration."""
874
+ config = context.get("config", {})
875
+
876
+ if not args:
877
+ # Show current config
878
+ print(f"\n{BOLD}{BRIGHT_CYAN}Configuration{RESET}")
879
+ print(f"{DIM}{'─' * 30}{RESET}")
880
+ for key, value in config.items():
881
+ if isinstance(value, dict):
882
+ print(f" {CYAN}{key}:{RESET}")
883
+ for k, v in value.items():
884
+ # Truncate long values
885
+ v_str = str(v)
886
+ if len(v_str) > 30:
887
+ v_str = v_str[:30] + "..."
888
+ print(f" {DIM}{k}:{RESET} {v_str}")
889
+ else:
890
+ print(f" {CYAN}{key}:{RESET} {value}")
891
+ print()
892
+ else:
893
+ parts = args.split(maxsplit=1)
894
+ if len(parts) == 1:
895
+ # Show specific config key
896
+ key = parts[0]
897
+ if key in config:
898
+ print(f"\n{CYAN}{key}:{RESET} {config[key]}\n")
899
+ elif "configurable" in config and key in config["configurable"]:
900
+ print(f"\n{CYAN}{key}:{RESET} {config['configurable'][key]}\n")
901
+ else:
902
+ print(f"{YELLOW}Unknown config key: {key}{RESET}")
903
+ else:
904
+ # Set config value
905
+ key, value = parts
906
+ if key == "verbose":
907
+ context["verbose"] = value.lower() in ("true", "1", "on", "yes")
908
+ print(f"{GREEN}✓ Set verbose = {context['verbose']}{RESET}")
909
+ else:
910
+ print(f"{YELLOW}Cannot modify {key} at runtime{RESET}")
911
+ return None
912
+
913
+
914
+ @register_command(
915
+ name="history",
916
+ description="Show recent messages (if available)",
917
+ aliases=["hist"],
918
+ )
919
+ def cmd_history(args: str, context: Dict[str, Any]) -> Optional[str]:
920
+ """Display conversation history if available."""
921
+ graph = context.get("graph")
922
+ config = context.get("config", {})
923
+
924
+ if graph is None:
925
+ print(f"{YELLOW}No graph available{RESET}")
926
+ return None
927
+
928
+ try:
929
+ # Try to get state from the graph's checkpointer
930
+ if hasattr(graph, "get_state"):
931
+ state = graph.get_state(config)
932
+ if state and hasattr(state, "values"):
933
+ messages = state.values.get("messages", [])
934
+ if messages:
935
+ print(f"\n{BOLD}{BRIGHT_CYAN}Conversation History{RESET}")
936
+ print(f"{DIM}{'─' * 40}{RESET}")
937
+
938
+ # Show last N messages
939
+ limit = 10
940
+ if args:
941
+ try:
942
+ limit = int(args)
943
+ except ValueError:
944
+ pass
945
+
946
+ for msg in messages[-limit:]:
947
+ role = getattr(msg, "type", "unknown")
948
+ content = getattr(msg, "content", str(msg))
949
+
950
+ if role == "human":
951
+ print(f"\n {BRIGHT_BLUE}You:{RESET}")
952
+ elif role == "ai":
953
+ print(f"\n {BRIGHT_CYAN}Agent:{RESET}")
954
+ else:
955
+ print(f"\n {DIM}{role}:{RESET}")
956
+
957
+ # Truncate long content
958
+ if len(content) > 200:
959
+ content = content[:200] + "..."
960
+ print(f" {DIM}{content}{RESET}")
961
+ print()
962
+ else:
963
+ print(f"{DIM}No messages in history{RESET}")
964
+ else:
965
+ print(f"{DIM}No state available{RESET}")
966
+ else:
967
+ print(f"{DIM}Graph does not support state retrieval{RESET}")
968
+ except Exception as e:
969
+ print(f"{DIM}Could not retrieve history: {e}{RESET}")
970
+
971
+ return None
972
+
973
+
974
+ @register_command(
975
+ name="reset",
976
+ description="Reset the session (clear history and restart)",
977
+ aliases=["restart"],
978
+ )
979
+ def cmd_reset(args: str, context: Dict[str, Any]) -> Optional[str]:
980
+ """Reset the session."""
981
+ context["config"]["configurable"]["thread_id"] = str(uuid.uuid4())
982
+ print(f"\n{GREEN}✓ Session reset{RESET}")
983
+ print(f"{DIM}New thread ID: {context['config']['configurable']['thread_id'][:8]}...{RESET}\n")
984
+ return None
985
+
986
+
987
+ @register_command(
988
+ name="verbose",
989
+ description="Toggle verbose output mode",
990
+ usage="/verbose [on|off]",
991
+ )
992
+ def cmd_verbose(args: str, context: Dict[str, Any]) -> Optional[str]:
993
+ """Toggle or show verbose output mode."""
994
+ verbose = context.get("verbose", False)
995
+ if args:
996
+ if args.lower() in ("on", "true", "1"):
997
+ context["verbose"] = True
998
+ print(f"{GREEN}✓ Verbose mode enabled{RESET}")
999
+ elif args.lower() in ("off", "false", "0"):
1000
+ context["verbose"] = False
1001
+ print(f"{GREEN}✓ Verbose mode disabled{RESET}")
1002
+ else:
1003
+ print(f"{DIM}Verbose mode: {'on' if verbose else 'off'}{RESET}")
1004
+ print(f"{DIM}Use /verbose on or /verbose off to change{RESET}")
1005
+ return None
1006
+
1007
+
1008
+ def get_command_suggestions(partial: str) -> List[str]:
1009
+ """Get command suggestions based on partial input.
1010
+
1011
+ Args:
1012
+ partial: Partial command name (without leading /)
1013
+
1014
+ Returns:
1015
+ List of matching command names
1016
+ """
1017
+ partial_lower = partial.lower()
1018
+ suggestions = []
1019
+
1020
+ for cmd in command_registry.all_commands():
1021
+ # Check main command name
1022
+ if cmd.name.startswith(partial_lower):
1023
+ suggestions.append(cmd.name)
1024
+ # Check aliases
1025
+ for alias in cmd.aliases:
1026
+ if alias.startswith(partial_lower) and cmd.name not in suggestions:
1027
+ suggestions.append(cmd.name)
1028
+
1029
+ return sorted(suggestions)
1030
+
1031
+
1032
+ def command_completer(text: str, state: int) -> Optional[str]:
1033
+ """Readline completer for slash commands.
1034
+
1035
+ Args:
1036
+ text: Current text being completed
1037
+ state: State index for multiple completions
1038
+
1039
+ Returns:
1040
+ Next completion or None
1041
+ """
1042
+ # Only complete if starting with /
1043
+ if not text.startswith("/"):
1044
+ return None
1045
+
1046
+ partial = text[1:] # Remove leading /
1047
+ suggestions = ["/" + s for s in get_command_suggestions(partial)]
1048
+
1049
+ if state < len(suggestions):
1050
+ return suggestions[state]
1051
+ return None
1052
+
1053
+
1054
+ def setup_readline_completion():
1055
+ """Set up readline for tab completion of slash commands."""
1056
+ if not HAS_READLINE:
1057
+ return
1058
+
1059
+ readline.set_completer(command_completer)
1060
+ readline.set_completer_delims(" \t\n")
1061
+
1062
+ # Use tab for completion
1063
+ if sys.platform == "darwin":
1064
+ readline.parse_and_bind("bind ^I rl_complete")
1065
+ else:
1066
+ readline.parse_and_bind("tab: complete")
1067
+
552
1068
 
553
1069
  def run_conversation_loop(
554
1070
  graph,
@@ -564,63 +1080,96 @@ def run_conversation_loop(
564
1080
  Run a continuous conversation loop with the LangGraph agent.
565
1081
  Styled after Claude Code / nanocode.
566
1082
  """
1083
+ # Set up tab completion for slash commands
1084
+ setup_readline_completion()
1085
+
567
1086
  # Print box-drawn header with agent name
568
1087
  print_header_box(agent_name, os.getcwd())
569
1088
 
1089
+ # Print welcome message with tips
1090
+ print_welcome()
1091
+
1092
+ # Create command context (mutable dict that commands can modify)
1093
+ command_context = {
1094
+ "graph": graph,
1095
+ "config": config,
1096
+ "agent_name": agent_name,
1097
+ "use_async": use_async,
1098
+ "interactive": interactive,
1099
+ "verbose": verbose,
1100
+ "stream_mode": stream_mode,
1101
+ }
1102
+
570
1103
  # Process initial message if provided
571
1104
  if initial_message:
572
- print(separator())
573
- print(f"{BOLD}{BLUE}❯{RESET} {initial_message}")
574
- print(separator())
1105
+ print(f"\n{BOLD}{BRIGHT_BLUE}You{RESET}")
1106
+ print(f"{initial_message}")
1107
+ print()
575
1108
 
576
1109
  if use_async:
577
- asyncio.run(
1110
+ duration = asyncio.run(
578
1111
  run_single_turn_async(graph, initial_message, config, interactive, verbose, stream_mode)
579
1112
  )
580
1113
  else:
581
- run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
1114
+ duration = run_single_turn_sync(graph, initial_message, config, interactive, verbose, stream_mode)
1115
+ print_timing(duration, verbose)
582
1116
  print()
583
1117
 
584
1118
  # Main conversation loop
585
1119
  while True:
586
1120
  try:
587
- print(separator())
588
- user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
589
- print(separator())
1121
+ print(separator("dots"))
1122
+ user_input = input(f"{BOLD}{BRIGHT_BLUE}❯{RESET} ").strip()
590
1123
 
591
1124
  if not user_input:
592
1125
  continue
593
1126
 
594
- # Handle special commands
595
- if user_input in ("/q", "/quit", "/exit", "exit"):
596
- break
597
-
598
- if user_input == "/c":
599
- # Generate new thread_id to start fresh conversation
600
- config["configurable"]["thread_id"] = str(uuid.uuid4())
601
- print(f"{GREEN}⏺ Cleared conversation{RESET}")
1127
+ # Check if it's a slash command
1128
+ cmd_name, cmd_args = command_registry.parse_input(user_input)
1129
+
1130
+ if cmd_name is not None:
1131
+ # It's a slash command
1132
+ cmd = command_registry.get(cmd_name)
1133
+ if cmd:
1134
+ result = cmd.execute(cmd_args, command_context)
1135
+ # Update local vars from context (commands may modify these)
1136
+ verbose = command_context.get("verbose", verbose)
1137
+ if result == "exit":
1138
+ break
1139
+ else:
1140
+ # Show suggestions for unknown commands
1141
+ suggestions = get_command_suggestions(cmd_name)
1142
+ print(f"{YELLOW}Unknown command: /{cmd_name}{RESET}")
1143
+ if suggestions:
1144
+ suggestion_str = ", ".join([f"/{s}" for s in suggestions[:3]])
1145
+ print(f"{DIM}Did you mean: {suggestion_str}?{RESET}")
1146
+ else:
1147
+ print(f"{DIM}Type /help to see available commands{RESET}")
602
1148
  continue
603
1149
 
604
- if user_input in ("/h", "/help"):
605
- print(f"\n{BOLD}Commands:{RESET}")
606
- print(f" /q, /quit, exit - Exit")
607
- print(f" /c - Clear conversation")
608
- print(f" /h, /help - Show this help\n")
609
- continue
1150
+ # Handle "exit" as a special case (without slash)
1151
+ if user_input.lower() == "exit":
1152
+ break
1153
+
1154
+ print() # Space before response
610
1155
 
611
1156
  # Run the agent
612
1157
  if use_async:
613
- asyncio.run(
1158
+ duration = asyncio.run(
614
1159
  run_single_turn_async(graph, user_input, config, interactive, verbose, stream_mode)
615
1160
  )
616
1161
  else:
617
- run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
1162
+ duration = run_single_turn_sync(graph, user_input, config, interactive, verbose, stream_mode)
1163
+ print_timing(duration, verbose)
618
1164
  print()
619
1165
 
620
1166
  except (EOFError, KeyboardInterrupt):
621
1167
  break
622
1168
  except Exception as err:
623
- print(f"{RED} Error: {err}{RESET}")
1169
+ print(f"\n{RED} Error: {err}{RESET}\n")
1170
+
1171
+ # Print goodbye message
1172
+ print_goodbye()
624
1173
 
625
1174
 
626
1175
  @click.command()
@@ -728,9 +1277,12 @@ def main(
728
1277
  if workspace_path.exists():
729
1278
  os.chdir(workspace_path)
730
1279
 
731
- # Load the graph
732
- print(f"{DIM}Loading {final_spec}...{RESET}")
1280
+ # Load the graph with a spinner
1281
+ spinner = Spinner("Loading agent")
1282
+ spinner.start()
733
1283
  graph, final_graph_name = load_graph(final_spec, default_graph_name)
1284
+ spinner.stop()
1285
+ print(f"{GREEN}✓{RESET} {DIM}Loaded {final_spec}{RESET}")
734
1286
 
735
1287
  # Parse config
736
1288
  config_dict = None
@@ -1,16 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepagent-code
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A Claude Code-style CLI for running LangGraph agents from the terminal
5
5
  Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/dkedar7/deepagent-code
8
8
  Project-URL: Repository, https://github.com/dkedar7/deepagent-code
9
9
  Project-URL: Issues, https://github.com/dkedar7/deepagent-code/issues
10
10
  Keywords: langgraph,cli,agents,llm,ai,claude-code
11
11
  Classifier: Development Status :: 3 - Alpha
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepagent-code"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "A Claude Code-style CLI for running LangGraph agents from the terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
- license = {text = "MIT"}
11
+ license = "MIT"
12
12
  authors = [
13
13
  {name = "Kedar Dabhadkar", email = "kdabhadk@gmail.com"}
14
14
  ]
@@ -16,7 +16,6 @@ keywords = ["langgraph", "cli", "agents", "llm", "ai", "claude-code"]
16
16
  classifiers = [
17
17
  "Development Status :: 3 - Alpha",
18
18
  "Intended Audience :: Developers",
19
- "License :: OSI Approved :: MIT License",
20
19
  "Programming Language :: Python :: 3",
21
20
  "Programming Language :: Python :: 3.11",
22
21
  "Programming Language :: Python :: 3.12",
File without changes
File without changes