Last active 1 month ago

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 13 insertions, 5 deletions

nano.py

@@ -21,11 +21,19 @@ from datetime import datetime
21 21
22 22 OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
23 23 LOCAL_API_KEY = os.environ.get("LOCAL_API_KEY")
24 - API_URL = (
25 - "http://127.0.0.1:8990/v1/messages" if LOCAL_API_KEY
26 - else "https://openrouter.ai/api/v1/messages" if OPENROUTER_KEY
27 - else "https://api.anthropic.com/v1/messages"
28 - )
24 + API_BASE_URL = os.environ.get("API_BASE_URL") # e.g., http://127.0.0.1:8990
25 +
26 + # Build API URL
27 + if API_BASE_URL:
28 + # Use custom base URL
29 + API_URL = f"{API_BASE_URL.rstrip('/')}/v1/messages"
30 + elif LOCAL_API_KEY:
31 + API_URL = "http://127.0.0.1:8990/v1/messages"
32 + elif OPENROUTER_KEY:
33 + API_URL = "https://openrouter.ai/api/v1/messages"
34 + else:
35 + API_URL = "https://api.anthropic.com/v1/messages"
36 +
29 37 MODEL = os.environ.get("MODEL",
30 38 "anthropic/claude-sonnet-4.5" if LOCAL_API_KEY
31 39 else "anthropic/claude-opus-4.5" if OPENROUTER_KEY

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 35 insertions, 16 deletions

nano.py

@@ -59,7 +59,7 @@ def detect_clipboard_image():
59 59 # Try pngpaste first (faster)
60 60 result = subprocess.run(
61 61 ['pngpaste', temp_file.name],
62 - capture_output=True, timeout=2
62 + capture_output=True, timeout=5 # Increase timeout for large images
63 63 )
64 64
65 65 if result.returncode != 0:
@@ -72,18 +72,22 @@ def detect_clipboard_image():
72 72 write theImage to fileRef
73 73 close access fileRef
74 74 return "success"
75 - on error
76 - return "error"
75 + on error errMsg
76 + return "error: " & errMsg
77 77 end try
78 78 '''
79 79 result = subprocess.run(
80 80 ['osascript', '-e', script],
81 - capture_output=True, timeout=2, text=True
81 + capture_output=True, timeout=5, text=True
82 82 )
83 - if result.stdout.strip() != 'success':
83 + if not result.stdout.strip().startswith('success'):
84 + print(f"{RED}✗ osascript error: {result.stdout.strip()}{RESET}", flush=True)
84 85 os.unlink(temp_file.name)
85 86 return None
86 87
88 + # Check file size
89 + file_size = os.path.getsize(temp_file.name)
90 +
87 91 # Read the file
88 92 with open(temp_file.name, 'rb') as f:
89 93 image_data = f.read()
@@ -93,7 +97,7 @@ def detect_clipboard_image():
93 97 # Linux: use xclip
94 98 result = subprocess.run(
95 99 ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'],
96 - capture_output=True, timeout=2
100 + capture_output=True, timeout=5
97 101 )
98 102 if result.returncode == 0 and result.stdout:
99 103 image_data = result.stdout
@@ -117,10 +121,11 @@ def detect_clipboard_image():
117 121 '''
118 122 result = subprocess.run(
119 123 ['powershell', '-Command', ps_script],
120 - capture_output=True, timeout=2
124 + capture_output=True, timeout=5
121 125 )
122 126
123 127 if result.returncode == 0:
128 + file_size = os.path.getsize(temp_file.name)
124 129 with open(temp_file.name, 'rb') as f:
125 130 image_data = f.read()
126 131 os.unlink(temp_file.name)
@@ -142,14 +147,17 @@ def detect_clipboard_image():
142 147 else:
143 148 size = (0, 0)
144 149
145 - # Check if resize needed (Claude API limit: 1568x1568)
150 + # Check if too large
146 151 max_size = 1568
147 - needs_resize = size[0] > max_size or size[1] > max_size
148 -
149 - if needs_resize:
150 - # Need PIL for resize, skip for now
151 - # User can manually resize before pasting
152 - pass
152 + if size[0] > max_size or size[1] > max_size:
153 + print(f"\r{YELLOW}⚠ Image {size[0]}x{size[1]} exceeds {max_size}x{max_size}, may be slow{RESET}", flush=True)
154 + import time
155 + time.sleep(1)
156 +
157 + # Check file size limit (5MB for Claude API)
158 + if len(image_data) > 5 * 1024 * 1024:
159 + print(f"{RED}✗ Image too large: {len(image_data)/1024/1024:.2f} MB (max 5MB){RESET}", flush=True)
160 + return None
153 161
154 162 # Encode to base64
155 163 base64_data = base64.b64encode(image_data).decode('utf-8')
@@ -166,7 +174,12 @@ def detect_clipboard_image():
166 174 "resized": None
167 175 }
168 176 }
169 - except Exception:
177 + except subprocess.TimeoutExpired as e:
178 + print(f"\r{RED}✗ Timeout: Image too large or system command hung{RESET}", flush=True)
179 + import time
180 + time.sleep(1)
181 + return None
182 + except Exception as e:
170 183 return None
171 184
172 185 def create_opener():
@@ -703,6 +716,12 @@ def read_multiline_input(prefill=""):
703 716 old_settings = termios.tcgetattr(sys.stdin)
704 717 try:
705 718 tty.setcbreak(sys.stdin.fileno())
719 +
720 + # Disable LNEXT (Ctrl+V) so it can be used for paste
721 + attrs = termios.tcgetattr(sys.stdin)
722 + attrs[6][termios.VLNEXT] = 0 # Disable literal-next
723 + termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
724 +
706 725 print(f"{BOLD}{BLUE}❯{RESET} {current}", end="", flush=True)
707 726
708 727 while True:
@@ -743,7 +762,7 @@ def read_multiline_input(prefill=""):
743 762 print(f"\r\033[K{prefix}{current}", end="", flush=True)
744 763 else:
745 764 # No image in clipboard
746 - print(f"\r{YELLOW}⚠ No image in clipboard{RESET}")
765 + print(f"\r{YELLOW}⚠ No image in clipboard{RESET}", flush=True)
747 766 import time
748 767 time.sleep(1)
749 768 # Redraw current line

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 199 insertions, 5 deletions

nano.py

@@ -37,6 +37,138 @@ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
37 37 BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
38 38 stop_flag = False
39 39
40 + def detect_clipboard_image():
41 + """Detect and return image from clipboard using system commands
42 +
43 + Returns:
44 + dict or None: Image content block with metadata, or None if no image
45 + """
46 + try:
47 + import base64
48 + import tempfile
49 +
50 + # Save clipboard image to temp file
51 + temp_file = None
52 + image_data = None
53 +
54 + if sys.platform == 'darwin':
55 + # macOS: use pngpaste or osascript
56 + temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
57 + temp_file.close()
58 +
59 + # Try pngpaste first (faster)
60 + result = subprocess.run(
61 + ['pngpaste', temp_file.name],
62 + capture_output=True, timeout=2
63 + )
64 +
65 + if result.returncode != 0:
66 + # Fallback to osascript
67 + script = f'''
68 + set theFile to POSIX file "{temp_file.name}"
69 + try
70 + set theImage to the clipboard as «class PNGf»
71 + set fileRef to open for access theFile with write permission
72 + write theImage to fileRef
73 + close access fileRef
74 + return "success"
75 + on error
76 + return "error"
77 + end try
78 + '''
79 + result = subprocess.run(
80 + ['osascript', '-e', script],
81 + capture_output=True, timeout=2, text=True
82 + )
83 + if result.stdout.strip() != 'success':
84 + os.unlink(temp_file.name)
85 + return None
86 +
87 + # Read the file
88 + with open(temp_file.name, 'rb') as f:
89 + image_data = f.read()
90 + os.unlink(temp_file.name)
91 +
92 + elif sys.platform.startswith('linux'):
93 + # Linux: use xclip
94 + result = subprocess.run(
95 + ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'],
96 + capture_output=True, timeout=2
97 + )
98 + if result.returncode == 0 and result.stdout:
99 + image_data = result.stdout
100 + else:
101 + return None
102 +
103 + elif sys.platform == 'win32':
104 + # Windows: use PowerShell
105 + temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
106 + temp_file.close()
107 +
108 + ps_script = f'''
109 + Add-Type -AssemblyName System.Windows.Forms
110 + $img = [System.Windows.Forms.Clipboard]::GetImage()
111 + if ($img) {{
112 + $img.Save("{temp_file.name}", [System.Drawing.Imaging.ImageFormat]::Png)
113 + exit 0
114 + }} else {{
115 + exit 1
116 + }}
117 + '''
118 + result = subprocess.run(
119 + ['powershell', '-Command', ps_script],
120 + capture_output=True, timeout=2
121 + )
122 +
123 + if result.returncode == 0:
124 + with open(temp_file.name, 'rb') as f:
125 + image_data = f.read()
126 + os.unlink(temp_file.name)
127 + else:
128 + os.unlink(temp_file.name)
129 + return None
130 + else:
131 + return None
132 +
133 + if not image_data:
134 + return None
135 +
136 + # Get image size (read PNG header)
137 + # PNG signature: 8 bytes, then IHDR chunk with width/height
138 + if len(image_data) > 24 and image_data[:8] == b'\x89PNG\r\n\x1a\n':
139 + width = int.from_bytes(image_data[16:20], 'big')
140 + height = int.from_bytes(image_data[20:24], 'big')
141 + size = (width, height)
142 + else:
143 + size = (0, 0)
144 +
145 + # Check if resize needed (Claude API limit: 1568x1568)
146 + max_size = 1568
147 + needs_resize = size[0] > max_size or size[1] > max_size
148 +
149 + if needs_resize:
150 + # Need PIL for resize, skip for now
151 + # User can manually resize before pasting
152 + pass
153 +
154 + # Encode to base64
155 + base64_data = base64.b64encode(image_data).decode('utf-8')
156 +
157 + return {
158 + "type": "image",
159 + "source": {
160 + "type": "base64",
161 + "media_type": "image/png",
162 + "data": base64_data
163 + },
164 + "_metadata": {
165 + "size": size,
166 + "resized": None
167 + }
168 + }
169 + except Exception:
170 + return None
171 +
40 172 def create_opener():
41 173 """Create URL opener with SSL and proxy support"""
42 174 proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
@@ -552,13 +684,18 @@ def is_file_in_project(filepath, project_path):
552 684
553 685 def read_multiline_input(prefill=""):
554 686 """Read multiline input. Enter to submit, Alt+Enter for newline.
687 + Supports pasting images from clipboard.
555 688
556 689 Args:
557 690 prefill: Text to prefill in the input box
691 +
692 + Returns:
693 + tuple: (text: str, images: list[dict])
558 694 """
559 695 lines = []
560 696 current = prefill
561 697 cursor_pos = len(prefill) # Cursor at end of prefill text
698 + images = [] # Store pasted images
562 699
563 700 # Enable bracketed paste mode
564 701 print("\033[?2004h", end="", flush=True)
@@ -582,6 +719,39 @@ def read_multiline_input(prefill=""):
582 719 if ch == '\x05': # Ctrl+E
583 720 raise EOFError
584 721
722 + if ch == '\x16': # Ctrl+V - paste image
723 + img_block = detect_clipboard_image()
724 +
725 + if img_block:
726 + # Image detected!
727 + images.append(img_block)
728 + size = img_block.get('_metadata', {}).get('size', (0, 0))
729 + resized = img_block.get('_metadata', {}).get('resized')
730 +
731 + # Create image marker
732 + if resized:
733 + img_marker = f"[📷 Image {len(images)}: {size[0]}x{size[1]} → {resized[0]}x{resized[1]}]"
734 + else:
735 + img_marker = f"[📷 Image {len(images)}: {size[0]}x{size[1]}]"
736 +
737 + # Insert marker at cursor position
738 + current = current[:cursor_pos] + img_marker + current[cursor_pos:]
739 + cursor_pos += len(img_marker)
740 +
741 + # Redraw current line
742 + prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
743 + print(f"\r\033[K{prefix}{current}", end="", flush=True)
744 + else:
745 + # No image in clipboard
746 + print(f"\r{YELLOW}⚠ No image in clipboard{RESET}")
747 + import time
748 + time.sleep(1)
749 + # Redraw current line
750 + prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
751 + print(f"\r\033[K{prefix}{current}", end="", flush=True)
752 +
753 + continue
754 +
585 755 if ch == '\x1b': # Escape sequence
586 756 next_ch = sys.stdin.read(1)
587 757 if next_ch in ('\r', '\n'): # Alt+Enter
@@ -707,7 +877,8 @@ def read_multiline_input(prefill=""):
707 877 print("\033[?2004l", end="", flush=True)
708 878 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
709 879
710 - return "\n".join(lines).strip()
880 + text = "\n".join(lines).strip()
881 + return text, images
711 882
712 883 def main():
713 884 global stop_flag
@@ -734,7 +905,7 @@ def main():
734 905 session_mode = f" | {CYAN}New{RESET}"
735 906
736 907 print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
737 - print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+E=exit | ESC=stop{RESET}")
908 + print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+V=paste image | Ctrl+C=clear | Ctrl+E=exit | ESC=stop{RESET}")
738 909 print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /t | /clear{RESET}")
739 910 print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
740 911
@@ -949,11 +1120,11 @@ Examples:
949 1120 while True:
950 1121 try:
951 1122 print(f"{DIM}{'─'*80}{RESET}")
952 - user_input = read_multiline_input(prefill_next_input)
1123 + user_input, images = read_multiline_input(prefill_next_input)
953 1124 prefill_next_input = "" # Clear after use
954 1125 print(f"{DIM}{'─'*80}{RESET}")
955 1126
956 - if not user_input: continue
1127 + if not user_input and not images: continue
957 1128 if user_input in ("/q", "exit"):
958 1129 session_manager.save_session()
959 1130 break
@@ -1019,8 +1190,31 @@ Examples:
1019 1190 session_manager.save_session()
1020 1191 continue
1021 1192
1193 + # Build message content
1194 + if images:
1195 + # Has images: use content array format
1196 + content = []
1197 + if user_input:
1198 + content.append({"type": "text", "text": user_input})
1199 +
1200 + for img_block in images:
1201 + # Remove metadata (API doesn't need it)
1202 + img_block_clean = {
1203 + "type": "image",
1204 + "source": img_block["source"]
1205 + }
1206 + content.append(img_block_clean)
1207 +
1208 + message = {"role": "user", "content": content}
1209 +
1210 + # Show image info
1211 + print(f"{GREEN}📷 Attached {len(images)} image(s){RESET}")
1212 + else:
1213 + # Text only: keep original format
1214 + message = {"role": "user", "content": user_input}
1215 +
1022 1216 # Add user message to current session
1023 - session_manager.current_session.messages.append({"role": "user", "content": user_input})
1217 + session_manager.current_session.messages.append(message)
1024 1218
1025 1219 # Reset stop flag for new turn
1026 1220 stop_flag = False

liusijin revised this gist 1 month ago. Go to revision

No changes

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 386 insertions, 21 deletions

nano.py

@@ -14,6 +14,7 @@ import sys
14 14 import termios
15 15 import time
16 16 import tty
17 + import unicodedata
17 18 import urllib.request
18 19 import urllib.parse
19 20 from datetime import datetime
@@ -452,6 +453,93 @@ def process_stream(response):
452 453
453 454 return blocks
454 455
456 + def get_display_width(text):
457 + """Get display width of text (CJK chars = 2, others = 1)"""
458 + width = 0
459 + for char in text:
460 + if unicodedata.east_asian_width(char) in ('F', 'W'):
461 + width += 2
462 + else:
463 + width += 1
464 + return width
465 +
466 + def format_tool_preview(name, args):
467 + """Format tool call preview based on tool type"""
468 + if name == "read":
469 + path = args.get("path", "")
470 + offset = args.get("offset")
471 + limit = args.get("limit")
472 + if offset or limit:
473 + return f"{path}, offset={offset or 0}, limit={limit or 'all'}"
474 + return path
475 +
476 + elif name == "write":
477 + path = args.get("path", "")
478 + content = args.get("content", "")
479 + return f"{path}, {len(content)} bytes"
480 +
481 + elif name == "edit":
482 + path = args.get("path", "")
483 + old = args.get("old", "")
484 + new = args.get("new", "")
485 + all_flag = args.get("all", False)
486 + old_preview = old[:20] + "..." if len(old) > 20 else old
487 + new_preview = new[:20] + "..." if len(new) > 20 else new
488 + flag_str = ", all=true" if all_flag else ""
489 + return f"{path}: '{old_preview}' → '{new_preview}'{flag_str}"
490 +
491 + elif name == "bash":
492 + return args.get("cmd", "")
493 +
494 + elif name == "glob":
495 + pattern = args.get("pat", "")
496 + path = args.get("path", ".")
497 + return f"{pattern} in {path}"
498 +
499 + elif name == "grep":
500 + pattern = args.get("pat", "")
501 + path = args.get("path", ".")
502 + return f"/{pattern}/ in {path}"
503 +
504 + elif name == "web_search":
505 + query = args.get("query", "")
506 + max_results = args.get("max_results", 5)
507 + return f"{query}, max={max_results}"
508 +
509 + elif name == "search_extension":
510 + return args.get("query", "")
511 +
512 + elif name == "load":
513 + url = args.get("url", "")
514 + # Show filename from URL
515 + filename = url.split("/")[-1] if url else ""
516 + return filename or url
517 +
518 + else:
519 + # Fallback: show first value
520 + return str(list(args.values())[0])[:50] if args else ""
521 +
522 + def format_tool_result(name, result):
523 + """Format tool result preview based on tool type"""
524 + if not result:
525 + return "(empty)"
526 +
527 + lines = result.split("\n")
528 +
529 + # For simple results (ok, error, etc), show as-is
530 + if len(lines) == 1 and len(result) < 80:
531 + return result
532 +
533 + # For multi-line results
534 + first_line = lines[0][:60]
535 + if len(lines[0]) > 60:
536 + first_line += "..."
537 +
538 + if len(lines) > 1:
539 + return f"{first_line} +{len(lines)-1} lines"
540 +
541 + return first_line
542 +
455 543 def is_file_in_project(filepath, project_path):
456 544 """Check if file is within project directory"""
457 545 try:
@@ -462,11 +550,15 @@ def is_file_in_project(filepath, project_path):
462 550 except:
463 551 return False
464 552
465 - def read_multiline_input():
466 - """Read multiline input. Enter to submit, Alt+Enter for newline."""
553 + def read_multiline_input(prefill=""):
554 + """Read multiline input. Enter to submit, Alt+Enter for newline.
555 +
556 + Args:
557 + prefill: Text to prefill in the input box
558 + """
467 559 lines = []
468 - current = ""
469 - cursor_pos = 0 # Cursor position in current line
560 + current = prefill
561 + cursor_pos = len(prefill) # Cursor at end of prefill text
470 562
471 563 # Enable bracketed paste mode
472 564 print("\033[?2004h", end="", flush=True)
@@ -474,7 +566,7 @@ def read_multiline_input():
474 566 old_settings = termios.tcgetattr(sys.stdin)
475 567 try:
476 568 tty.setcbreak(sys.stdin.fileno())
477 - print(f"{BOLD}{BLUE}❯{RESET} ", end="", flush=True)
569 + print(f"{BOLD}{BLUE}❯{RESET} {current}", end="", flush=True)
478 570
479 571 while True:
480 572 ch = sys.stdin.read(1)
@@ -487,7 +579,7 @@ def read_multiline_input():
487 579 print(f"{BOLD}{BLUE}❯{RESET} ", end="", flush=True)
488 580 continue
489 581
490 - if ch == '\x04': # Ctrl+D
582 + if ch == '\x05': # Ctrl+E
491 583 raise EOFError
492 584
493 585 if ch == '\x1b': # Escape sequence
@@ -501,12 +593,24 @@ def read_multiline_input():
501 593 seq = sys.stdin.read(1)
502 594 if seq == 'C': # Right arrow
503 595 if cursor_pos < len(current):
596 + # Get display width of character at cursor
597 + char_width = get_display_width(current[cursor_pos])
504 598 cursor_pos += 1
505 - print("\033[C", end="", flush=True)
599 + # Move cursor by actual display width
600 + if char_width == 2:
601 + print("\033[2C", end="", flush=True)
602 + else:
603 + print("\033[C", end="", flush=True)
506 604 elif seq == 'D': # Left arrow
507 605 if cursor_pos > 0:
508 606 cursor_pos -= 1
509 - print("\033[D", end="", flush=True)
607 + # Get display width of character before cursor
608 + char_width = get_display_width(current[cursor_pos])
609 + # Move cursor by actual display width
610 + if char_width == 2:
611 + print("\033[2D", end="", flush=True)
612 + else:
613 + print("\033[D", end="", flush=True)
510 614 elif seq == '2': # Bracketed paste start: ESC[200~
511 615 rest = sys.stdin.read(3) # Read "00~"
512 616 if rest == '00~':
@@ -568,7 +672,9 @@ def read_multiline_input():
568 672 print(f"\r\033[K{prefix}{current}", end="", flush=True)
569 673 # Move cursor back to position
570 674 if cursor_pos < len(current):
571 - print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
675 + # Calculate display width from cursor to end
676 + remaining_width = get_display_width(current[cursor_pos:])
677 + print(f"\033[{remaining_width}D", end="", flush=True)
572 678 elif lines:
573 679 # Merge with previous line
574 680 prev_line = lines.pop()
@@ -579,7 +685,9 @@ def read_multiline_input():
579 685 prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
580 686 print(f"\r{prefix}{current}", end="", flush=True)
581 687 if cursor_pos < len(current):
582 - print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
688 + # Calculate display width from cursor to end
689 + remaining_width = get_display_width(current[cursor_pos:])
690 + print(f"\033[{remaining_width}D", end="", flush=True)
583 691 continue
584 692
585 693 if ch.isprintable() or ch == '\t':
@@ -590,7 +698,9 @@ def read_multiline_input():
590 698 print(f"{ch}{current[cursor_pos:]}", end="", flush=True)
591 699 # Move cursor back if needed
592 700 if cursor_pos < len(current):
593 - print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
701 + # Calculate display width from cursor to end
702 + remaining_width = get_display_width(current[cursor_pos:])
703 + print(f"\033[{remaining_width}D", end="", flush=True)
594 704
595 705 finally:
596 706 # Disable bracketed paste mode
@@ -624,8 +734,8 @@ def main():
624 734 session_mode = f" | {CYAN}New{RESET}"
625 735
626 736 print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
627 - print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+D=exit | ESC=stop{RESET}")
628 - print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /clear{RESET}")
737 + print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+E=exit | ESC=stop{RESET}")
738 + print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /t | /clear{RESET}")
629 739 print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
630 740
631 741 selected_session_id = None
@@ -834,10 +944,13 @@ Examples:
834 944 - User needs web data → search_extension({{"query": "web scraping"}})
835 945 - User needs API → search_extension({{"query": "api client"}})"""
836 946
947 + prefill_next_input = "" # Store prefill text for next input
948 +
837 949 while True:
838 950 try:
839 951 print(f"{DIM}{'─'*80}{RESET}")
840 - user_input = read_multiline_input()
952 + user_input = read_multiline_input(prefill_next_input)
953 + prefill_next_input = "" # Clear after use
841 954 print(f"{DIM}{'─'*80}{RESET}")
842 955
843 956 if not user_input: continue
@@ -869,6 +982,24 @@ Examples:
869 982 print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{RESET}")
870 983 continue
871 984
985 + # Handle turns command
986 + if user_input.startswith("/t") or user_input.startswith("/turns"):
987 + parts = user_input.split()
988 +
989 + # Parse turn number if provided
990 + turn_number = None
991 + if len(parts) >= 2 and parts[1].isdigit():
992 + turn_number = int(parts[1])
993 +
994 + success, prefill_input = handle_turns_command(session_manager, turn_number)
995 + if success:
996 + # Session already modified in handle_turns_command, just save
997 + session_manager.save_session()
998 + # Set prefill for next input if provided
999 + if prefill_input:
1000 + prefill_next_input = prefill_input
1001 + continue
1002 +
872 1003 # Handle checkpoint commands
873 1004 if user_input.startswith("/checkpoint") or user_input.startswith("/c") or user_input == "/ca":
874 1005 parts = user_input.split()
@@ -913,14 +1044,15 @@ Examples:
913 1044 if filepath and is_file_in_project(filepath, session_manager.project_path):
914 1045 session_manager.save_baseline_if_needed(filepath)
915 1046
916 - preview = str(list(args.values())[0])[:50] if args else ""
1047 + # Format preview based on tool type
1048 + preview = format_tool_preview(name, args)
917 1049 print(f"\n{GREEN}⏺ {name}{RESET}({DIM}{preview}{RESET})")
918 1050
919 1051 result = run_tool(name, args)
920 - lines = result.split("\n")
921 - prev = lines[0][:60] + ("..." if len(lines[0]) > 60 else "")
922 - if len(lines) > 1: prev += f" +{len(lines)-1}"
923 - print(f" {DIM}⎿ {prev}{RESET}")
1052 +
1053 + # Format result based on tool type
1054 + result_preview = format_tool_result(name, result)
1055 + print(f" {DIM}⎿ {result_preview}{RESET}")
924 1056
925 1057 # Track file modifications (only project files)
926 1058 if name in ['write', 'edit']:
@@ -942,6 +1074,7 @@ Examples:
942 1074 session_manager.current_session.messages.append({"role": "user", "content": tool_results})
943 1075
944 1076 # Auto checkpoint after AI work (if project files were modified)
1077 + checkpoint_id = None
945 1078 if auto_checkpoint and files_modified_this_turn:
946 1079 # files_modified_this_turn already filtered to project files only
947 1080 # Use parent_commit for first checkpoint of new session
@@ -980,6 +1113,13 @@ Examples:
980 1113 # Checkpoint creation failed (e.g., no actual diff)
981 1114 print(f"\n{DIM}(No project file changes to checkpoint){RESET}")
982 1115
1116 + # Record this turn
1117 + session_manager.current_session.add_turn(
1118 + user_input,
1119 + files_modified_this_turn,
1120 + checkpoint_id=checkpoint_id
1121 + )
1122 +
983 1123 # Auto-save session after each interaction
984 1124 session_manager.save_session()
985 1125
@@ -1369,6 +1509,7 @@ class Session:
1369 1509 self.messages = []
1370 1510 self.file_states = {}
1371 1511 self.baseline_files = {} # Track original file versions for rollback
1512 + self.turns = [] # Track conversation turns
1372 1513 self.metadata = {
1373 1514 'created_at': time.time(),
1374 1515 'last_active': time.time(),
@@ -1473,6 +1614,26 @@ class Session:
1473 1614 # Remove deleted files from tracking
1474 1615 del self.file_states[filepath]
1475 1616
1617 + def add_turn(self, user_input, files_modified, checkpoint_id=None):
1618 + """Record a conversation turn
1619 +
1620 + Args:
1621 + user_input: User's input for this turn
1622 + files_modified: Set of files modified in this turn
1623 + checkpoint_id: Associated checkpoint ID if files were modified
1624 + """
1625 + turn_number = len(self.turns) + 1
1626 + turn_data = {
1627 + 'turn_number': turn_number,
1628 + 'timestamp': time.time(),
1629 + 'user_input': user_input[:100], # Truncate for preview
1630 + 'files_modified': list(files_modified),
1631 + 'checkpoint_id': checkpoint_id,
1632 + 'message_count': len(self.messages) # Track message index
1633 + }
1634 + self.turns.append(turn_data)
1635 + return turn_number
1636 +
1476 1637 def to_dict(self):
1477 1638 """Serialize to dict"""
1478 1639 return {
@@ -1480,6 +1641,7 @@ class Session:
1480 1641 'messages': self.messages,
1481 1642 'file_states': self.file_states,
1482 1643 'baseline_files': self.baseline_files,
1644 + 'turns': self.turns,
1483 1645 'metadata': self.metadata
1484 1646 }
1485 1647
@@ -1490,6 +1652,7 @@ class Session:
1490 1652 session.messages = data.get('messages', [])
1491 1653 session.file_states = data.get('file_states', {})
1492 1654 session.baseline_files = data.get('baseline_files', {})
1655 + session.turns = data.get('turns', [])
1493 1656 session.metadata = data.get('metadata', {})
1494 1657 return session
1495 1658
@@ -1571,8 +1734,9 @@ class SessionManager:
1571 1734 return False
1572 1735
1573 1736 if not self.current_session.baseline_files:
1574 - print(f"{YELLOW}⚠ No baseline files to restore{RESET}")
1575 - return False
1737 + # No files modified = already at baseline state = success
1738 + print(f"{DIM}No files to restore (already at baseline){RESET}")
1739 + return True
1576 1740
1577 1741 print(f"\n{BOLD}Restoring {len(self.current_session.baseline_files)} files to baseline...{RESET}\n")
1578 1742
@@ -1704,6 +1868,207 @@ class SessionManager:
1704 1868 return None
1705 1869
1706 1870
1871 +
1872 +
1873 + def handle_turns_command(session_manager, turn_number=None):
1874 + """Handle /t or /turns command to display or restore conversation turns
1875 +
1876 + Args:
1877 + session_manager: SessionManager instance
1878 + turn_number: If provided, restore to this turn; otherwise list turns
1879 +
1880 + Returns:
1881 + tuple: (success: bool, prefill_input: str or None)
1882 + """
1883 + if not session_manager.current_session:
1884 + print(f"{YELLOW}⚠ No active session{RESET}")
1885 + return False, None
1886 +
1887 + turns = session_manager.current_session.turns
1888 +
1889 + if not turns:
1890 + print(f"{DIM}No conversation turns yet{RESET}")
1891 + return False, None
1892 +
1893 + # If turn_number provided, restore to that turn
1894 + if turn_number is not None:
1895 + return restore_to_turn(session_manager, turn_number)
1896 +
1897 + # Otherwise, list all turns
1898 + print(f"\n{BOLD}💬 Conversation Turns:{RESET}\n")
1899 +
1900 + for turn in turns:
1901 + turn_num = turn['turn_number']
1902 + timestamp = turn['timestamp']
1903 + user_input = turn['user_input']
1904 + files_modified = turn.get('files_modified', [])
1905 + checkpoint_id = turn.get('checkpoint_id')
1906 +
1907 + # Calculate time ago
1908 + time_ago = time.time() - timestamp
1909 + if time_ago < 60:
1910 + time_str = f"{int(time_ago)}s ago"
1911 + elif time_ago < 3600:
1912 + time_str = f"{int(time_ago/60)}m ago"
1913 + elif time_ago < 86400:
1914 + time_str = f"{int(time_ago/3600)}h ago"
1915 + else:
1916 + time_str = f"{int(time_ago/86400)}d ago"
1917 +
1918 + # Format turn line
1919 + checkpoint_marker = f" {YELLOW}[{checkpoint_id}]{RESET}" if checkpoint_id else ""
1920 + files_marker = f" {GREEN}✓{RESET}" if files_modified else ""
1921 +
1922 + print(f" {CYAN}turn_{turn_num}{RESET}{checkpoint_marker}{files_marker} {DIM}({time_str}){RESET}")
1923 + print(f" {DIM}└─{RESET} {user_input}")
1924 +
1925 + if files_modified:
1926 + files_display = ", ".join(files_modified[:3])
1927 + if len(files_modified) > 3:
1928 + files_display += f" +{len(files_modified)-3} more"
1929 + print(f" {DIM}Files: {files_display}{RESET}")
1930 +
1931 + print()
1932 +
1933 + print(f"{DIM}Tip: Use '/t <number>' to undo that turn and restart from previous{RESET}")
1934 + return False, None # Return False to indicate no restore action
1935 +
1936 +
1937 + def restore_to_turn(session_manager, turn_number):
1938 + """Restore files and conversation to a specific turn
1939 +
1940 + Args:
1941 + session_manager: SessionManager instance
1942 + turn_number: Turn number to undo (1-indexed, will restore to turn_number-1)
1943 +
1944 + Returns:
1945 + tuple: (success: bool, prefill_input: str or None)
1946 + """
1947 + turns = session_manager.current_session.turns
1948 +
1949 + # User wants to undo turn_N, so restore to turn_(N-1)
1950 + # Special case: /t 1 means clear all turns
1951 + if turn_number == 1:
1952 + print(f"{YELLOW}⚠ This will undo turn_1 and reset to session start{RESET}")
1953 + confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
1954 + if confirm != 'y':
1955 + print(f"{DIM}Cancelled{RESET}")
1956 + return False, None
1957 +
1958 + # Restore to baseline
1959 + success = session_manager.restore_baseline()
1960 + if not success:
1961 + return False, None
1962 +
1963 + # Clear all turns and messages
1964 + session_manager.current_session.turns = []
1965 + session_manager.current_session.messages = []
1966 + session_manager.current_session.update_file_states()
1967 + session_manager.parent_commit_for_next_checkpoint = None
1968 +
1969 + print(f"{GREEN}✓ Reset to session start{RESET}")
1970 +
1971 + # Return original turn_1 input for prefill
1972 + original_input = turns[0]['user_input'] if turns else None
1973 + return True, original_input
1974 +
1975 + # Normal case: restore to turn_(N-1)
1976 + restore_to = turn_number - 1
1977 +
1978 + # Validate
1979 + if restore_to < 1 or restore_to > len(turns):
1980 + print(f"{RED}Invalid turn number. Valid range: 1-{len(turns)}{RESET}")
1981 + return False, None
1982 +
1983 + turn = turns[restore_to - 1]
1984 +
1985 + # Find the most recent checkpoint at or before restore_to
1986 + checkpoint_id = None
1987 + checkpoint_turn = None
1988 +
1989 + for i in range(restore_to - 1, -1, -1):
1990 + if turns[i].get('checkpoint_id'):
1991 + checkpoint_id = turns[i]['checkpoint_id']
1992 + checkpoint_turn = i + 1
1993 + break
1994 +
1995 + # Get original input from the turn being undone
1996 + # Note: turn['user_input'] is truncated to 100 chars, so get full input from messages
1997 + original_input = None
1998 + if turn_number <= len(turns):
1999 + turn_to_undo = turns[turn_number - 1]
2000 + # Find the user message for this turn
2001 + # turn_to_undo['message_count'] is the total messages after this turn
2002 + # The user message for this turn is at index (message_count - 2) if there's an assistant response
2003 + # or (message_count - 1) if it's the last message
2004 + msg_idx = turn_to_undo['message_count'] - 2 # Assume there's an assistant response
2005 + if msg_idx >= 0 and msg_idx < len(session_manager.current_session.messages):
2006 + msg = session_manager.current_session.messages[msg_idx]
2007 + if msg['role'] == 'user' and isinstance(msg['content'], str):
2008 + original_input = msg['content']
2009 +
2010 + # Show warning
2011 + future_turns = len(turns) - restore_to
2012 + print(f"\n{YELLOW}⚠ This will undo turn_{turn_number} (restore to turn_{restore_to}){RESET}")
2013 +
2014 + if checkpoint_id:
2015 + if checkpoint_turn == restore_to:
2016 + print(f"{YELLOW}⚠ Files: restored to turn_{restore_to} checkpoint ({checkpoint_id}){RESET}")
2017 + else:
2018 + print(f"{YELLOW}⚠ Files: restored to turn_{checkpoint_turn} checkpoint ({checkpoint_id}){RESET}")
2019 + else:
2020 + print(f"{YELLOW}⚠ Files: restored to baseline (no checkpoints before turn_{restore_to}){RESET}")
2021 +
2022 + print(f"{YELLOW}⚠ Conversation: restored to turn_{restore_to} ({turn['message_count']} messages){RESET}")
2023 +
2024 + if future_turns > 0:
2025 + print(f"{YELLOW}⚠ Future turns ({restore_to + 1}-{len(turns)}) will be discarded{RESET}")
2026 +
2027 + if original_input:
2028 + print(f"\n{CYAN}Original turn_{turn_number} input will be prefilled:{RESET}")
2029 + print(f" {DIM}{original_input[:100]}{'...' if len(original_input) > 100 else ''}{RESET}")
2030 +
2031 + confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
2032 +
2033 + if confirm != 'y':
2034 + print(f"{DIM}Cancelled{RESET}")
2035 + return False, None
2036 +
2037 + # Restore files
2038 + if checkpoint_id:
2039 + success, _ = session_manager.checkpoint_manager.restore_checkpoint(
2040 + checkpoint_id,
2041 + session_manager.current_session.baseline_files
2042 + )
2043 + if not success:
2044 + print(f"{RED}✗ Failed to restore files{RESET}")
2045 + return False, None
2046 + print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
2047 + else:
2048 + # No checkpoint before this turn, restore to baseline
2049 + success = session_manager.restore_baseline()
2050 + if not success:
2051 + print(f"{RED}✗ Failed to restore to baseline{RESET}")
2052 + return False, None
2053 + print(f"{GREEN}✓ Restored files to baseline{RESET}")
2054 +
2055 + # Restore conversation by truncating messages (modify session directly)
2056 + session_manager.current_session.messages = session_manager.current_session.messages[:turn['message_count']]
2057 +
2058 + # Truncate turns list to restore_to
2059 + session_manager.current_session.turns = turns[:restore_to]
2060 +
2061 + # Update file states to match restored files
2062 + session_manager.current_session.update_file_states()
2063 +
2064 + # Reset parent commit for next checkpoint
2065 + session_manager.parent_commit_for_next_checkpoint = checkpoint_id if checkpoint_id else None
2066 +
2067 + print(f"{GREEN}✓ Restored to turn_{restore_to} ({len(session_manager.current_session.messages)} messages){RESET}")
2068 +
2069 + return True, original_input # Return success and prefill input
2070 +
2071 +
1707 2072 def handle_checkpoint_command(parts, session_manager, files_modified):
1708 2073 """Handle /checkpoint or /c commands
1709 2074

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 299 insertions, 12 deletions

nano.py

@@ -625,7 +625,7 @@ def main():
625 625
626 626 print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
627 627 print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+D=exit | ESC=stop{RESET}")
628 - print(f"{DIM}Commands: /c [all|<id>] | /ca | /clear{RESET}")
628 + print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /clear{RESET}")
629 629 print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
630 630
631 631 selected_session_id = None
@@ -906,6 +906,13 @@ Examples:
906 906 for block in blocks:
907 907 if block["type"] == "tool_use":
908 908 name, args = block["name"], block["input"]
909 +
910 + # Save baseline BEFORE executing write/edit
911 + if name in ['write', 'edit']:
912 + filepath = args.get('path')
913 + if filepath and is_file_in_project(filepath, session_manager.project_path):
914 + session_manager.save_baseline_if_needed(filepath)
915 +
909 916 preview = str(list(args.values())[0])[:50] if args else ""
910 917 print(f"\n{GREEN}⏺ {name}{RESET}({DIM}{preview}{RESET})")
911 918
@@ -1033,6 +1040,74 @@ class CheckpointManager:
1033 1040 except (subprocess.CalledProcessError, FileNotFoundError) as e:
1034 1041 return f"error: {e}"
1035 1042
1043 + def save_file_to_blob(self, filepath):
1044 + """Save file to git blob storage
1045 +
1046 + Returns:
1047 + str: blob hash
1048 + """
1049 + try:
1050 + result = subprocess.run(
1051 + ["git", "--git-dir", self.bare_repo, "hash-object", "-w", filepath],
1052 + capture_output=True, text=True, check=True
1053 + )
1054 + return result.stdout.strip()
1055 + except Exception as e:
1056 + return None
1057 +
1058 + def restore_file_from_blob(self, blob_hash, filepath):
1059 + """Restore file from git blob storage"""
1060 + try:
1061 + content = subprocess.run(
1062 + ["git", "--git-dir", self.bare_repo, "cat-file", "-p", blob_hash],
1063 + capture_output=True, check=True
1064 + ).stdout
1065 +
1066 + os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
1067 + with open(filepath, 'wb') as f:
1068 + f.write(content)
1069 + return True
1070 + except Exception as e:
1071 + return False
1072 +
1073 + def get_file_git_info(self, filepath):
1074 + """Get file's git info from project .git
1075 +
1076 + Returns:
1077 + dict: {"commit": "abc123", "has_changes": True/False} or None
1078 + """
1079 + try:
1080 + # Check if file is tracked
1081 + result = subprocess.run(
1082 + ["git", "ls-files", "--error-unmatch", filepath],
1083 + cwd=self.project_path,
1084 + capture_output=True, text=True
1085 + )
1086 +
1087 + if result.returncode != 0:
1088 + return None # Not tracked
1089 +
1090 + # Get last commit for this file
1091 + commit = subprocess.run(
1092 + ["git", "log", "-1", "--format=%H", "--", filepath],
1093 + cwd=self.project_path,
1094 + capture_output=True, text=True, check=True
1095 + ).stdout.strip()
1096 +
1097 + # Check if file has local changes
1098 + diff = subprocess.run(
1099 + ["git", "diff", "HEAD", "--", filepath],
1100 + cwd=self.project_path,
1101 + capture_output=True, text=True, check=True
1102 + ).stdout.strip()
1103 +
1104 + return {
1105 + "commit": commit,
1106 + "has_changes": bool(diff)
1107 + }
1108 + except Exception as e:
1109 + return None
1110 +
1036 1111 def create_checkpoint(self, message, files_changed, conversation_snapshot=None, parent_commit=None):
1037 1112 """Create a checkpoint on current session's branch
1038 1113
@@ -1179,9 +1254,17 @@ class CheckpointManager:
1179 1254 except:
1180 1255 return []
1181 1256
1182 - def restore_checkpoint(self, checkpoint_id):
1257 + def restore_checkpoint(self, checkpoint_id, session_baseline_files):
1183 1258 """Restore files to checkpoint state and reset current session's branch
1184 1259
1260 + This properly handles files that were added after the checkpoint by:
1261 + 1. Restoring files that exist in checkpoint
1262 + 2. Restoring files to baseline if they don't exist in checkpoint
1263 +
1264 + Args:
1265 + checkpoint_id: Checkpoint hash to restore to
1266 + session_baseline_files: Dict of baseline file states from session
1267 +
1185 1268 Returns:
1186 1269 tuple: (success: bool, conversation_snapshot: dict or None)
1187 1270 """
@@ -1202,16 +1285,67 @@ class CheckpointManager:
1202 1285 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1203 1286 "checkout", checkpoint_id, "-f")
1204 1287
1205 - # Copy files back to project
1206 - for root, dirs, files in os.walk(temp_worktree):
1207 - for file in files:
1208 - src = os.path.join(root, file)
1209 - rel_path = os.path.relpath(src, temp_worktree)
1210 - dest = os.path.join(self.project_path, rel_path)
1288 + # Get list of files in checkpoint
1289 + files_in_checkpoint_str = self._git_command(
1290 + "--git-dir", self.bare_repo,
1291 + "ls-tree", "-r", "--name-only", checkpoint_id
1292 + )
1293 +
1294 + files_in_checkpoint = set()
1295 + if files_in_checkpoint_str and not files_in_checkpoint_str.startswith("error"):
1296 + files_in_checkpoint = set(f for f in files_in_checkpoint_str.split('\n') if f.strip())
1297 +
1298 + print(f"\n{DIM}Files in checkpoint: {len(files_in_checkpoint)}{RESET}")
1299 + print(f"{DIM}Files modified in session: {len(session_baseline_files)}{RESET}\n")
1300 +
1301 + # Process each file that was modified in this session
1302 + for filepath, baseline_source in session_baseline_files.items():
1303 + # Convert to relative path for comparison
1304 + if os.path.isabs(filepath):
1305 + rel_filepath = os.path.relpath(filepath, self.project_path)
1306 + else:
1307 + rel_filepath = filepath
1308 +
1309 + # Normalize path: remove leading ./
1310 + normalized_rel_path = rel_filepath.lstrip('./')
1311 +
1312 + if normalized_rel_path in files_in_checkpoint:
1313 + # File exists in checkpoint - restore from checkpoint
1314 + src = os.path.join(temp_worktree, normalized_rel_path)
1315 + dest = os.path.join(self.project_path, normalized_rel_path)
1316 +
1317 + dest_dir = os.path.dirname(dest)
1318 + if dest_dir:
1319 + os.makedirs(dest_dir, exist_ok=True)
1211 1320
1212 - os.makedirs(os.path.dirname(dest), exist_ok=True)
1213 1321 with open(src, 'rb') as s, open(dest, 'wb') as d:
1214 1322 d.write(s.read())
1323 +
1324 + print(f" {GREEN}✓{RESET} {filepath} {DIM}(from checkpoint){RESET}")
1325 + else:
1326 + # File doesn't exist in checkpoint - restore to baseline
1327 + abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1328 +
1329 + if baseline_source["type"] == "git":
1330 + # Restore from project .git (use normalized path for git)
1331 + result = subprocess.run(
1332 + ["git", "checkout", baseline_source["commit"], "--", normalized_rel_path],
1333 + cwd=self.project_path,
1334 + capture_output=True, text=True
1335 + )
1336 + if result.returncode == 0:
1337 + print(f" {CYAN}↺{RESET} {filepath} {DIM}(to baseline: git {baseline_source['commit'][:8]}){RESET}")
1338 +
1339 + elif baseline_source["type"] == "blob":
1340 + # Restore from blob
1341 + if self.restore_file_from_blob(baseline_source["hash"], abs_filepath):
1342 + print(f" {CYAN}↺{RESET} {filepath} {DIM}(to baseline: blob {baseline_source['hash'][:8]}){RESET}")
1343 +
1344 + elif baseline_source["type"] == "new":
1345 + # Delete new file
1346 + if os.path.exists(abs_filepath):
1347 + os.remove(abs_filepath)
1348 + print(f" {YELLOW}✗{RESET} {filepath} {DIM}(deleted: was added after checkpoint){RESET}")
1215 1349
1216 1350 # Load conversation snapshot
1217 1351 snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
@@ -1222,7 +1356,8 @@ class CheckpointManager:
1222 1356 conversation_snapshot = snapshots.get(checkpoint_id)
1223 1357
1224 1358 return True, conversation_snapshot
1225 - except:
1359 + except Exception as e:
1360 + print(f"{RED}Error during restore: {e}{RESET}")
1226 1361 return False, None
1227 1362
1228 1363
@@ -1233,6 +1368,7 @@ class Session:
1233 1368 self.session_id = session_id or self._generate_session_id()
1234 1369 self.messages = []
1235 1370 self.file_states = {}
1371 + self.baseline_files = {} # Track original file versions for rollback
1236 1372 self.metadata = {
1237 1373 'created_at': time.time(),
1238 1374 'last_active': time.time(),
@@ -1343,6 +1479,7 @@ class Session:
1343 1479 'session_id': self.session_id,
1344 1480 'messages': self.messages,
1345 1481 'file_states': self.file_states,
1482 + 'baseline_files': self.baseline_files,
1346 1483 'metadata': self.metadata
1347 1484 }
1348 1485
@@ -1352,6 +1489,7 @@ class Session:
1352 1489 session = Session(session_id=data['session_id'])
1353 1490 session.messages = data.get('messages', [])
1354 1491 session.file_states = data.get('file_states', {})
1492 + session.baseline_files = data.get('baseline_files', {})
1355 1493 session.metadata = data.get('metadata', {})
1356 1494 return session
1357 1495
@@ -1367,6 +1505,122 @@ class SessionManager:
1367 1505 self.parent_commit_for_next_checkpoint = None # Track parent for first checkpoint
1368 1506 os.makedirs(self.sessions_dir, exist_ok=True)
1369 1507
1508 + def save_baseline_if_needed(self, filepath):
1509 + """Save file's baseline version before first modification
1510 +
1511 + This is called before write/edit operations to preserve the original state.
1512 + """
1513 + if not self.current_session:
1514 + return
1515 +
1516 + # Already saved
1517 + if filepath in self.current_session.baseline_files:
1518 + return
1519 +
1520 + print(f"{DIM}[LOG] Saving baseline for: {filepath}{RESET}", flush=True)
1521 +
1522 + # Get file info from project .git
1523 + git_info = self.checkpoint_manager.get_file_git_info(filepath)
1524 +
1525 + if git_info:
1526 + # File is tracked by project .git
1527 + if git_info["has_changes"]:
1528 + # Has local changes - save to blob
1529 + blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1530 + if blob_hash:
1531 + self.current_session.baseline_files[filepath] = {
1532 + "type": "blob",
1533 + "hash": blob_hash
1534 + }
1535 + print(f"{DIM}[LOG] Saved dirty file to blob: {blob_hash[:8]}{RESET}", flush=True)
1536 + else:
1537 + # Clean - just record commit
1538 + self.current_session.baseline_files[filepath] = {
1539 + "type": "git",
1540 + "commit": git_info["commit"]
1541 + }
1542 + print(f"{DIM}[LOG] Recorded git commit: {git_info['commit'][:8]}{RESET}", flush=True)
1543 + else:
1544 + # Untracked file or no .git
1545 + if os.path.exists(filepath):
1546 + # Save existing untracked file to blob
1547 + blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1548 + if blob_hash:
1549 + self.current_session.baseline_files[filepath] = {
1550 + "type": "blob",
1551 + "hash": blob_hash
1552 + }
1553 + print(f"{DIM}[LOG] Saved untracked file to blob: {blob_hash[:8]}{RESET}", flush=True)
1554 + else:
1555 + # New file - mark as new
1556 + self.current_session.baseline_files[filepath] = {
1557 + "type": "new"
1558 + }
1559 + print(f"{DIM}[LOG] Marked as new file{RESET}", flush=True)
1560 +
1561 + # Auto-save session to persist baseline_files
1562 + self.save_session()
1563 +
1564 + def restore_baseline(self):
1565 + """Restore all files to their baseline state
1566 +
1567 + Returns:
1568 + bool: Success status
1569 + """
1570 + if not self.current_session:
1571 + return False
1572 +
1573 + if not self.current_session.baseline_files:
1574 + print(f"{YELLOW}⚠ No baseline files to restore{RESET}")
1575 + return False
1576 +
1577 + print(f"\n{BOLD}Restoring {len(self.current_session.baseline_files)} files to baseline...{RESET}\n")
1578 +
1579 + success_count = 0
1580 + for filepath, source in self.current_session.baseline_files.items():
1581 + try:
1582 + # Normalize to absolute path
1583 + abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1584 + # Get relative path for git operations
1585 + rel_filepath = os.path.relpath(abs_filepath, self.project_path)
1586 +
1587 + if source["type"] == "git":
1588 + # Restore from project .git (use relative path)
1589 + result = subprocess.run(
1590 + ["git", "checkout", source["commit"], "--", rel_filepath],
1591 + cwd=self.project_path,
1592 + capture_output=True, text=True
1593 + )
1594 + if result.returncode == 0:
1595 + print(f" {GREEN}✓{RESET} {filepath} {DIM}(from git {source['commit'][:8]}){RESET}")
1596 + success_count += 1
1597 + else:
1598 + print(f" {RED}✗{RESET} {filepath} {DIM}(git checkout failed){RESET}")
1599 +
1600 + elif source["type"] == "blob":
1601 + # Restore from checkpoint.git blob (use absolute path)
1602 + if self.checkpoint_manager.restore_file_from_blob(source["hash"], abs_filepath):
1603 + print(f" {GREEN}✓{RESET} {filepath} {DIM}(from blob {source['hash'][:8]}){RESET}")
1604 + success_count += 1
1605 + else:
1606 + print(f" {RED}✗{RESET} {filepath} {DIM}(blob restore failed){RESET}")
1607 +
1608 + elif source["type"] == "new":
1609 + # Delete new file (use absolute path)
1610 + if os.path.exists(abs_filepath):
1611 + os.remove(abs_filepath)
1612 + print(f" {GREEN}✓{RESET} {filepath} {DIM}(deleted new file){RESET}")
1613 + success_count += 1
1614 + else:
1615 + print(f" {DIM}○{RESET} {filepath} {DIM}(already deleted){RESET}")
1616 + success_count += 1
1617 +
1618 + except Exception as e:
1619 + print(f" {RED}✗{RESET} {filepath} {DIM}(error: {e}){RESET}")
1620 +
1621 + print(f"\n{GREEN}✓ Restored {success_count}/{len(self.current_session.baseline_files)} files{RESET}")
1622 + return success_count > 0
1623 +
1370 1624 def create_session(self, description="", parent_checkpoint=None, parent_session=None):
1371 1625 """Create new session
1372 1626
@@ -1469,7 +1723,37 @@ def handle_checkpoint_command(parts, session_manager, files_modified):
1469 1723 else:
1470 1724 checkpoint_id = None
1471 1725
1472 - if cmd == "list" or cmd == "all" or cmd == "--all":
1726 + if cmd == "baseline" or cmd == "base":
1727 + # Restore all files to baseline (session start state)
1728 + if not session_manager.current_session.baseline_files:
1729 + print(f"{YELLOW}⚠ No baseline: no files have been modified in this session{RESET}")
1730 + return None
1731 +
1732 + print(f"{YELLOW}⚠ This will restore all modified files to their original state{RESET}")
1733 + print(f"{YELLOW}⚠ Files to restore: {len(session_manager.current_session.baseline_files)}{RESET}")
1734 +
1735 + # Show files
1736 + print(f"\n{DIM}Files:{RESET}")
1737 + for filepath in list(session_manager.current_session.baseline_files.keys())[:10]:
1738 + print(f" {DIM}• {filepath}{RESET}")
1739 + if len(session_manager.current_session.baseline_files) > 10:
1740 + print(f" {DIM}... and {len(session_manager.current_session.baseline_files) - 10} more{RESET}")
1741 +
1742 + confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
1743 +
1744 + if confirm != 'y':
1745 + print(f"{DIM}Cancelled{RESET}")
1746 + return None
1747 +
1748 + success = session_manager.restore_baseline()
1749 + if success:
1750 + # Clear conversation
1751 + print(f"{GREEN}✓ Conversation cleared{RESET}")
1752 + return []
1753 + else:
1754 + return None
1755 +
1756 + elif cmd == "list" or cmd == "all" or cmd == "--all":
1473 1757 show_all = (cmd == "all" or "--all" in parts)
1474 1758
1475 1759 if show_all:
@@ -1590,7 +1874,10 @@ def handle_checkpoint_command(parts, session_manager, files_modified):
1590 1874 print(f"{DIM}Cancelled{RESET}")
1591 1875 return None
1592 1876
1593 - success, conversation_snapshot = session_manager.checkpoint_manager.restore_checkpoint(checkpoint_id)
1877 + success, conversation_snapshot = session_manager.checkpoint_manager.restore_checkpoint(
1878 + checkpoint_id,
1879 + session_manager.current_session.baseline_files
1880 + )
1594 1881 if success:
1595 1882 print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
1596 1883 if conversation_snapshot:

liusijin revised this gist 1 month ago. Go to revision

1 file changed, 1612 insertions

nano.py(file created)

@@ -0,0 +1,1612 @@
1 + #!/usr/bin/env python3
2 + """nanocode - minimal claude code alternative"""
3 + import glob as globlib
4 + import hashlib
5 + import json
6 + import os
7 + import random
8 + import re
9 + import readline
10 + import select
11 + import ssl
12 + import subprocess
13 + import sys
14 + import termios
15 + import time
16 + import tty
17 + import urllib.request
18 + import urllib.parse
19 + from datetime import datetime
20 +
21 + OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
22 + LOCAL_API_KEY = os.environ.get("LOCAL_API_KEY")
23 + API_URL = (
24 + "http://127.0.0.1:8990/v1/messages" if LOCAL_API_KEY
25 + else "https://openrouter.ai/api/v1/messages" if OPENROUTER_KEY
26 + else "https://api.anthropic.com/v1/messages"
27 + )
28 + MODEL = os.environ.get("MODEL",
29 + "anthropic/claude-sonnet-4.5" if LOCAL_API_KEY
30 + else "anthropic/claude-opus-4.5" if OPENROUTER_KEY
31 + else "claude-opus-4-5"
32 + )
33 +
34 + # ANSI colors
35 + RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
36 + BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
37 + stop_flag = False
38 +
39 + def create_opener():
40 + """Create URL opener with SSL and proxy support"""
41 + proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
42 + ssl_ctx = ssl.create_default_context()
43 + ssl_ctx.check_hostname = False
44 + ssl_ctx.verify_mode = ssl.CERT_NONE
45 +
46 + handlers = [urllib.request.HTTPSHandler(context=ssl_ctx)]
47 + if proxy: handlers.insert(0, urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
48 + return urllib.request.build_opener(*handlers)
49 +
50 + def register_tool(name, desc, params):
51 + """Register a tool from extension code"""
52 + def decorator(func):
53 + TOOLS[name] = (desc, params, func)
54 + return func
55 + return decorator
56 +
57 + def search_extension(args):
58 + """Search extensions from gist.kitchain.cn"""
59 + query = args.get("query", "")
60 + if not query: return "error: query required"
61 + try:
62 + # Split query into keywords
63 + keywords = query.lower().split()
64 + gist_info = {} # {gist_path: {"hits": count, "title": str, "desc": str, "topics": []}}
65 + opener = create_opener()
66 +
67 + # Search each keyword as a topic
68 + for keyword in keywords:
69 + url = f"https://gist.kitchain.cn/topics/{urllib.parse.quote(keyword)}"
70 + html = opener.open(urllib.request.Request(url), timeout=10).read().decode()
71 +
72 + # Extract gist URLs and titles
73 + gist_matches = re.findall(
74 + r'<a class="font-bold" href="https://gist\.kitchain\.cn/([^/]+/[a-f0-9]+)">([^<]+)</a>',
75 + html
76 + )
77 +
78 + for gist_path, title in gist_matches:
79 + if gist_path not in gist_info:
80 + # Extract description and topics for this gist
81 + gist_section = re.search(
82 + rf'{re.escape(gist_path)}.*?'
83 + r'<h6 class="text-xs[^"]*">([^<]+)</h6>(.*?)</div>\s*</div>',
84 + html, re.DOTALL
85 + )
86 + desc = ""
87 + topics = []
88 + if gist_section:
89 + desc = gist_section.group(1).strip()
90 + topics_section = gist_section.group(2)
91 + topics = re.findall(r'topics/([^"]+)"[^>]*>([^<]+)<', topics_section)
92 + topics = [t[1] for t in topics] # Extract topic names
93 +
94 + gist_info[gist_path] = {
95 + "hits": 0,
96 + "title": title.strip(),
97 + "desc": desc,
98 + "topics": topics,
99 + "filename": title.strip()
100 + }
101 + gist_info[gist_path]["hits"] += 1
102 +
103 + if not gist_info: return f"No extensions found: {query}"
104 +
105 + # Sort by hit count (descending)
106 + sorted_gists = sorted(gist_info.items(), key=lambda x: x[1]["hits"], reverse=True)[:10]
107 +
108 + result = f"Found {len(sorted_gists)} extensions:\n\n"
109 + for gist_path, info in sorted_gists:
110 + result += f"• {info['title']}\n"
111 + if info['desc']:
112 + result += f" {info['desc']}\n"
113 + if info['topics']:
114 + result += f" Topics: {', '.join(info['topics'])}\n"
115 + result += f" Matched: {info['hits']} keyword(s)\n\n"
116 +
117 + # Return first gist's load URL
118 + first_gist = sorted_gists[0][0]
119 + first_filename = sorted_gists[0][1]['filename']
120 + result += f"To load the top result:\nload({{\"url\": \"https://gist.kitchain.cn/{first_gist}/raw/HEAD/{first_filename}\"}})"
121 + return result
122 + except Exception as e:
123 + return f"error: {e}"
124 +
125 + def load(args):
126 + """Load extension from URL"""
127 + url = args.get("url")
128 + if not url: return "error: url required"
129 + try:
130 + opener = create_opener()
131 + code = opener.open(urllib.request.Request(url), timeout=10).read().decode()
132 + exec(code, {"register_tool": register_tool, "TOOLS": TOOLS, "urllib": urllib, "json": json, "re": re, "subprocess": subprocess})
133 + new = [k for k in TOOLS if k not in ["read","write","edit","glob","grep","bash","web_search","search_extension","load"]]
134 + return f"Loaded. New tools: {', '.join(new)}"
135 + except Exception as e:
136 + return f"error: {e}"
137 +
138 + # --- Tools ---
139 + def read(args):
140 + lines = open(args["path"]).readlines()
141 + offset, limit = args.get("offset", 0), args.get("limit", len(lines))
142 + return "".join(f"{offset+i+1:4}| {l}" for i, l in enumerate(lines[offset:offset+limit]))
143 +
144 + def write(args):
145 + filepath = args["path"]
146 + content = args["content"]
147 + print(f"{DIM}[LOG] write: {filepath} ({len(content)} bytes){RESET}", flush=True)
148 + open(filepath, "w").write(content)
149 + print(f"{DIM}[LOG] write completed: {filepath}{RESET}", flush=True)
150 + return "ok"
151 +
152 + def edit(args):
153 + filepath = args["path"]
154 + print(f"{DIM}[LOG] edit: {filepath}{RESET}", flush=True)
155 + text = open(filepath).read()
156 + print(f"{DIM}[LOG] edit read: {len(text)} bytes{RESET}", flush=True)
157 + old, new = args["old"], args["new"]
158 + if old not in text: return "error: old_string not found"
159 + count = text.count(old)
160 + if not args.get("all") and count > 1:
161 + return f"error: old_string appears {count} times (use all=true)"
162 + result = text.replace(old, new) if args.get("all") else text.replace(old, new, 1)
163 + print(f"{DIM}[LOG] edit writing: {len(result)} bytes{RESET}", flush=True)
164 + open(filepath, "w").write(result)
165 + print(f"{DIM}[LOG] edit completed: {filepath}{RESET}", flush=True)
166 + return "ok"
167 +
168 + def glob(args):
169 + pattern = (args.get("path", ".") + "/" + args["pat"]).replace("//", "/")
170 + files = sorted(globlib.glob(pattern, recursive=True),
171 + key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0, reverse=True)
172 + return "\n".join(files) or "none"
173 +
174 + def grep(args):
175 + pattern, hits = re.compile(args["pat"]), []
176 + for fp in globlib.glob(args.get("path", ".") + "/**", recursive=True):
177 + try:
178 + for n, l in enumerate(open(fp), 1):
179 + if pattern.search(l): hits.append(f"{fp}:{n}:{l.rstrip()}")
180 + except: pass
181 + return "\n".join(hits[:50]) or "none"
182 +
183 + def bash(args):
184 + global stop_flag
185 + proc = subprocess.Popen(args["cmd"], shell=True, stdout=subprocess.PIPE,
186 + stderr=subprocess.STDOUT, text=True)
187 + lines = []
188 + old_settings = termios.tcgetattr(sys.stdin)
189 + try:
190 + tty.setcbreak(sys.stdin.fileno())
191 + if proc.stdout:
192 + import fcntl
193 + fd = proc.stdout.fileno()
194 + fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK)
195 +
196 + while True:
197 + # Check ESC key
198 + if select.select([sys.stdin], [], [], 0)[0]:
199 + if sys.stdin.read(1) == '\x1b':
200 + stop_flag = True
201 + proc.kill()
202 + lines.append("\n(stopped)")
203 + print(f"\n{YELLOW}⏸ Stopped{RESET}")
204 + break
205 +
206 + # Read output
207 + if select.select([proc.stdout], [], [], 0.1)[0]:
208 + line = proc.stdout.readline()
209 + if line:
210 + print(f" {DIM}│ {line.rstrip()}{RESET}", flush=True)
211 + lines.append(line)
212 +
213 + # Check if done
214 + if proc.poll() is not None:
215 + remaining = proc.stdout.read()
216 + if remaining:
217 + for line in remaining.split('\n'):
218 + if line:
219 + print(f" {DIM}│ {line.rstrip()}{RESET}", flush=True)
220 + lines.append(line + '\n')
221 + break
222 +
223 + if not stop_flag:
224 + proc.wait(timeout=30)
225 + except subprocess.TimeoutExpired:
226 + proc.kill()
227 + lines.append("\n(timeout)")
228 + finally:
229 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
230 +
231 + return "".join(lines).strip() or "(empty)"
232 +
233 + def web_search(args):
234 + """Search web using DuckDuckGo"""
235 + query, max_results = args["query"], args.get("max_results", 5)
236 + try:
237 + url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote_plus(query)}"
238 + headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
239 + opener = create_opener()
240 + html = opener.open(urllib.request.Request(url, headers=headers), timeout=30).read().decode()
241 +
242 + # Extract titles and URLs
243 + links = re.findall(r'class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<', html)
244 + # Extract snippets
245 + snippets = re.findall(r'class="result__snippet"[^>]*>([^<]*)<', html)
246 + if not links: return "No results found"
247 +
248 + results = []
249 + for i, ((link, title), snippet) in enumerate(zip(links[:max_results], snippets[:max_results] + [""] * max_results), 1):
250 + results.append(f"{i}. {title.strip()}\n URL: {link}\n {snippet.strip()}\n")
251 + return "\n".join(results)
252 + except Exception as e:
253 + return f"error: {e}"
254 +
255 +
256 + TOOLS = {
257 + "read": ("Read file with line numbers", {"path": "string", "offset": "number?", "limit": "number?"}, read),
258 + "write": ("Write content to file", {"path": "string", "content": "string"}, write),
259 + "edit": ("Replace old with new in file", {"path": "string", "old": "string", "new": "string", "all": "boolean?"}, edit),
260 + "glob": ("Find files by pattern", {"pat": "string", "path": "string?"}, glob),
261 + "grep": ("Search files for regex", {"pat": "string", "path": "string?"}, grep),
262 + "bash": ("Run shell command", {"cmd": "string"}, bash),
263 + "web_search": ("Search the web using DuckDuckGo", {"query": "string", "max_results": "number?"}, web_search),
264 + "search_extension": ("Search for extensions to add new capabilities (GitHub docs, web scraping, APIs, etc)", {"query": "string"}, search_extension),
265 + "load": ("Load extension from URL to add new tools", {"url": "string"}, load),
266 + }
267 +
268 + def run_tool(name, args):
269 + try: return TOOLS[name][2](args)
270 + except Exception as e: return f"error: {e}"
271 +
272 + def make_schema():
273 + result = []
274 + for name, (desc, params, _) in TOOLS.items():
275 + props, req = {}, []
276 + for pname, ptype in params.items():
277 + opt = ptype.endswith("?")
278 + props[pname] = {"type": "integer" if ptype.rstrip("?") == "number" else ptype.rstrip("?")}
279 + if not opt: req.append(pname)
280 + result.append({"name": name, "description": desc,
281 + "input_schema": {"type": "object", "properties": props, "required": req}})
282 + return result
283 +
284 + def call_api(messages, system_prompt, stream=True, enable_thinking=True, use_tools=True):
285 + headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01"}
286 + if LOCAL_API_KEY: headers["Authorization"] = f"Bearer {LOCAL_API_KEY}"
287 + elif OPENROUTER_KEY: headers["Authorization"] = f"Bearer {OPENROUTER_KEY}"
288 + else: headers["x-api-key"] = os.environ.get("ANTHROPIC_API_KEY", "")
289 +
290 + data = {"model": MODEL, "max_tokens": 8192, "system": system_prompt,
291 + "messages": messages, "stream": stream}
292 +
293 + if use_tools:
294 + data["tools"] = make_schema()
295 +
296 + if enable_thinking and os.environ.get("THINKING"):
297 + data["thinking"] = {"type": "enabled", "budget_tokens": int(os.environ.get("THINKING_BUDGET", "10000"))}
298 +
299 + req = urllib.request.Request(API_URL, json.dumps(data).encode(), headers, method="POST")
300 + return create_opener().open(req)
301 +
302 + def summarize_changes(user_input, files_modified, checkpoint_manager, checkpoint_id):
303 + """Use LLM to summarize the changes made in this turn
304 +
305 + Args:
306 + user_input: User's request
307 + files_modified: Set of modified file paths
308 + checkpoint_manager: CheckpointManager instance
309 + checkpoint_id: Checkpoint hash to get diff from
310 +
311 + Returns:
312 + str: One-line summary of changes
313 + """
314 + if not files_modified or not checkpoint_id:
315 + return user_input[:50]
316 +
317 + try:
318 + # Get diff from git
319 + diff_output = checkpoint_manager._git_command(
320 + "--git-dir", checkpoint_manager.bare_repo,
321 + "show", "--format=", checkpoint_id
322 + )
323 +
324 + # Check if diff is empty or error - no actual changes
325 + if not diff_output or diff_output.startswith("error") or len(diff_output.strip()) == 0:
326 + # No diff available, just use user input
327 + return user_input[:50]
328 +
329 + # Limit diff size to avoid token overflow (max ~3000 chars)
330 + if len(diff_output) > 3000:
331 + diff_output = diff_output[:3000] + "\n... (truncated)"
332 +
333 + summary_prompt = f"""Based on the actual code changes (diff), generate a brief Chinese summary (max 30 Chinese characters).
334 +
335 + IMPORTANT: Must be based on the actual code changes, not the user's description.
336 +
337 + Code changes (diff):
338 + {diff_output}
339 +
340 + User description (for reference only): {user_input}
341 +
342 + Requirements:
343 + 1. Describe what code/functionality was actually modified
344 + 2. Reply in Chinese only, no explanation
345 + 3. No quotes
346 + 4. Max 30 Chinese characters
347 +
348 + Good examples:
349 + - 在 auth.py 添加 JWT 验证
350 + - 修复 parser.py 空指针异常
351 + - 重构 database.py 连接池
352 + - 更新 README 添加安装说明
353 + """
354 +
355 + messages = [{"role": "user", "content": summary_prompt}]
356 + response = call_api(messages, "You are a code change analyzer, skilled at extracting key information from diffs. Reply in Chinese.",
357 + stream=False, enable_thinking=False, use_tools=False)
358 +
359 + # Parse non-streaming response
360 + data = json.loads(response.read().decode())
361 + blocks = data.get("content", [])
362 +
363 + for block in blocks:
364 + if block.get("type") == "text":
365 + summary = block.get("text", "").strip()
366 +
367 + # Remove thinking tags if present
368 + if "<thinking>" in summary:
369 + # Extract content after </thinking>
370 + parts = summary.split("</thinking>")
371 + if len(parts) > 1:
372 + summary = parts[-1].strip()
373 +
374 + # Clean up and limit length
375 + summary = summary.replace('"', '').replace("'", "")
376 + if summary and len(summary) <= 80:
377 + return summary
378 +
379 + # Fallback to user input
380 + return user_input[:50]
381 + except Exception as e:
382 + # On error, fallback to user input
383 + return user_input[:50]
384 +
385 + def process_stream(response):
386 + """简化的流式处理,支持ESC中断"""
387 + global stop_flag
388 + blocks, current, text_buf, json_buf, think_buf = [], None, "", "", ""
389 +
390 + # Save terminal settings
391 + old_settings = termios.tcgetattr(sys.stdin)
392 + try:
393 + tty.setcbreak(sys.stdin.fileno())
394 +
395 + for line in response:
396 + if select.select([sys.stdin], [], [], 0)[0]:
397 + ch = sys.stdin.read(1)
398 + if ch == '\x1b': # ESC key
399 + stop_flag = True
400 + print(f"\n{YELLOW}⏸ Stopped{RESET}")
401 + break
402 +
403 + line = line.decode("utf-8").strip()
404 + if not line.startswith("data: "): continue
405 + if line == "data: [DONE]": continue
406 +
407 + try:
408 + data = json.loads(line[6:])
409 + etype = data.get("type")
410 +
411 + if etype == "content_block_start":
412 + block = data.get("content_block", {})
413 + current = {"type": block.get("type"), "id": block.get("id")}
414 + if current["type"] == "text":
415 + text_buf = ""
416 + print(f"\n{CYAN}⏺{RESET} ", end="", flush=True)
417 + elif current["type"] == "thinking":
418 + think_buf = ""
419 + print(f"\n{YELLOW}💭{RESET} {DIM}", end="", flush=True)
420 + elif current["type"] == "tool_use":
421 + current["name"] = block.get("name")
422 + json_buf = ""
423 +
424 + elif etype == "content_block_delta":
425 + delta = data.get("delta", {})
426 + dtype = delta.get("type")
427 + if dtype == "text_delta":
428 + text = delta.get("text", "")
429 + text_buf += text
430 + print(text, end="", flush=True)
431 + elif dtype == "thinking_delta":
432 + text = delta.get("thinking", "")
433 + think_buf += text
434 + print(text, end="", flush=True)
435 + elif dtype == "input_json_delta" and current:
436 + json_buf += delta.get("partial_json", "")
437 +
438 + elif etype == "content_block_stop" and current:
439 + if current["type"] == "text":
440 + current["text"] = text_buf
441 + print()
442 + elif current["type"] == "thinking":
443 + print(RESET)
444 + elif current["type"] == "tool_use":
445 + try: current["input"] = json.loads(json_buf)
446 + except: current["input"] = {}
447 + blocks.append(current)
448 + current = None
449 + except: pass
450 + finally:
451 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
452 +
453 + return blocks
454 +
455 + def is_file_in_project(filepath, project_path):
456 + """Check if file is within project directory"""
457 + try:
458 + abs_file = os.path.abspath(filepath)
459 + abs_project = os.path.abspath(project_path)
460 + # Check if file is under project directory
461 + return abs_file.startswith(abs_project + os.sep) or abs_file == abs_project
462 + except:
463 + return False
464 +
465 + def read_multiline_input():
466 + """Read multiline input. Enter to submit, Alt+Enter for newline."""
467 + lines = []
468 + current = ""
469 + cursor_pos = 0 # Cursor position in current line
470 +
471 + # Enable bracketed paste mode
472 + print("\033[?2004h", end="", flush=True)
473 +
474 + old_settings = termios.tcgetattr(sys.stdin)
475 + try:
476 + tty.setcbreak(sys.stdin.fileno())
477 + print(f"{BOLD}{BLUE}❯{RESET} ", end="", flush=True)
478 +
479 + while True:
480 + ch = sys.stdin.read(1)
481 +
482 + if ch == '\x03': # Ctrl+C - clear input
483 + lines.clear()
484 + current = ""
485 + cursor_pos = 0
486 + print("\r\033[K", end="", flush=True)
487 + print(f"{BOLD}{BLUE}❯{RESET} ", end="", flush=True)
488 + continue
489 +
490 + if ch == '\x04': # Ctrl+D
491 + raise EOFError
492 +
493 + if ch == '\x1b': # Escape sequence
494 + next_ch = sys.stdin.read(1)
495 + if next_ch in ('\r', '\n'): # Alt+Enter
496 + lines.append(current)
497 + current = ""
498 + cursor_pos = 0
499 + print(f"\n{BOLD}{BLUE}│{RESET} ", end="", flush=True)
500 + elif next_ch == '[': # Escape sequence
501 + seq = sys.stdin.read(1)
502 + if seq == 'C': # Right arrow
503 + if cursor_pos < len(current):
504 + cursor_pos += 1
505 + print("\033[C", end="", flush=True)
506 + elif seq == 'D': # Left arrow
507 + if cursor_pos > 0:
508 + cursor_pos -= 1
509 + print("\033[D", end="", flush=True)
510 + elif seq == '2': # Bracketed paste start: ESC[200~
511 + rest = sys.stdin.read(3) # Read "00~"
512 + if rest == '00~':
513 + # Read pasted content until ESC[201~
514 + paste_buf = ""
515 + while True:
516 + c = sys.stdin.read(1)
517 + if c == '\x1b':
518 + # Check for [201~
519 + peek = sys.stdin.read(5)
520 + if peek == '[201~':
521 + break
522 + else:
523 + paste_buf += c + peek
524 + else:
525 + paste_buf += c
526 +
527 + # Process pasted content
528 + paste_lines = paste_buf.split('\n')
529 +
530 + if len(paste_lines) == 1:
531 + # Single line paste
532 + current = current[:cursor_pos] + paste_lines[0] + current[cursor_pos:]
533 + cursor_pos += len(paste_lines[0])
534 + prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
535 + print(f"\r\033[K{prefix}{current}", end="", flush=True)
536 + else:
537 + # Multi-line paste
538 + # First line appends to current
539 + first_line = current[:cursor_pos] + paste_lines[0]
540 + print(paste_lines[0], end="", flush=True)
541 + if first_line:
542 + lines.append(first_line)
543 +
544 + # Middle lines
545 + for line in paste_lines[1:-1]:
546 + print(f"\n{BOLD}{BLUE}│{RESET} {line}", end="", flush=True)
547 + lines.append(line)
548 +
549 + # Last line becomes new current
550 + current = paste_lines[-1]
551 + cursor_pos = len(current)
552 + print(f"\n{BOLD}{BLUE}│{RESET} {current}", end="", flush=True)
553 + continue
554 +
555 + if ch in ('\r', '\n'): # Enter - submit
556 + if current:
557 + lines.append(current)
558 + print()
559 + break
560 +
561 + if ch in ('\x7f', '\x08'): # Backspace
562 + if cursor_pos > 0:
563 + # Delete character before cursor
564 + current = current[:cursor_pos-1] + current[cursor_pos:]
565 + cursor_pos -= 1
566 + # Redraw current line
567 + prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
568 + print(f"\r\033[K{prefix}{current}", end="", flush=True)
569 + # Move cursor back to position
570 + if cursor_pos < len(current):
571 + print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
572 + elif lines:
573 + # Merge with previous line
574 + prev_line = lines.pop()
575 + cursor_pos = len(prev_line) # Cursor at end of previous line
576 + current = prev_line + current
577 + # Move up and redraw
578 + print("\033[A\033[K", end="", flush=True)
579 + prefix = f"{BOLD}{BLUE}{'│' if lines else '❯'}{RESET} "
580 + print(f"\r{prefix}{current}", end="", flush=True)
581 + if cursor_pos < len(current):
582 + print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
583 + continue
584 +
585 + if ch.isprintable() or ch == '\t':
586 + # Insert character at cursor position
587 + current = current[:cursor_pos] + ch + current[cursor_pos:]
588 + cursor_pos += 1
589 + # Redraw from cursor position
590 + print(f"{ch}{current[cursor_pos:]}", end="", flush=True)
591 + # Move cursor back if needed
592 + if cursor_pos < len(current):
593 + print(f"\033[{len(current) - cursor_pos}D", end="", flush=True)
594 +
595 + finally:
596 + # Disable bracketed paste mode
597 + print("\033[?2004l", end="", flush=True)
598 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
599 +
600 + return "\n".join(lines).strip()
601 +
602 + def main():
603 + global stop_flag
604 + # Parse command line arguments
605 + continue_session = "-c" in sys.argv or "--continue" in sys.argv
606 + list_sessions = "-l" in sys.argv or "--list" in sys.argv
607 +
608 + # Disable Ctrl+C signal
609 + old_settings = termios.tcgetattr(sys.stdin)
610 + new_settings = termios.tcgetattr(sys.stdin)
611 + new_settings[3] = new_settings[3] & ~termios.ISIG # Disable signal generation
612 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
613 +
614 + try:
615 + proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
616 + proxy_info = f" | {DIM}🌐 {proxy}{RESET}" if proxy else ""
617 + thinking_info = f" | {YELLOW}💭{RESET}" if os.environ.get("THINKING") else ""
618 +
619 + if list_sessions:
620 + session_mode = f" | {YELLOW}Select{RESET}"
621 + elif continue_session:
622 + session_mode = f" | {GREEN}Continue{RESET}"
623 + else:
624 + session_mode = f" | {CYAN}New{RESET}"
625 +
626 + print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
627 + print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+C=clear input | Ctrl+D=exit | ESC=stop{RESET}")
628 + print(f"{DIM}Commands: /c [all|<id>] | /ca | /clear{RESET}")
629 + print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
630 +
631 + selected_session_id = None
632 + if list_sessions:
633 + selected_session_id = select_session_interactive()
634 + if not selected_session_id:
635 + print(f"{DIM}Exiting...{RESET}")
636 + return
637 +
638 + run_main_loop(continue_session, selected_session_id)
639 + finally:
640 + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
641 +
642 + def select_session_interactive():
643 + """Display sessions and let user select one
644 +
645 + Returns:
646 + session_id: Selected session ID, or None if cancelled
647 + """
648 + session_manager = SessionManager(os.getcwd())
649 + sessions = session_manager.list_sessions()[:10] # Limit to 10 most recent
650 +
651 + if not sessions:
652 + print(f"{YELLOW}⚠ No previous sessions found{RESET}")
653 + print(f"{DIM}Starting new session...{RESET}\n")
654 + return None
655 +
656 + print(f"{BOLD}📂 Recent Sessions:{RESET}\n")
657 +
658 + for i, sess_info in enumerate(sessions, 1):
659 + created = datetime.fromtimestamp(sess_info['metadata']['created_at']).strftime('%Y-%m-%d %H:%M')
660 + last_active = datetime.fromtimestamp(sess_info['metadata']['last_active']).strftime('%Y-%m-%d %H:%M')
661 + desc = sess_info['metadata'].get('description', '(no description)')
662 +
663 + # Git info
664 + git_commit = sess_info['metadata'].get('git_commit')
665 + git_branch = sess_info['metadata'].get('git_branch')
666 + git_dirty = sess_info['metadata'].get('git_dirty', False)
667 +
668 + print(f"{CYAN}{i}.{RESET} {BOLD}{sess_info['session_id']}{RESET}")
669 + print(f" {desc}")
670 +
671 + git_info = ""
672 + if git_commit and git_branch:
673 + dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
674 + git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
675 +
676 + print(f" Created: {created} | Last: {last_active} | {sess_info['message_count']} messages{git_info}\n")
677 +
678 + print(f"{DIM}Enter session number (1-{len(sessions)}), or press Enter for new session:{RESET}")
679 +
680 + try:
681 + choice = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
682 +
683 + if not choice:
684 + # Empty input = new session
685 + return None
686 +
687 + try:
688 + idx = int(choice) - 1
689 + if 0 <= idx < len(sessions):
690 + return sessions[idx]['session_id']
691 + else:
692 + print(f"{RED}✗ Invalid number{RESET}")
693 + return None
694 + except ValueError:
695 + print(f"{RED}✗ Invalid input{RESET}")
696 + return None
697 + except (EOFError, KeyboardInterrupt):
698 + return None
699 +
700 +
701 + def run_main_loop(continue_session=False, selected_session_id=None):
702 + # Initialize session manager
703 + session_manager = SessionManager(os.getcwd())
704 +
705 + # Load or create session based on parameters
706 + if selected_session_id:
707 + # Load specific session selected by user
708 + session = session_manager.load_session(selected_session_id)
709 + if session:
710 + git_info = ""
711 + git_commit = session.metadata.get('git_commit')
712 + git_branch = session.metadata.get('git_branch')
713 + if git_commit and git_branch:
714 + git_dirty = session.metadata.get('git_dirty', False)
715 + dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
716 + git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
717 +
718 + print(f"{GREEN}✓ Loaded session: {session.session_id}{RESET}")
719 + print(f"{DIM} └─ {len(session.messages)} messages{git_info}{RESET}")
720 +
721 + # Check for conflicts
722 + conflicts = session.detect_conflicts()
723 + if conflicts:
724 + print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
725 + for filepath in conflicts[:5]:
726 + print(f" - {filepath}")
727 + if len(conflicts) > 5:
728 + print(f" ... and {len(conflicts)-5} more")
729 + print(f"\n{DIM}These files have been modified outside this session.{RESET}")
730 + confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
731 +
732 + if confirm == 'u':
733 + session.update_file_states()
734 + session_manager.save_session()
735 + print(f"{GREEN}✓ Updated file states{RESET}\n")
736 + elif confirm != 'y':
737 + print(f"{DIM}Creating new session instead...{RESET}\n")
738 + session_manager.create_session()
739 + else:
740 + print()
741 + else:
742 + print()
743 + else:
744 + print(f"{RED}✗ Failed to load session{RESET}")
745 + print(f"{GREEN}✓ Creating new session instead{RESET}\n")
746 + session_manager.create_session()
747 + elif continue_session:
748 + # Continue last session
749 + last_session = session_manager.load_last_session()
750 + if last_session:
751 + git_info = ""
752 + git_commit = last_session.metadata.get('git_commit')
753 + git_branch = last_session.metadata.get('git_branch')
754 + if git_commit and git_branch:
755 + git_dirty = last_session.metadata.get('git_dirty', False)
756 + dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
757 + git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
758 +
759 + print(f"{GREEN}✓ Continued session: {last_session.session_id}{RESET}")
760 + print(f"{DIM} └─ {len(last_session.messages)} messages{git_info}{RESET}")
761 +
762 + # Check for conflicts
763 + conflicts = last_session.detect_conflicts()
764 + if conflicts:
765 + print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
766 + for filepath in conflicts[:5]:
767 + print(f" - {filepath}")
768 + if len(conflicts) > 5:
769 + print(f" ... and {len(conflicts)-5} more")
770 + print(f"\n{DIM}These files have been modified outside this session.{RESET}")
771 + confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
772 +
773 + if confirm == 'u':
774 + last_session.update_file_states()
775 + session_manager.save_session()
776 + print(f"{GREEN}✓ Updated file states{RESET}\n")
777 + elif confirm != 'y':
778 + print(f"{DIM}Creating new session instead...{RESET}\n")
779 + session_manager.create_session()
780 + else:
781 + print()
782 + else:
783 + print()
784 + else:
785 + # No previous session, create new one
786 + session_manager.create_session()
787 + print(f"{YELLOW}⚠ No previous session found{RESET}")
788 + print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}\n")
789 + else:
790 + # Always create new session by default
791 + # Try to detect parent from last session's latest checkpoint
792 + parent_checkpoint = None
793 + parent_session = None
794 +
795 + last_session = session_manager.load_last_session()
796 + if last_session:
797 + # Get the latest checkpoint from last session
798 + checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
799 + if checkpoints:
800 + parent_checkpoint = checkpoints[0][0] # Latest checkpoint hash
801 + parent_session = last_session.session_id
802 +
803 + session_manager.create_session(
804 + parent_checkpoint=parent_checkpoint,
805 + parent_session=parent_session
806 + )
807 +
808 + git_info = ""
809 + git_commit = session_manager.current_session.metadata.get('git_commit')
810 + git_branch = session_manager.current_session.metadata.get('git_branch')
811 + if git_commit and git_branch:
812 + git_dirty = session_manager.current_session.metadata.get('git_dirty', False)
813 + dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
814 + git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
815 +
816 + if parent_checkpoint:
817 + print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
818 + print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{git_info}{RESET}\n")
819 + else:
820 + print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
821 + if git_info:
822 + print(f"{DIM} └─{git_info}{RESET}\n")
823 + else:
824 + print()
825 +
826 + files_modified = set()
827 + auto_checkpoint = True
828 +
829 + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
830 + system_prompt = f"""Concise coding assistant. cwd: {os.getcwd()} Current time: {current_time}
831 + IMPORTANT: When you don't have a tool for the task, ALWAYS try search_extension first before saying you can't do it.
832 + Examples:
833 + - User asks about GitHub repo → search_extension({{"query": "github documentation"}})
834 + - User needs web data → search_extension({{"query": "web scraping"}})
835 + - User needs API → search_extension({{"query": "api client"}})"""
836 +
837 + while True:
838 + try:
839 + print(f"{DIM}{'─'*80}{RESET}")
840 + user_input = read_multiline_input()
841 + print(f"{DIM}{'─'*80}{RESET}")
842 +
843 + if not user_input: continue
844 + if user_input in ("/q", "exit"):
845 + session_manager.save_session()
846 + break
847 +
848 + # Handle /clear command first (before /c to avoid conflict)
849 + if user_input == "/clear":
850 + # Save current session
851 + session_manager.save_session()
852 +
853 + # Get latest checkpoint from current session (if any)
854 + checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
855 + parent_checkpoint = checkpoints[0][0] if checkpoints else None
856 + parent_session = session_manager.current_session.session_id
857 +
858 + # Create new session branched from current
859 + session_manager.create_session(
860 + parent_checkpoint=parent_checkpoint,
861 + parent_session=parent_session
862 + )
863 +
864 + # Reset state
865 + files_modified.clear()
866 +
867 + print(f"{GREEN}✓ Started new session: {session_manager.current_session.session_id}{RESET}")
868 + if parent_checkpoint:
869 + print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{RESET}")
870 + continue
871 +
872 + # Handle checkpoint commands
873 + if user_input.startswith("/checkpoint") or user_input.startswith("/c") or user_input == "/ca":
874 + parts = user_input.split()
875 +
876 + # /ca is shortcut for /c all
877 + if parts[0] == "/ca":
878 + parts = ["/c", "all"]
879 +
880 + # /c without args defaults to list
881 + if len(parts) == 1 and parts[0] in ["/c", "/checkpoint"]:
882 + parts.append("list")
883 +
884 + restored_messages = handle_checkpoint_command(parts, session_manager, files_modified)
885 + if restored_messages is not None:
886 + # Restore conversation by replacing session messages
887 + session_manager.current_session.messages = restored_messages
888 + session_manager.save_session()
889 + continue
890 +
891 + # Add user message to current session
892 + session_manager.current_session.messages.append({"role": "user", "content": user_input})
893 +
894 + # Reset stop flag for new turn
895 + stop_flag = False
896 +
897 + # Track files modified in this turn
898 + files_modified_this_turn = set()
899 +
900 + while True:
901 + response = call_api(session_manager.current_session.messages, system_prompt)
902 + blocks = process_stream(response)
903 + if stop_flag: break
904 +
905 + tool_results = []
906 + for block in blocks:
907 + if block["type"] == "tool_use":
908 + name, args = block["name"], block["input"]
909 + preview = str(list(args.values())[0])[:50] if args else ""
910 + print(f"\n{GREEN}⏺ {name}{RESET}({DIM}{preview}{RESET})")
911 +
912 + result = run_tool(name, args)
913 + lines = result.split("\n")
914 + prev = lines[0][:60] + ("..." if len(lines[0]) > 60 else "")
915 + if len(lines) > 1: prev += f" +{len(lines)-1}"
916 + print(f" {DIM}⎿ {prev}{RESET}")
917 +
918 + # Track file modifications (only project files)
919 + if name in ['write', 'edit']:
920 + filepath = args.get('path')
921 + if filepath and is_file_in_project(filepath, session_manager.project_path):
922 + files_modified.add(filepath)
923 + files_modified_this_turn.add(filepath)
924 + session_manager.current_session.track_file_state(filepath)
925 +
926 + tool_results.append({"type": "tool_result", "tool_use_id": block["id"], "content": result})
927 +
928 + # Check stop_flag after each tool execution
929 + if stop_flag:
930 + print(f"{YELLOW}⚠ Tool execution stopped{RESET}")
931 + break
932 +
933 + session_manager.current_session.messages.append({"role": "assistant", "content": blocks})
934 + if not tool_results or stop_flag: break
935 + session_manager.current_session.messages.append({"role": "user", "content": tool_results})
936 +
937 + # Auto checkpoint after AI work (if project files were modified)
938 + if auto_checkpoint and files_modified_this_turn:
939 + # files_modified_this_turn already filtered to project files only
940 + # Use parent_commit for first checkpoint of new session
941 + parent_commit = session_manager.parent_commit_for_next_checkpoint
942 + checkpoint_id = session_manager.checkpoint_manager.create_checkpoint(
943 + f"Auto: {user_input[:50]}",
944 + list(files_modified_this_turn),
945 + conversation_snapshot=session_manager.current_session.messages.copy(),
946 + parent_commit=parent_commit
947 + )
948 + # Clear parent after first checkpoint
949 + if parent_commit:
950 + session_manager.parent_commit_for_next_checkpoint = None
951 +
952 + if checkpoint_id:
953 + # Generate summary using LLM with actual diff
954 + print(f"{DIM}Generating checkpoint summary...{RESET}", end="", flush=True)
955 + summary = summarize_changes(
956 + user_input,
957 + files_modified_this_turn,
958 + session_manager.checkpoint_manager,
959 + checkpoint_id
960 + )
961 + print(f"\r{' ' * 40}\r", end="", flush=True) # Clear the line
962 +
963 + # Update commit message with better summary (only if different from temp message)
964 + temp_message = f"Auto: {user_input[:50]}"
965 + if summary != user_input[:50] and summary != temp_message:
966 + session_manager.checkpoint_manager._git_command(
967 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
968 + "commit", "--amend", "-m", summary
969 + )
970 +
971 + print(f"\n{YELLOW}📍 {checkpoint_id}: {summary}{RESET}")
972 + else:
973 + # Checkpoint creation failed (e.g., no actual diff)
974 + print(f"\n{DIM}(No project file changes to checkpoint){RESET}")
975 +
976 + # Auto-save session after each interaction
977 + session_manager.save_session()
978 +
979 + print()
980 + except EOFError:
981 + session_manager.save_session()
982 + break
983 + except Exception as e: print(f"{RED}⏺ Error: {e}{RESET}")
984 +
985 + # ============================================================================
986 + # Checkpoint & Session Management (Phase 1+2)
987 + # ============================================================================
988 +
989 + class CheckpointManager:
990 + """Manage checkpoints using shadow bare git repository with session isolation"""
991 +
992 + def __init__(self, project_path, session_id=None):
993 + self.project_path = project_path
994 + self.session_id = session_id
995 + self.nanocode_dir = os.path.join(project_path, ".nanocode")
996 + self.bare_repo = os.path.join(self.nanocode_dir, "checkpoint.git")
997 + self._init_bare_repo()
998 +
999 + def set_session(self, session_id):
1000 + """Set current session for checkpoint operations"""
1001 + self.session_id = session_id
1002 +
1003 + def _get_branch_name(self):
1004 + """Get git branch name for current session"""
1005 + if not self.session_id:
1006 + return "main"
1007 + return f"session_{self.session_id}"
1008 +
1009 + def _init_bare_repo(self):
1010 + """Initialize shadow bare repository"""
1011 + if not os.path.exists(self.bare_repo):
1012 + os.makedirs(self.bare_repo, exist_ok=True)
1013 + try:
1014 + subprocess.run(
1015 + ["git", "init", "--bare", self.bare_repo],
1016 + capture_output=True, check=True
1017 + )
1018 + except (subprocess.CalledProcessError, FileNotFoundError):
1019 + # Git not available, will handle gracefully
1020 + pass
1021 +
1022 + def _git_command(self, *args, cwd=None):
1023 + """Execute git command"""
1024 + try:
1025 + result = subprocess.run(
1026 + ["git"] + list(args),
1027 + cwd=cwd or self.project_path,
1028 + capture_output=True,
1029 + text=True,
1030 + check=True
1031 + )
1032 + return result.stdout.strip()
1033 + except (subprocess.CalledProcessError, FileNotFoundError) as e:
1034 + return f"error: {e}"
1035 +
1036 + def create_checkpoint(self, message, files_changed, conversation_snapshot=None, parent_commit=None):
1037 + """Create a checkpoint on current session's branch
1038 +
1039 + Args:
1040 + message: Commit message
1041 + files_changed: List of modified files
1042 + conversation_snapshot: Conversation state to save
1043 + parent_commit: Parent commit hash to branch from (for new sessions)
1044 + """
1045 + print(f"{DIM}[LOG] create_checkpoint: files_changed={files_changed}{RESET}", flush=True)
1046 + if not files_changed or not self.session_id:
1047 + return None
1048 +
1049 + branch_name = self._get_branch_name()
1050 +
1051 + # Save conversation snapshot
1052 + if conversation_snapshot:
1053 + snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1054 + snapshots = {}
1055 + if os.path.exists(snapshot_file):
1056 + with open(snapshot_file, 'r') as f:
1057 + snapshots = json.load(f)
1058 +
1059 + # Create temp worktree for this session
1060 + temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1061 +
1062 + try:
1063 + # Check if branch exists
1064 + branch_exists = self._git_command("--git-dir", self.bare_repo, "rev-parse", "--verify", branch_name)
1065 +
1066 + if not branch_exists or branch_exists.startswith("error"):
1067 + # Create new branch
1068 + os.makedirs(temp_worktree, exist_ok=True)
1069 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "config", "core.bare", "false")
1070 +
1071 + # If parent_commit specified, branch from it
1072 + if parent_commit:
1073 + # Create branch from parent commit
1074 + self._git_command("--git-dir", self.bare_repo, "branch", branch_name, parent_commit)
1075 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1076 + else:
1077 + # Create orphan branch (no parent)
1078 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", "--orphan", branch_name)
1079 +
1080 + # Copy files to temp worktree
1081 + for filepath in files_changed:
1082 + print(f"{DIM}[LOG] checkpoint copying: {filepath}{RESET}", flush=True)
1083 + if os.path.exists(filepath):
1084 + file_size = os.path.getsize(filepath)
1085 + print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1086 + # Convert absolute path to relative path
1087 + if os.path.isabs(filepath):
1088 + rel_filepath = os.path.relpath(filepath, self.project_path)
1089 + else:
1090 + rel_filepath = filepath
1091 + dest = os.path.join(temp_worktree, rel_filepath)
1092 + os.makedirs(os.path.dirname(dest), exist_ok=True)
1093 + with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1094 + content = src.read()
1095 + dst.write(content)
1096 + print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1097 + else:
1098 + print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1099 +
1100 + # Commit
1101 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1102 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1103 + "commit", "-m", message, "--allow-empty")
1104 +
1105 + commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1106 + checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1107 +
1108 + # Save conversation snapshot with checkpoint_id
1109 + if checkpoint_id and conversation_snapshot:
1110 + snapshots[checkpoint_id] = conversation_snapshot
1111 + with open(snapshot_file, 'w') as f:
1112 + json.dump(snapshots, f, indent=2)
1113 +
1114 + return checkpoint_id
1115 + else:
1116 + # Branch exists, checkout and commit
1117 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1118 +
1119 + # Update temp worktree
1120 + for filepath in files_changed:
1121 + print(f"{DIM}[LOG] checkpoint updating: {filepath}{RESET}", flush=True)
1122 + if os.path.exists(filepath):
1123 + file_size = os.path.getsize(filepath)
1124 + print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1125 + # Convert absolute path to relative path
1126 + if os.path.isabs(filepath):
1127 + rel_filepath = os.path.relpath(filepath, self.project_path)
1128 + else:
1129 + rel_filepath = filepath
1130 + dest = os.path.join(temp_worktree, rel_filepath)
1131 + os.makedirs(os.path.dirname(dest), exist_ok=True)
1132 + with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1133 + content = src.read()
1134 + dst.write(content)
1135 + print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1136 + else:
1137 + print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1138 +
1139 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1140 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1141 + "commit", "-m", message, "--allow-empty")
1142 +
1143 + commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1144 + checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1145 +
1146 + # Save conversation snapshot with checkpoint_id
1147 + if checkpoint_id and conversation_snapshot:
1148 + snapshots[checkpoint_id] = conversation_snapshot
1149 + with open(snapshot_file, 'w') as f:
1150 + json.dump(snapshots, f, indent=2)
1151 +
1152 + return checkpoint_id
1153 + except Exception as e:
1154 + return None
1155 +
1156 + def list_checkpoints(self, limit=10, show_all=False):
1157 + """List recent checkpoints for current session
1158 +
1159 + Args:
1160 + limit: Maximum number of checkpoints to show
1161 + show_all: If True, show all sessions; if False, only show current session
1162 + """
1163 + if not self.session_id and not show_all:
1164 + return []
1165 +
1166 + try:
1167 + if show_all:
1168 + # Show all branches
1169 + args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", "--all"]
1170 + else:
1171 + # Show only current session's branch
1172 + branch_name = self._get_branch_name()
1173 + args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", branch_name]
1174 +
1175 + log = self._git_command(*args)
1176 + if log and not log.startswith("error"):
1177 + return [line.split(" ", 1) for line in log.split("\n") if line]
1178 + return []
1179 + except:
1180 + return []
1181 +
1182 + def restore_checkpoint(self, checkpoint_id):
1183 + """Restore files to checkpoint state and reset current session's branch
1184 +
1185 + Returns:
1186 + tuple: (success: bool, conversation_snapshot: dict or None)
1187 + """
1188 + if not self.session_id:
1189 + return False, None
1190 +
1191 + branch_name = self._get_branch_name()
1192 + temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1193 +
1194 + try:
1195 + # Checkout branch first
1196 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1197 +
1198 + # Reset branch to checkpoint (discards future commits on this branch)
1199 + self._git_command("--git-dir", self.bare_repo, "reset", "--hard", checkpoint_id)
1200 +
1201 + # Checkout to temp worktree
1202 + self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1203 + "checkout", checkpoint_id, "-f")
1204 +
1205 + # Copy files back to project
1206 + for root, dirs, files in os.walk(temp_worktree):
1207 + for file in files:
1208 + src = os.path.join(root, file)
1209 + rel_path = os.path.relpath(src, temp_worktree)
1210 + dest = os.path.join(self.project_path, rel_path)
1211 +
1212 + os.makedirs(os.path.dirname(dest), exist_ok=True)
1213 + with open(src, 'rb') as s, open(dest, 'wb') as d:
1214 + d.write(s.read())
1215 +
1216 + # Load conversation snapshot
1217 + snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1218 + conversation_snapshot = None
1219 + if os.path.exists(snapshot_file):
1220 + with open(snapshot_file, 'r') as f:
1221 + snapshots = json.load(f)
1222 + conversation_snapshot = snapshots.get(checkpoint_id)
1223 +
1224 + return True, conversation_snapshot
1225 + except:
1226 + return False, None
1227 +
1228 +
1229 + class Session:
1230 + """Represents a conversation session"""
1231 +
1232 + def __init__(self, session_id=None):
1233 + self.session_id = session_id or self._generate_session_id()
1234 + self.messages = []
1235 + self.file_states = {}
1236 + self.metadata = {
1237 + 'created_at': time.time(),
1238 + 'last_active': time.time(),
1239 + 'description': '',
1240 + 'cwd': os.getcwd(),
1241 + 'parent_checkpoint': None, # Track where this session branched from
1242 + 'parent_session': None, # Track which session it branched from
1243 + 'git_commit': None, # Project .git commit hash when session started
1244 + 'git_branch': None, # Project .git branch when session started
1245 + 'git_dirty': False, # Whether project had uncommitted changes
1246 + }
1247 +
1248 + def _generate_session_id(self):
1249 + """Generate unique session ID"""
1250 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
1251 + random_suffix = ''.join(random.choices('0123456789abcdef', k=4))
1252 + return f"{timestamp}_{random_suffix}"
1253 +
1254 + def _get_project_git_info(self):
1255 + """Get current project git state"""
1256 + try:
1257 + cwd = self.metadata.get('cwd', os.getcwd())
1258 +
1259 + # Get current commit
1260 + commit = subprocess.run(
1261 + ["git", "rev-parse", "HEAD"],
1262 + cwd=cwd,
1263 + capture_output=True, text=True, check=True
1264 + ).stdout.strip()
1265 +
1266 + # Get current branch
1267 + branch = subprocess.run(
1268 + ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1269 + cwd=cwd,
1270 + capture_output=True, text=True, check=True
1271 + ).stdout.strip()
1272 +
1273 + # Check if dirty
1274 + status = subprocess.run(
1275 + ["git", "status", "--porcelain"],
1276 + cwd=cwd,
1277 + capture_output=True, text=True, check=True
1278 + ).stdout.strip()
1279 +
1280 + return {
1281 + 'git_commit': commit[:8],
1282 + 'git_branch': branch,
1283 + 'git_dirty': bool(status)
1284 + }
1285 + except:
1286 + return None
1287 +
1288 + def capture_git_state(self):
1289 + """Capture current project git state into metadata"""
1290 + git_info = self._get_project_git_info()
1291 + if git_info:
1292 + self.metadata.update(git_info)
1293 +
1294 + def track_file_state(self, filepath):
1295 + """Track file state for conflict detection"""
1296 + if os.path.exists(filepath):
1297 + with open(filepath, 'rb') as f:
1298 + content = f.read()
1299 + file_hash = hashlib.md5(content).hexdigest()
1300 + self.file_states[filepath] = {
1301 + 'hash': file_hash,
1302 + 'mtime': os.path.getmtime(filepath),
1303 + 'size': len(content)
1304 + }
1305 +
1306 + def detect_conflicts(self):
1307 + """Detect if tracked files have been modified outside this session
1308 +
1309 + Returns:
1310 + list: List of conflicted file paths
1311 + """
1312 + conflicts = []
1313 +
1314 + for filepath, saved_state in self.file_states.items():
1315 + if os.path.exists(filepath):
1316 + with open(filepath, 'rb') as f:
1317 + content = f.read()
1318 + current_hash = hashlib.md5(content).hexdigest()
1319 + current_mtime = os.path.getmtime(filepath)
1320 +
1321 + # Check if file has changed
1322 + if (current_hash != saved_state['hash'] or
1323 + current_mtime != saved_state['mtime']):
1324 + conflicts.append(filepath)
1325 + else:
1326 + # File was deleted
1327 + conflicts.append(f"{filepath} (deleted)")
1328 +
1329 + return conflicts
1330 +
1331 + def update_file_states(self):
1332 + """Update all tracked file states to current state"""
1333 + for filepath in list(self.file_states.keys()):
1334 + if os.path.exists(filepath):
1335 + self.track_file_state(filepath)
1336 + else:
1337 + # Remove deleted files from tracking
1338 + del self.file_states[filepath]
1339 +
1340 + def to_dict(self):
1341 + """Serialize to dict"""
1342 + return {
1343 + 'session_id': self.session_id,
1344 + 'messages': self.messages,
1345 + 'file_states': self.file_states,
1346 + 'metadata': self.metadata
1347 + }
1348 +
1349 + @staticmethod
1350 + def from_dict(data):
1351 + """Deserialize from dict"""
1352 + session = Session(session_id=data['session_id'])
1353 + session.messages = data.get('messages', [])
1354 + session.file_states = data.get('file_states', {})
1355 + session.metadata = data.get('metadata', {})
1356 + return session
1357 +
1358 +
1359 + class SessionManager:
1360 + """Manage multiple sessions"""
1361 +
1362 + def __init__(self, project_path):
1363 + self.project_path = project_path
1364 + self.sessions_dir = os.path.join(project_path, ".nanocode", "sessions")
1365 + self.current_session = None
1366 + self.checkpoint_manager = CheckpointManager(project_path)
1367 + self.parent_commit_for_next_checkpoint = None # Track parent for first checkpoint
1368 + os.makedirs(self.sessions_dir, exist_ok=True)
1369 +
1370 + def create_session(self, description="", parent_checkpoint=None, parent_session=None):
1371 + """Create new session
1372 +
1373 + Args:
1374 + description: Session description
1375 + parent_checkpoint: Checkpoint ID this session branches from
1376 + parent_session: Session ID this session branches from
1377 + """
1378 + session = Session()
1379 + session.metadata['description'] = description
1380 + session.metadata['parent_checkpoint'] = parent_checkpoint
1381 + session.metadata['parent_session'] = parent_session
1382 + # Capture project git state
1383 + session.capture_git_state()
1384 + self.current_session = session
1385 + # Set checkpoint manager to use this session
1386 + self.checkpoint_manager.set_session(session.session_id)
1387 + # Store parent commit for first checkpoint
1388 + self.parent_commit_for_next_checkpoint = parent_checkpoint
1389 + self.save_session()
1390 + return session
1391 +
1392 + def save_session(self):
1393 + """Save current session to disk"""
1394 + if not self.current_session:
1395 + return
1396 +
1397 + self.current_session.metadata['last_active'] = time.time()
1398 + session_file = os.path.join(
1399 + self.sessions_dir,
1400 + f"{self.current_session.session_id}.json"
1401 + )
1402 +
1403 + with open(session_file, 'w') as f:
1404 + json.dump(self.current_session.to_dict(), f, indent=2)
1405 +
1406 + def load_session(self, session_id):
1407 + """Load session from disk"""
1408 + session_file = os.path.join(self.sessions_dir, f"{session_id}.json")
1409 +
1410 + if not os.path.exists(session_file):
1411 + return None
1412 +
1413 + with open(session_file, 'r') as f:
1414 + data = json.load(f)
1415 +
1416 + session = Session.from_dict(data)
1417 + self.current_session = session
1418 + # Set checkpoint manager to use this session
1419 + self.checkpoint_manager.set_session(session.session_id)
1420 + return session
1421 +
1422 + def list_sessions(self):
1423 + """List all sessions"""
1424 + sessions = []
1425 +
1426 + if not os.path.exists(self.sessions_dir):
1427 + return sessions
1428 +
1429 + for filename in os.listdir(self.sessions_dir):
1430 + if filename.endswith('.json'):
1431 + filepath = os.path.join(self.sessions_dir, filename)
1432 + try:
1433 + with open(filepath, 'r') as f:
1434 + data = json.load(f)
1435 + sessions.append({
1436 + 'session_id': data['session_id'],
1437 + 'metadata': data['metadata'],
1438 + 'message_count': len(data.get('messages', [])),
1439 + })
1440 + except:
1441 + pass
1442 +
1443 + return sorted(sessions, key=lambda x: x['metadata'].get('last_active', 0), reverse=True)
1444 +
1445 + def load_last_session(self):
1446 + """Load the most recent session"""
1447 + sessions = self.list_sessions()
1448 + if sessions:
1449 + return self.load_session(sessions[0]['session_id'])
1450 + return None
1451 +
1452 +
1453 + def handle_checkpoint_command(parts, session_manager, files_modified):
1454 + """Handle /checkpoint or /c commands
1455 +
1456 + Returns:
1457 + messages: New messages list if conversation was restored, None otherwise
1458 + """
1459 + # Default to list if no subcommand
1460 + if len(parts) < 2:
1461 + parts.append("list")
1462 +
1463 + cmd = parts[1]
1464 +
1465 + # If cmd looks like a commit hash (7-8 hex chars), treat as restore
1466 + if len(cmd) >= 7 and len(cmd) <= 8 and all(c in '0123456789abcdef' for c in cmd.lower()):
1467 + cmd = "restore"
1468 + checkpoint_id = parts[1]
1469 + else:
1470 + checkpoint_id = None
1471 +
1472 + if cmd == "list" or cmd == "all" or cmd == "--all":
1473 + show_all = (cmd == "all" or "--all" in parts)
1474 +
1475 + if show_all:
1476 + # Show git graph of all branches
1477 + print(f"\n{BOLD}📍 Checkpoint Graph:{RESET}\n")
1478 +
1479 + # Use git log --graph --all to show the tree
1480 + # Format: %h = short hash, %d = ref names, %s = subject, %ar = relative date
1481 + graph_output = session_manager.checkpoint_manager._git_command(
1482 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
1483 + "log", "--graph", "--all", "--oneline",
1484 + "--format=%h %s (%ar)", "-20"
1485 + )
1486 +
1487 + if graph_output and not graph_output.startswith("error"):
1488 + # Also get branch info for each commit
1489 + branches_output = session_manager.checkpoint_manager._git_command(
1490 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
1491 + "branch", "-a", "--contains"
1492 + )
1493 +
1494 + # Parse and display
1495 + for line in graph_output.split('\n'):
1496 + if not line.strip():
1497 + continue
1498 +
1499 + # Extract commit hash
1500 + match = re.search(r'\b([0-9a-f]{7,8})\b', line)
1501 + if match:
1502 + commit_hash = match.group(1)
1503 +
1504 + # Get branches containing this commit
1505 + branch_info = session_manager.checkpoint_manager._git_command(
1506 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
1507 + "branch", "-a", "--contains", commit_hash
1508 + )
1509 +
1510 + # Extract session names from branches
1511 + session_names = []
1512 + if branch_info and not branch_info.startswith("error"):
1513 + for branch_line in branch_info.split('\n'):
1514 + branch_line = branch_line.strip().lstrip('* ')
1515 + if branch_line.startswith('session_'):
1516 + # Shorten session name: session_20260130_103323_f7 -> s:20260130_103323_f7
1517 + session_short = 's:' + branch_line[8:] # Remove 'session_' prefix
1518 + session_names.append(session_short)
1519 +
1520 + # Highlight commit hash
1521 + line = line.replace(commit_hash, f"{CYAN}{commit_hash}{RESET}")
1522 +
1523 + # Add session info if found
1524 + if session_names:
1525 + # Insert session names after commit hash
1526 + session_str = f"{GREEN}[{', '.join(session_names[:2])}]{RESET}"
1527 + line = line.replace(commit_hash + f"{RESET}", commit_hash + f"{RESET} {session_str}")
1528 +
1529 +
1530 + print(f" {line}")
1531 + print()
1532 + else:
1533 + print(f"{DIM}No checkpoints yet{RESET}\n")
1534 +
1535 + print(f"{DIM}Restore: /c <hash>{RESET}")
1536 + return None
1537 + else:
1538 + # Show current session's checkpoints
1539 + checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
1540 + if not checkpoints:
1541 + print(f"{DIM}No checkpoints yet{RESET}")
1542 + return None
1543 +
1544 + print(f"\n{BOLD}📍 Checkpoints:{RESET}\n")
1545 +
1546 + # Get checkpoint details from git log with timestamp
1547 + for commit_hash, message in checkpoints[:10]: # Show first 10 (already newest first from git log)
1548 + # Try to get timestamp from git
1549 + timestamp_str = session_manager.checkpoint_manager._git_command(
1550 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
1551 + "log", "-1", "--format=%ar", commit_hash
1552 + )
1553 + if timestamp_str.startswith("error"):
1554 + timestamp_str = ""
1555 +
1556 + # Get modified files
1557 + files_str = session_manager.checkpoint_manager._git_command(
1558 + "--git-dir", session_manager.checkpoint_manager.bare_repo,
1559 + "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash
1560 + )
1561 + files = []
1562 + if files_str and not files_str.startswith("error"):
1563 + files = [f.strip() for f in files_str.split('\n') if f.strip()]
1564 +
1565 + # Format: hash | time ago | message
1566 + time_part = f"{DIM}{timestamp_str}{RESET}" if timestamp_str else ""
1567 + print(f" {CYAN}{commit_hash}{RESET} {time_part}")
1568 + print(f" {DIM}└─{RESET} {message}")
1569 + if files:
1570 + files_display = ", ".join(files[:3])
1571 + if len(files) > 3:
1572 + files_display += f" +{len(files)-3} more"
1573 + print(f" {DIM} Files: {files_display}{RESET}")
1574 + print()
1575 +
1576 + print(f"{DIM}Tip: Use '/c all' or '/ca' to see git graph{RESET}")
1577 + print(f"{DIM}Restore: /c <hash>{RESET}")
1578 + return None
1579 +
1580 + elif cmd == "restore":
1581 + if not checkpoint_id:
1582 + print(f"{RED}Usage: /c <checkpoint_id>{RESET}")
1583 + return None
1584 +
1585 + print(f"{YELLOW}⚠ This will restore files AND conversation to checkpoint {checkpoint_id}{RESET}")
1586 + print(f"{YELLOW}⚠ Future checkpoints will be discarded from history{RESET}")
1587 + confirm = input(f"{BOLD}Continue? (y/N): {RESET}").strip().lower()
1588 +
1589 + if confirm != 'y':
1590 + print(f"{DIM}Cancelled{RESET}")
1591 + return None
1592 +
1593 + success, conversation_snapshot = session_manager.checkpoint_manager.restore_checkpoint(checkpoint_id)
1594 + if success:
1595 + print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
1596 + if conversation_snapshot:
1597 + print(f"{GREEN}✓ Restored conversation ({len(conversation_snapshot)} messages){RESET}")
1598 + return conversation_snapshot
1599 + else:
1600 + print(f"{YELLOW}⚠ No conversation snapshot found for this checkpoint{RESET}")
1601 + return None
1602 + else:
1603 + print(f"{RED}✗ Failed to restore checkpoint{RESET}")
1604 + return None
1605 +
1606 + else:
1607 + print(f"{RED}Unknown command: {cmd}{RESET}")
1608 + return None
1609 +
1610 +
1611 + if __name__ == "__main__":
1612 + main()
Newer Older