Last active 1 month ago

Revision bc1f6e25daeb3185b4e114befe86bf257ee2a2cf

nano.py Raw
1#!/usr/bin/env python3
2"""nanocode - minimal claude code alternative"""
3import glob as globlib
4import hashlib
5import json
6import os
7import random
8import re
9import readline
10import select
11import ssl
12import subprocess
13import sys
14import termios
15import time
16import tty
17import unicodedata
18import urllib.request
19import urllib.parse
20from datetime import datetime
21
22OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
23LOCAL_API_KEY = os.environ.get("LOCAL_API_KEY")
24API_BASE_URL = os.environ.get("API_BASE_URL") # e.g., http://127.0.0.1:8990
25
26# Build API URL
27if API_BASE_URL:
28 # Use custom base URL
29 API_URL = f"{API_BASE_URL.rstrip('/')}/v1/messages"
30elif LOCAL_API_KEY:
31 API_URL = "http://127.0.0.1:8990/v1/messages"
32elif OPENROUTER_KEY:
33 API_URL = "https://openrouter.ai/api/v1/messages"
34else:
35 API_URL = "https://api.anthropic.com/v1/messages"
36
37MODEL = os.environ.get("MODEL",
38 "anthropic/claude-sonnet-4.5" if LOCAL_API_KEY
39 else "anthropic/claude-opus-4.5" if OPENROUTER_KEY
40 else "claude-opus-4-5"
41)
42
43# ANSI colors
44RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
45BLUE, CYAN, GREEN, YELLOW, RED = "\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m"
46stop_flag = False
47
48def detect_clipboard_image():
49 """Detect and return image from clipboard using system commands
50
51 Returns:
52 dict or None: Image content block with metadata, or None if no image
53 """
54 try:
55 import base64
56 import tempfile
57
58 # Save clipboard image to temp file
59 temp_file = None
60 image_data = None
61
62 if sys.platform == 'darwin':
63 # macOS: use pngpaste or osascript
64 temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
65 temp_file.close()
66
67 # Try pngpaste first (faster)
68 result = subprocess.run(
69 ['pngpaste', temp_file.name],
70 capture_output=True, timeout=5 # Increase timeout for large images
71 )
72
73 if result.returncode != 0:
74 # Fallback to osascript
75 script = f'''
76 set theFile to POSIX file "{temp_file.name}"
77 try
78 set theImage to the clipboard as «class PNGf»
79 set fileRef to open for access theFile with write permission
80 write theImage to fileRef
81 close access fileRef
82 return "success"
83 on error errMsg
84 return "error: " & errMsg
85 end try
86 '''
87 result = subprocess.run(
88 ['osascript', '-e', script],
89 capture_output=True, timeout=5, text=True
90 )
91 if not result.stdout.strip().startswith('success'):
92 print(f"{RED}✗ osascript error: {result.stdout.strip()}{RESET}", flush=True)
93 os.unlink(temp_file.name)
94 return None
95
96 # Check file size
97 file_size = os.path.getsize(temp_file.name)
98
99 # Read the file
100 with open(temp_file.name, 'rb') as f:
101 image_data = f.read()
102 os.unlink(temp_file.name)
103
104 elif sys.platform.startswith('linux'):
105 # Linux: use xclip
106 result = subprocess.run(
107 ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'],
108 capture_output=True, timeout=5
109 )
110 if result.returncode == 0 and result.stdout:
111 image_data = result.stdout
112 else:
113 return None
114
115 elif sys.platform == 'win32':
116 # Windows: use PowerShell
117 temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
118 temp_file.close()
119
120 ps_script = f'''
121 Add-Type -AssemblyName System.Windows.Forms
122 $img = [System.Windows.Forms.Clipboard]::GetImage()
123 if ($img) {{
124 $img.Save("{temp_file.name}", [System.Drawing.Imaging.ImageFormat]::Png)
125 exit 0
126 }} else {{
127 exit 1
128 }}
129 '''
130 result = subprocess.run(
131 ['powershell', '-Command', ps_script],
132 capture_output=True, timeout=5
133 )
134
135 if result.returncode == 0:
136 file_size = os.path.getsize(temp_file.name)
137 with open(temp_file.name, 'rb') as f:
138 image_data = f.read()
139 os.unlink(temp_file.name)
140 else:
141 os.unlink(temp_file.name)
142 return None
143 else:
144 return None
145
146 if not image_data:
147 return None
148
149 # Get image size (read PNG header)
150 # PNG signature: 8 bytes, then IHDR chunk with width/height
151 if len(image_data) > 24 and image_data[:8] == b'\x89PNG\r\n\x1a\n':
152 width = int.from_bytes(image_data[16:20], 'big')
153 height = int.from_bytes(image_data[20:24], 'big')
154 size = (width, height)
155 else:
156 size = (0, 0)
157
158 # Check if too large
159 max_size = 1568
160 if size[0] > max_size or size[1] > max_size:
161 print(f"\r{YELLOW}⚠ Image {size[0]}x{size[1]} exceeds {max_size}x{max_size}, may be slow{RESET}", flush=True)
162 import time
163 time.sleep(1)
164
165 # Check file size limit (5MB for Claude API)
166 if len(image_data) > 5 * 1024 * 1024:
167 print(f"{RED}✗ Image too large: {len(image_data)/1024/1024:.2f} MB (max 5MB){RESET}", flush=True)
168 return None
169
170 # Encode to base64
171 base64_data = base64.b64encode(image_data).decode('utf-8')
172
173 return {
174 "type": "image",
175 "source": {
176 "type": "base64",
177 "media_type": "image/png",
178 "data": base64_data
179 },
180 "_metadata": {
181 "size": size,
182 "resized": None
183 }
184 }
185 except subprocess.TimeoutExpired as e:
186 print(f"\r{RED}✗ Timeout: Image too large or system command hung{RESET}", flush=True)
187 import time
188 time.sleep(1)
189 return None
190 except Exception as e:
191 return None
192
193def create_opener():
194 """Create URL opener with SSL and proxy support"""
195 proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
196 ssl_ctx = ssl.create_default_context()
197 ssl_ctx.check_hostname = False
198 ssl_ctx.verify_mode = ssl.CERT_NONE
199
200 handlers = [urllib.request.HTTPSHandler(context=ssl_ctx)]
201 if proxy: handlers.insert(0, urllib.request.ProxyHandler({"http": proxy, "https": proxy}))
202 return urllib.request.build_opener(*handlers)
203
204def register_tool(name, desc, params):
205 """Register a tool from extension code"""
206 def decorator(func):
207 TOOLS[name] = (desc, params, func)
208 return func
209 return decorator
210
211def search_extension(args):
212 """Search extensions from gist.kitchain.cn"""
213 query = args.get("query", "")
214 if not query: return "error: query required"
215 try:
216 # Split query into keywords
217 keywords = query.lower().split()
218 gist_info = {} # {gist_path: {"hits": count, "title": str, "desc": str, "topics": []}}
219 opener = create_opener()
220
221 # Search each keyword as a topic
222 for keyword in keywords:
223 url = f"https://gist.kitchain.cn/topics/{urllib.parse.quote(keyword)}"
224 html = opener.open(urllib.request.Request(url), timeout=10).read().decode()
225
226 # Extract gist URLs and titles
227 gist_matches = re.findall(
228 r'<a class="font-bold" href="https://gist\.kitchain\.cn/([^/]+/[a-f0-9]+)">([^<]+)</a>',
229 html
230 )
231
232 for gist_path, title in gist_matches:
233 if gist_path not in gist_info:
234 # Extract description and topics for this gist
235 gist_section = re.search(
236 rf'{re.escape(gist_path)}.*?'
237 r'<h6 class="text-xs[^"]*">([^<]+)</h6>(.*?)</div>\s*</div>',
238 html, re.DOTALL
239 )
240 desc = ""
241 topics = []
242 if gist_section:
243 desc = gist_section.group(1).strip()
244 topics_section = gist_section.group(2)
245 topics = re.findall(r'topics/([^"]+)"[^>]*>([^<]+)<', topics_section)
246 topics = [t[1] for t in topics] # Extract topic names
247
248 gist_info[gist_path] = {
249 "hits": 0,
250 "title": title.strip(),
251 "desc": desc,
252 "topics": topics,
253 "filename": title.strip()
254 }
255 gist_info[gist_path]["hits"] += 1
256
257 if not gist_info: return f"No extensions found: {query}"
258
259 # Sort by hit count (descending)
260 sorted_gists = sorted(gist_info.items(), key=lambda x: x[1]["hits"], reverse=True)[:10]
261
262 result = f"Found {len(sorted_gists)} extensions:\n\n"
263 for gist_path, info in sorted_gists:
264 result += f"{info['title']}\n"
265 if info['desc']:
266 result += f" {info['desc']}\n"
267 if info['topics']:
268 result += f" Topics: {', '.join(info['topics'])}\n"
269 result += f" Matched: {info['hits']} keyword(s)\n\n"
270
271 # Return first gist's load URL
272 first_gist = sorted_gists[0][0]
273 first_filename = sorted_gists[0][1]['filename']
274 result += f"To load the top result:\nload({{\"url\": \"https://gist.kitchain.cn/{first_gist}/raw/HEAD/{first_filename}\"}})"
275 return result
276 except Exception as e:
277 return f"error: {e}"
278
279def load(args):
280 """Load extension from URL"""
281 url = args.get("url")
282 if not url: return "error: url required"
283 try:
284 opener = create_opener()
285 code = opener.open(urllib.request.Request(url), timeout=10).read().decode()
286 exec(code, {"register_tool": register_tool, "TOOLS": TOOLS, "urllib": urllib, "json": json, "re": re, "subprocess": subprocess})
287 new = [k for k in TOOLS if k not in ["read","write","edit","glob","grep","bash","web_search","search_extension","load"]]
288 return f"Loaded. New tools: {', '.join(new)}"
289 except Exception as e:
290 return f"error: {e}"
291
292# --- Tools ---
293def read(args):
294 lines = open(args["path"]).readlines()
295 offset, limit = args.get("offset", 0), args.get("limit", len(lines))
296 return "".join(f"{offset+i+1:4}| {l}" for i, l in enumerate(lines[offset:offset+limit]))
297
298def write(args):
299 filepath = args["path"]
300 content = args["content"]
301 print(f"{DIM}[LOG] write: {filepath} ({len(content)} bytes){RESET}", flush=True)
302 open(filepath, "w").write(content)
303 print(f"{DIM}[LOG] write completed: {filepath}{RESET}", flush=True)
304 return "ok"
305
306def edit(args):
307 filepath = args["path"]
308 print(f"{DIM}[LOG] edit: {filepath}{RESET}", flush=True)
309 text = open(filepath).read()
310 print(f"{DIM}[LOG] edit read: {len(text)} bytes{RESET}", flush=True)
311 old, new = args["old"], args["new"]
312 if old not in text: return "error: old_string not found"
313 count = text.count(old)
314 if not args.get("all") and count > 1:
315 return f"error: old_string appears {count} times (use all=true)"
316 result = text.replace(old, new) if args.get("all") else text.replace(old, new, 1)
317 print(f"{DIM}[LOG] edit writing: {len(result)} bytes{RESET}", flush=True)
318 open(filepath, "w").write(result)
319 print(f"{DIM}[LOG] edit completed: {filepath}{RESET}", flush=True)
320 return "ok"
321
322def glob(args):
323 pattern = (args.get("path", ".") + "/" + args["pat"]).replace("//", "/")
324 files = sorted(globlib.glob(pattern, recursive=True),
325 key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0, reverse=True)
326 return "\n".join(files) or "none"
327
328def grep(args):
329 pattern, hits = re.compile(args["pat"]), []
330 for fp in globlib.glob(args.get("path", ".") + "/**", recursive=True):
331 try:
332 for n, l in enumerate(open(fp), 1):
333 if pattern.search(l): hits.append(f"{fp}:{n}:{l.rstrip()}")
334 except: pass
335 return "\n".join(hits[:50]) or "none"
336
337def bash(args):
338 global stop_flag
339 proc = subprocess.Popen(args["cmd"], shell=True, stdout=subprocess.PIPE,
340 stderr=subprocess.STDOUT, text=True)
341 lines = []
342 old_settings = termios.tcgetattr(sys.stdin)
343 try:
344 tty.setcbreak(sys.stdin.fileno())
345 if proc.stdout:
346 import fcntl
347 fd = proc.stdout.fileno()
348 fcntl.fcntl(fd, fcntl.F_SETFL, fcntl.fcntl(fd, fcntl.F_GETFL) | os.O_NONBLOCK)
349
350 while True:
351 # Check ESC key
352 if select.select([sys.stdin], [], [], 0)[0]:
353 if sys.stdin.read(1) == '\x1b':
354 stop_flag = True
355 proc.kill()
356 lines.append("\n(stopped)")
357 print(f"\n{YELLOW}⏸ Stopped{RESET}")
358 break
359
360 # Read output
361 if select.select([proc.stdout], [], [], 0.1)[0]:
362 line = proc.stdout.readline()
363 if line:
364 print(f" {DIM}{line.rstrip()}{RESET}", flush=True)
365 lines.append(line)
366
367 # Check if done
368 if proc.poll() is not None:
369 remaining = proc.stdout.read()
370 if remaining:
371 for line in remaining.split('\n'):
372 if line:
373 print(f" {DIM}{line.rstrip()}{RESET}", flush=True)
374 lines.append(line + '\n')
375 break
376
377 if not stop_flag:
378 proc.wait(timeout=30)
379 except subprocess.TimeoutExpired:
380 proc.kill()
381 lines.append("\n(timeout)")
382 finally:
383 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
384
385 return "".join(lines).strip() or "(empty)"
386
387def web_search(args):
388 """Search web using DuckDuckGo"""
389 query, max_results = args["query"], args.get("max_results", 5)
390 try:
391 url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote_plus(query)}"
392 headers = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"}
393 opener = create_opener()
394 html = opener.open(urllib.request.Request(url, headers=headers), timeout=30).read().decode()
395
396 # Extract titles and URLs
397 links = re.findall(r'class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<', html)
398 # Extract snippets
399 snippets = re.findall(r'class="result__snippet"[^>]*>([^<]*)<', html)
400 if not links: return "No results found"
401
402 results = []
403 for i, ((link, title), snippet) in enumerate(zip(links[:max_results], snippets[:max_results] + [""] * max_results), 1):
404 results.append(f"{i}. {title.strip()}\n URL: {link}\n {snippet.strip()}\n")
405 return "\n".join(results)
406 except Exception as e:
407 return f"error: {e}"
408
409
410TOOLS = {
411 "read": ("Read file with line numbers", {"path": "string", "offset": "number?", "limit": "number?"}, read),
412 "write": ("Write content to file", {"path": "string", "content": "string"}, write),
413 "edit": ("Replace old with new in file", {"path": "string", "old": "string", "new": "string", "all": "boolean?"}, edit),
414 "glob": ("Find files by pattern", {"pat": "string", "path": "string?"}, glob),
415 "grep": ("Search files for regex", {"pat": "string", "path": "string?"}, grep),
416 "bash": ("Run shell command", {"cmd": "string"}, bash),
417 "web_search": ("Search the web using DuckDuckGo", {"query": "string", "max_results": "number?"}, web_search),
418 "search_extension": ("Search for extensions to add new capabilities (GitHub docs, web scraping, APIs, etc)", {"query": "string"}, search_extension),
419 "load": ("Load extension from URL to add new tools", {"url": "string"}, load),
420}
421
422def run_tool(name, args):
423 try: return TOOLS[name][2](args)
424 except Exception as e: return f"error: {e}"
425
426def make_schema():
427 result = []
428 for name, (desc, params, _) in TOOLS.items():
429 props, req = {}, []
430 for pname, ptype in params.items():
431 opt = ptype.endswith("?")
432 props[pname] = {"type": "integer" if ptype.rstrip("?") == "number" else ptype.rstrip("?")}
433 if not opt: req.append(pname)
434 result.append({"name": name, "description": desc,
435 "input_schema": {"type": "object", "properties": props, "required": req}})
436 return result
437
438def call_api(messages, system_prompt, stream=True, enable_thinking=True, use_tools=True):
439 headers = {"Content-Type": "application/json", "anthropic-version": "2023-06-01"}
440 if LOCAL_API_KEY: headers["Authorization"] = f"Bearer {LOCAL_API_KEY}"
441 elif OPENROUTER_KEY: headers["Authorization"] = f"Bearer {OPENROUTER_KEY}"
442 else: headers["x-api-key"] = os.environ.get("ANTHROPIC_API_KEY", "")
443
444 data = {"model": MODEL, "max_tokens": 8192, "system": system_prompt,
445 "messages": messages, "stream": stream}
446
447 if use_tools:
448 data["tools"] = make_schema()
449
450 if enable_thinking and os.environ.get("THINKING"):
451 data["thinking"] = {"type": "enabled", "budget_tokens": int(os.environ.get("THINKING_BUDGET", "10000"))}
452
453 req = urllib.request.Request(API_URL, json.dumps(data).encode(), headers, method="POST")
454 return create_opener().open(req)
455
456def summarize_changes(user_input, files_modified, checkpoint_manager, checkpoint_id):
457 """Use LLM to summarize the changes made in this turn
458
459 Args:
460 user_input: User's request
461 files_modified: Set of modified file paths
462 checkpoint_manager: CheckpointManager instance
463 checkpoint_id: Checkpoint hash to get diff from
464
465 Returns:
466 str: One-line summary of changes
467 """
468 if not files_modified or not checkpoint_id:
469 return user_input[:50]
470
471 try:
472 # Get diff from git
473 diff_output = checkpoint_manager._git_command(
474 "--git-dir", checkpoint_manager.bare_repo,
475 "show", "--format=", checkpoint_id
476 )
477
478 # Check if diff is empty or error - no actual changes
479 if not diff_output or diff_output.startswith("error") or len(diff_output.strip()) == 0:
480 # No diff available, just use user input
481 return user_input[:50]
482
483 # Limit diff size to avoid token overflow (max ~3000 chars)
484 if len(diff_output) > 3000:
485 diff_output = diff_output[:3000] + "\n... (truncated)"
486
487 summary_prompt = f"""Based on the actual code changes (diff), generate a brief Chinese summary (max 30 Chinese characters).
488
489IMPORTANT: Must be based on the actual code changes, not the user's description.
490
491Code changes (diff):
492{diff_output}
493
494User description (for reference only): {user_input}
495
496Requirements:
4971. Describe what code/functionality was actually modified
4982. Reply in Chinese only, no explanation
4993. No quotes
5004. Max 30 Chinese characters
501
502Good examples:
503- 在 auth.py 添加 JWT 验证
504- 修复 parser.py 空指针异常
505- 重构 database.py 连接池
506- 更新 README 添加安装说明
507"""
508
509 messages = [{"role": "user", "content": summary_prompt}]
510 response = call_api(messages, "You are a code change analyzer, skilled at extracting key information from diffs. Reply in Chinese.",
511 stream=False, enable_thinking=False, use_tools=False)
512
513 # Parse non-streaming response
514 data = json.loads(response.read().decode())
515 blocks = data.get("content", [])
516
517 for block in blocks:
518 if block.get("type") == "text":
519 summary = block.get("text", "").strip()
520
521 # Remove thinking tags if present
522 if "<thinking>" in summary:
523 # Extract content after </thinking>
524 parts = summary.split("</thinking>")
525 if len(parts) > 1:
526 summary = parts[-1].strip()
527
528 # Clean up and limit length
529 summary = summary.replace('"', '').replace("'", "")
530 if summary and len(summary) <= 80:
531 return summary
532
533 # Fallback to user input
534 return user_input[:50]
535 except Exception as e:
536 # On error, fallback to user input
537 return user_input[:50]
538
539def process_stream(response):
540 """简化的流式处理,支持ESC中断"""
541 global stop_flag
542 blocks, current, text_buf, json_buf, think_buf = [], None, "", "", ""
543
544 # Save terminal settings
545 old_settings = termios.tcgetattr(sys.stdin)
546 try:
547 tty.setcbreak(sys.stdin.fileno())
548
549 for line in response:
550 if select.select([sys.stdin], [], [], 0)[0]:
551 ch = sys.stdin.read(1)
552 if ch == '\x1b': # ESC key
553 stop_flag = True
554 print(f"\n{YELLOW}⏸ Stopped{RESET}")
555 break
556
557 line = line.decode("utf-8").strip()
558 if not line.startswith("data: "): continue
559 if line == "data: [DONE]": continue
560
561 try:
562 data = json.loads(line[6:])
563 etype = data.get("type")
564
565 if etype == "content_block_start":
566 block = data.get("content_block", {})
567 current = {"type": block.get("type"), "id": block.get("id")}
568 if current["type"] == "text":
569 text_buf = ""
570 print(f"\n{CYAN}{RESET} ", end="", flush=True)
571 elif current["type"] == "thinking":
572 think_buf = ""
573 print(f"\n{YELLOW}💭{RESET} {DIM}", end="", flush=True)
574 elif current["type"] == "tool_use":
575 current["name"] = block.get("name")
576 json_buf = ""
577
578 elif etype == "content_block_delta":
579 delta = data.get("delta", {})
580 dtype = delta.get("type")
581 if dtype == "text_delta":
582 text = delta.get("text", "")
583 text_buf += text
584 print(text, end="", flush=True)
585 elif dtype == "thinking_delta":
586 text = delta.get("thinking", "")
587 think_buf += text
588 print(text, end="", flush=True)
589 elif dtype == "input_json_delta" and current:
590 json_buf += delta.get("partial_json", "")
591
592 elif etype == "content_block_stop" and current:
593 if current["type"] == "text":
594 current["text"] = text_buf
595 print()
596 elif current["type"] == "thinking":
597 print(RESET)
598 elif current["type"] == "tool_use":
599 try: current["input"] = json.loads(json_buf)
600 except: current["input"] = {}
601 blocks.append(current)
602 current = None
603 except: pass
604 finally:
605 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
606
607 return blocks
608
609def get_display_width(text):
610 """Get display width of text (CJK chars = 2, others = 1)"""
611 width = 0
612 for char in text:
613 if unicodedata.east_asian_width(char) in ('F', 'W'):
614 width += 2
615 else:
616 width += 1
617 return width
618
619def format_tool_preview(name, args):
620 """Format tool call preview based on tool type"""
621 if name == "read":
622 path = args.get("path", "")
623 offset = args.get("offset")
624 limit = args.get("limit")
625 if offset or limit:
626 return f"{path}, offset={offset or 0}, limit={limit or 'all'}"
627 return path
628
629 elif name == "write":
630 path = args.get("path", "")
631 content = args.get("content", "")
632 return f"{path}, {len(content)} bytes"
633
634 elif name == "edit":
635 path = args.get("path", "")
636 old = args.get("old", "")
637 new = args.get("new", "")
638 all_flag = args.get("all", False)
639 old_preview = old[:20] + "..." if len(old) > 20 else old
640 new_preview = new[:20] + "..." if len(new) > 20 else new
641 flag_str = ", all=true" if all_flag else ""
642 return f"{path}: '{old_preview}''{new_preview}'{flag_str}"
643
644 elif name == "bash":
645 return args.get("cmd", "")
646
647 elif name == "glob":
648 pattern = args.get("pat", "")
649 path = args.get("path", ".")
650 return f"{pattern} in {path}"
651
652 elif name == "grep":
653 pattern = args.get("pat", "")
654 path = args.get("path", ".")
655 return f"/{pattern}/ in {path}"
656
657 elif name == "web_search":
658 query = args.get("query", "")
659 max_results = args.get("max_results", 5)
660 return f"{query}, max={max_results}"
661
662 elif name == "search_extension":
663 return args.get("query", "")
664
665 elif name == "load":
666 url = args.get("url", "")
667 # Show filename from URL
668 filename = url.split("/")[-1] if url else ""
669 return filename or url
670
671 else:
672 # Fallback: show first value
673 return str(list(args.values())[0])[:50] if args else ""
674
675def format_tool_result(name, result):
676 """Format tool result preview based on tool type"""
677 if not result:
678 return "(empty)"
679
680 lines = result.split("\n")
681
682 # For simple results (ok, error, etc), show as-is
683 if len(lines) == 1 and len(result) < 80:
684 return result
685
686 # For multi-line results
687 first_line = lines[0][:60]
688 if len(lines[0]) > 60:
689 first_line += "..."
690
691 if len(lines) > 1:
692 return f"{first_line} +{len(lines)-1} lines"
693
694 return first_line
695
696def is_file_in_project(filepath, project_path):
697 """Check if file is within project directory"""
698 try:
699 abs_file = os.path.abspath(filepath)
700 abs_project = os.path.abspath(project_path)
701 # Check if file is under project directory
702 return abs_file.startswith(abs_project + os.sep) or abs_file == abs_project
703 except:
704 return False
705
706def read_multiline_input(prefill=""):
707 """Read multiline input. Enter to submit, Alt+Enter for newline.
708 Supports pasting images from clipboard.
709
710 Args:
711 prefill: Text to prefill in the input box
712
713 Returns:
714 tuple: (text: str, images: list[dict])
715 """
716 lines = []
717 current = prefill
718 cursor_pos = len(prefill) # Cursor at end of prefill text
719 images = [] # Store pasted images
720
721 # Enable bracketed paste mode
722 print("\033[?2004h", end="", flush=True)
723
724 old_settings = termios.tcgetattr(sys.stdin)
725 try:
726 tty.setcbreak(sys.stdin.fileno())
727
728 # Disable LNEXT (Ctrl+V) so it can be used for paste
729 attrs = termios.tcgetattr(sys.stdin)
730 attrs[6][termios.VLNEXT] = 0 # Disable literal-next
731 termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
732
733 print(f"{BOLD}{BLUE}{RESET} {current}", end="", flush=True)
734
735 while True:
736 ch = sys.stdin.read(1)
737
738 if ch == '\x03': # Ctrl+C - clear input
739 lines.clear()
740 current = ""
741 cursor_pos = 0
742 print("\r\033[K", end="", flush=True)
743 print(f"{BOLD}{BLUE}{RESET} ", end="", flush=True)
744 continue
745
746 if ch == '\x05': # Ctrl+E
747 raise EOFError
748
749 if ch == '\x16': # Ctrl+V - paste image
750 img_block = detect_clipboard_image()
751
752 if img_block:
753 # Image detected!
754 images.append(img_block)
755 size = img_block.get('_metadata', {}).get('size', (0, 0))
756 resized = img_block.get('_metadata', {}).get('resized')
757
758 # Create image marker
759 if resized:
760 img_marker = f"[📷 Image {len(images)}: {size[0]}x{size[1]}{resized[0]}x{resized[1]}]"
761 else:
762 img_marker = f"[📷 Image {len(images)}: {size[0]}x{size[1]}]"
763
764 # Insert marker at cursor position
765 current = current[:cursor_pos] + img_marker + current[cursor_pos:]
766 cursor_pos += len(img_marker)
767
768 # Redraw current line
769 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
770 print(f"\r\033[K{prefix}{current}", end="", flush=True)
771 else:
772 # No image in clipboard
773 print(f"\r{YELLOW}⚠ No image in clipboard{RESET}", flush=True)
774 import time
775 time.sleep(1)
776 # Redraw current line
777 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
778 print(f"\r\033[K{prefix}{current}", end="", flush=True)
779
780 continue
781
782 if ch == '\x1b': # Escape sequence
783 next_ch = sys.stdin.read(1)
784 if next_ch in ('\r', '\n'): # Alt+Enter
785 lines.append(current)
786 current = ""
787 cursor_pos = 0
788 print(f"\n{BOLD}{BLUE}{RESET} ", end="", flush=True)
789 elif next_ch == '[': # Escape sequence
790 seq = sys.stdin.read(1)
791 if seq == 'C': # Right arrow
792 if cursor_pos < len(current):
793 # Get display width of character at cursor
794 char_width = get_display_width(current[cursor_pos])
795 cursor_pos += 1
796 # Move cursor by actual display width
797 if char_width == 2:
798 print("\033[2C", end="", flush=True)
799 else:
800 print("\033[C", end="", flush=True)
801 elif seq == 'D': # Left arrow
802 if cursor_pos > 0:
803 cursor_pos -= 1
804 # Get display width of character before cursor
805 char_width = get_display_width(current[cursor_pos])
806 # Move cursor by actual display width
807 if char_width == 2:
808 print("\033[2D", end="", flush=True)
809 else:
810 print("\033[D", end="", flush=True)
811 elif seq == '2': # Bracketed paste start: ESC[200~
812 rest = sys.stdin.read(3) # Read "00~"
813 if rest == '00~':
814 # Read pasted content until ESC[201~
815 paste_buf = ""
816 while True:
817 c = sys.stdin.read(1)
818 if c == '\x1b':
819 # Check for [201~
820 peek = sys.stdin.read(5)
821 if peek == '[201~':
822 break
823 else:
824 paste_buf += c + peek
825 else:
826 paste_buf += c
827
828 # Process pasted content
829 paste_lines = paste_buf.split('\n')
830
831 if len(paste_lines) == 1:
832 # Single line paste
833 current = current[:cursor_pos] + paste_lines[0] + current[cursor_pos:]
834 cursor_pos += len(paste_lines[0])
835 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
836 print(f"\r\033[K{prefix}{current}", end="", flush=True)
837 else:
838 # Multi-line paste
839 # First line appends to current
840 first_line = current[:cursor_pos] + paste_lines[0]
841 print(paste_lines[0], end="", flush=True)
842 if first_line:
843 lines.append(first_line)
844
845 # Middle lines
846 for line in paste_lines[1:-1]:
847 print(f"\n{BOLD}{BLUE}{RESET} {line}", end="", flush=True)
848 lines.append(line)
849
850 # Last line becomes new current
851 current = paste_lines[-1]
852 cursor_pos = len(current)
853 print(f"\n{BOLD}{BLUE}{RESET} {current}", end="", flush=True)
854 continue
855
856 if ch in ('\r', '\n'): # Enter - submit
857 if current:
858 lines.append(current)
859 print()
860 break
861
862 if ch in ('\x7f', '\x08'): # Backspace
863 if cursor_pos > 0:
864 # Delete character before cursor
865 current = current[:cursor_pos-1] + current[cursor_pos:]
866 cursor_pos -= 1
867 # Redraw current line
868 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
869 print(f"\r\033[K{prefix}{current}", end="", flush=True)
870 # Move cursor back to position
871 if cursor_pos < len(current):
872 # Calculate display width from cursor to end
873 remaining_width = get_display_width(current[cursor_pos:])
874 print(f"\033[{remaining_width}D", end="", flush=True)
875 elif lines:
876 # Merge with previous line
877 prev_line = lines.pop()
878 cursor_pos = len(prev_line) # Cursor at end of previous line
879 current = prev_line + current
880 # Move up and redraw
881 print("\033[A\033[K", end="", flush=True)
882 prefix = f"{BOLD}{BLUE}{'' if lines else ''}{RESET} "
883 print(f"\r{prefix}{current}", end="", flush=True)
884 if cursor_pos < len(current):
885 # Calculate display width from cursor to end
886 remaining_width = get_display_width(current[cursor_pos:])
887 print(f"\033[{remaining_width}D", end="", flush=True)
888 continue
889
890 if ch.isprintable() or ch == '\t':
891 # Insert character at cursor position
892 current = current[:cursor_pos] + ch + current[cursor_pos:]
893 cursor_pos += 1
894 # Redraw from cursor position
895 print(f"{ch}{current[cursor_pos:]}", end="", flush=True)
896 # Move cursor back if needed
897 if cursor_pos < len(current):
898 # Calculate display width from cursor to end
899 remaining_width = get_display_width(current[cursor_pos:])
900 print(f"\033[{remaining_width}D", end="", flush=True)
901
902 finally:
903 # Disable bracketed paste mode
904 print("\033[?2004l", end="", flush=True)
905 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
906
907 text = "\n".join(lines).strip()
908 return text, images
909
910def main():
911 global stop_flag
912 # Parse command line arguments
913 continue_session = "-c" in sys.argv or "--continue" in sys.argv
914 list_sessions = "-l" in sys.argv or "--list" in sys.argv
915
916 # Disable Ctrl+C signal
917 old_settings = termios.tcgetattr(sys.stdin)
918 new_settings = termios.tcgetattr(sys.stdin)
919 new_settings[3] = new_settings[3] & ~termios.ISIG # Disable signal generation
920 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
921
922 try:
923 proxy = os.environ.get("http_proxy") or os.environ.get("https_proxy")
924 proxy_info = f" | {DIM}🌐 {proxy}{RESET}" if proxy else ""
925 thinking_info = f" | {YELLOW}💭{RESET}" if os.environ.get("THINKING") else ""
926
927 if list_sessions:
928 session_mode = f" | {YELLOW}Select{RESET}"
929 elif continue_session:
930 session_mode = f" | {GREEN}Continue{RESET}"
931 else:
932 session_mode = f" | {CYAN}New{RESET}"
933
934 print(f"{BOLD}nanocode{RESET} | {DIM}{MODEL} | {os.getcwd()}{proxy_info}{thinking_info}{session_mode}{RESET}")
935 print(f"{DIM}Shortcuts: Enter=submit | Alt+Enter=newline | Ctrl+V=paste image | Ctrl+C=clear | Ctrl+E=exit | ESC=stop{RESET}")
936 print(f"{DIM}Commands: /c [all|baseline|<id>] | /ca | /t | /clear{RESET}")
937 print(f"{DIM}Usage: nanocode (new) | nanocode -c (continue) | nanocode -l (select){RESET}\n")
938
939 selected_session_id = None
940 if list_sessions:
941 selected_session_id = select_session_interactive()
942 if not selected_session_id:
943 print(f"{DIM}Exiting...{RESET}")
944 return
945
946 run_main_loop(continue_session, selected_session_id)
947 finally:
948 termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
949
950def select_session_interactive():
951 """Display sessions and let user select one
952
953 Returns:
954 session_id: Selected session ID, or None if cancelled
955 """
956 session_manager = SessionManager(os.getcwd())
957 sessions = session_manager.list_sessions()[:10] # Limit to 10 most recent
958
959 if not sessions:
960 print(f"{YELLOW}⚠ No previous sessions found{RESET}")
961 print(f"{DIM}Starting new session...{RESET}\n")
962 return None
963
964 print(f"{BOLD}📂 Recent Sessions:{RESET}\n")
965
966 for i, sess_info in enumerate(sessions, 1):
967 created = datetime.fromtimestamp(sess_info['metadata']['created_at']).strftime('%Y-%m-%d %H:%M')
968 last_active = datetime.fromtimestamp(sess_info['metadata']['last_active']).strftime('%Y-%m-%d %H:%M')
969 desc = sess_info['metadata'].get('description', '(no description)')
970
971 # Git info
972 git_commit = sess_info['metadata'].get('git_commit')
973 git_branch = sess_info['metadata'].get('git_branch')
974 git_dirty = sess_info['metadata'].get('git_dirty', False)
975
976 print(f"{CYAN}{i}.{RESET} {BOLD}{sess_info['session_id']}{RESET}")
977 print(f" {desc}")
978
979 git_info = ""
980 if git_commit and git_branch:
981 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
982 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
983
984 print(f" Created: {created} | Last: {last_active} | {sess_info['message_count']} messages{git_info}\n")
985
986 print(f"{DIM}Enter session number (1-{len(sessions)}), or press Enter for new session:{RESET}")
987
988 try:
989 choice = input(f"{BOLD}{BLUE}{RESET} ").strip()
990
991 if not choice:
992 # Empty input = new session
993 return None
994
995 try:
996 idx = int(choice) - 1
997 if 0 <= idx < len(sessions):
998 return sessions[idx]['session_id']
999 else:
1000 print(f"{RED}✗ Invalid number{RESET}")
1001 return None
1002 except ValueError:
1003 print(f"{RED}✗ Invalid input{RESET}")
1004 return None
1005 except (EOFError, KeyboardInterrupt):
1006 return None
1007
1008
1009def run_main_loop(continue_session=False, selected_session_id=None):
1010 # Initialize session manager
1011 session_manager = SessionManager(os.getcwd())
1012
1013 # Load or create session based on parameters
1014 if selected_session_id:
1015 # Load specific session selected by user
1016 session = session_manager.load_session(selected_session_id)
1017 if session:
1018 git_info = ""
1019 git_commit = session.metadata.get('git_commit')
1020 git_branch = session.metadata.get('git_branch')
1021 if git_commit and git_branch:
1022 git_dirty = session.metadata.get('git_dirty', False)
1023 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
1024 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
1025
1026 print(f"{GREEN}✓ Loaded session: {session.session_id}{RESET}")
1027 print(f"{DIM} └─ {len(session.messages)} messages{git_info}{RESET}")
1028
1029 # Check for conflicts
1030 conflicts = session.detect_conflicts()
1031 if conflicts:
1032 print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
1033 for filepath in conflicts[:5]:
1034 print(f" - {filepath}")
1035 if len(conflicts) > 5:
1036 print(f" ... and {len(conflicts)-5} more")
1037 print(f"\n{DIM}These files have been modified outside this session.{RESET}")
1038 confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
1039
1040 if confirm == 'u':
1041 session.update_file_states()
1042 session_manager.save_session()
1043 print(f"{GREEN}✓ Updated file states{RESET}\n")
1044 elif confirm != 'y':
1045 print(f"{DIM}Creating new session instead...{RESET}\n")
1046 session_manager.create_session()
1047 else:
1048 print()
1049 else:
1050 print()
1051 else:
1052 print(f"{RED}✗ Failed to load session{RESET}")
1053 print(f"{GREEN}✓ Creating new session instead{RESET}\n")
1054 session_manager.create_session()
1055 elif continue_session:
1056 # Continue last session
1057 last_session = session_manager.load_last_session()
1058 if last_session:
1059 git_info = ""
1060 git_commit = last_session.metadata.get('git_commit')
1061 git_branch = last_session.metadata.get('git_branch')
1062 if git_commit and git_branch:
1063 git_dirty = last_session.metadata.get('git_dirty', False)
1064 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
1065 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
1066
1067 print(f"{GREEN}✓ Continued session: {last_session.session_id}{RESET}")
1068 print(f"{DIM} └─ {len(last_session.messages)} messages{git_info}{RESET}")
1069
1070 # Check for conflicts
1071 conflicts = last_session.detect_conflicts()
1072 if conflicts:
1073 print(f"\n{YELLOW}⚠ File conflicts detected:{RESET}")
1074 for filepath in conflicts[:5]:
1075 print(f" - {filepath}")
1076 if len(conflicts) > 5:
1077 print(f" ... and {len(conflicts)-5} more")
1078 print(f"\n{DIM}These files have been modified outside this session.{RESET}")
1079 confirm = input(f"{BOLD}Continue anyway? (y/N/u=update): {RESET}").strip().lower()
1080
1081 if confirm == 'u':
1082 last_session.update_file_states()
1083 session_manager.save_session()
1084 print(f"{GREEN}✓ Updated file states{RESET}\n")
1085 elif confirm != 'y':
1086 print(f"{DIM}Creating new session instead...{RESET}\n")
1087 session_manager.create_session()
1088 else:
1089 print()
1090 else:
1091 print()
1092 else:
1093 # No previous session, create new one
1094 session_manager.create_session()
1095 print(f"{YELLOW}⚠ No previous session found{RESET}")
1096 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}\n")
1097 else:
1098 # Always create new session by default
1099 # Try to detect parent from last session's latest checkpoint
1100 parent_checkpoint = None
1101 parent_session = None
1102
1103 last_session = session_manager.load_last_session()
1104 if last_session:
1105 # Get the latest checkpoint from last session
1106 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
1107 if checkpoints:
1108 parent_checkpoint = checkpoints[0][0] # Latest checkpoint hash
1109 parent_session = last_session.session_id
1110
1111 session_manager.create_session(
1112 parent_checkpoint=parent_checkpoint,
1113 parent_session=parent_session
1114 )
1115
1116 git_info = ""
1117 git_commit = session_manager.current_session.metadata.get('git_commit')
1118 git_branch = session_manager.current_session.metadata.get('git_branch')
1119 if git_commit and git_branch:
1120 git_dirty = session_manager.current_session.metadata.get('git_dirty', False)
1121 dirty_mark = f"{YELLOW}*{RESET}" if git_dirty else ""
1122 git_info = f" | Git: {git_branch}@{git_commit}{dirty_mark}"
1123
1124 if parent_checkpoint:
1125 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
1126 print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{git_info}{RESET}\n")
1127 else:
1128 print(f"{GREEN}✓ Created new session: {session_manager.current_session.session_id}{RESET}")
1129 if git_info:
1130 print(f"{DIM} └─{git_info}{RESET}\n")
1131 else:
1132 print()
1133
1134 files_modified = set()
1135 auto_checkpoint = True
1136
1137 current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1138 system_prompt = f"""Concise coding assistant. cwd: {os.getcwd()} Current time: {current_time}
1139IMPORTANT: When you don't have a tool for the task, ALWAYS try search_extension first before saying you can't do it.
1140Examples:
1141- User asks about GitHub repo → search_extension({{"query": "github documentation"}})
1142- User needs web data → search_extension({{"query": "web scraping"}})
1143- User needs API → search_extension({{"query": "api client"}})"""
1144
1145 prefill_next_input = "" # Store prefill text for next input
1146
1147 while True:
1148 try:
1149 print(f"{DIM}{''*80}{RESET}")
1150 user_input, images = read_multiline_input(prefill_next_input)
1151 prefill_next_input = "" # Clear after use
1152 print(f"{DIM}{''*80}{RESET}")
1153
1154 if not user_input and not images: continue
1155 if user_input in ("/q", "exit"):
1156 session_manager.save_session()
1157 break
1158
1159 # Handle /clear command first (before /c to avoid conflict)
1160 if user_input == "/clear":
1161 # Save current session
1162 session_manager.save_session()
1163
1164 # Get latest checkpoint from current session (if any)
1165 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
1166 parent_checkpoint = checkpoints[0][0] if checkpoints else None
1167 parent_session = session_manager.current_session.session_id
1168
1169 # Create new session branched from current
1170 session_manager.create_session(
1171 parent_checkpoint=parent_checkpoint,
1172 parent_session=parent_session
1173 )
1174
1175 # Reset state
1176 files_modified.clear()
1177
1178 print(f"{GREEN}✓ Started new session: {session_manager.current_session.session_id}{RESET}")
1179 if parent_checkpoint:
1180 print(f"{DIM} └─ Branched from {parent_session[:8]}... @ {parent_checkpoint}{RESET}")
1181 continue
1182
1183 # Handle turns command
1184 if user_input.startswith("/t") or user_input.startswith("/turns"):
1185 parts = user_input.split()
1186
1187 # Parse turn number if provided
1188 turn_number = None
1189 if len(parts) >= 2 and parts[1].isdigit():
1190 turn_number = int(parts[1])
1191
1192 success, prefill_input = handle_turns_command(session_manager, turn_number)
1193 if success:
1194 # Session already modified in handle_turns_command, just save
1195 session_manager.save_session()
1196 # Set prefill for next input if provided
1197 if prefill_input:
1198 prefill_next_input = prefill_input
1199 continue
1200
1201 # Handle checkpoint commands
1202 if user_input.startswith("/checkpoint") or user_input.startswith("/c") or user_input == "/ca":
1203 parts = user_input.split()
1204
1205 # /ca is shortcut for /c all
1206 if parts[0] == "/ca":
1207 parts = ["/c", "all"]
1208
1209 # /c without args defaults to list
1210 if len(parts) == 1 and parts[0] in ["/c", "/checkpoint"]:
1211 parts.append("list")
1212
1213 restored_messages = handle_checkpoint_command(parts, session_manager, files_modified)
1214 if restored_messages is not None:
1215 # Restore conversation by replacing session messages
1216 session_manager.current_session.messages = restored_messages
1217 session_manager.save_session()
1218 continue
1219
1220 # Build message content
1221 if images:
1222 # Has images: use content array format
1223 content = []
1224 if user_input:
1225 content.append({"type": "text", "text": user_input})
1226
1227 for img_block in images:
1228 # Remove metadata (API doesn't need it)
1229 img_block_clean = {
1230 "type": "image",
1231 "source": img_block["source"]
1232 }
1233 content.append(img_block_clean)
1234
1235 message = {"role": "user", "content": content}
1236
1237 # Show image info
1238 print(f"{GREEN}📷 Attached {len(images)} image(s){RESET}")
1239 else:
1240 # Text only: keep original format
1241 message = {"role": "user", "content": user_input}
1242
1243 # Add user message to current session
1244 session_manager.current_session.messages.append(message)
1245
1246 # Reset stop flag for new turn
1247 stop_flag = False
1248
1249 # Track files modified in this turn
1250 files_modified_this_turn = set()
1251
1252 while True:
1253 response = call_api(session_manager.current_session.messages, system_prompt)
1254 blocks = process_stream(response)
1255 if stop_flag: break
1256
1257 tool_results = []
1258 for block in blocks:
1259 if block["type"] == "tool_use":
1260 name, args = block["name"], block["input"]
1261
1262 # Save baseline BEFORE executing write/edit
1263 if name in ['write', 'edit']:
1264 filepath = args.get('path')
1265 if filepath and is_file_in_project(filepath, session_manager.project_path):
1266 session_manager.save_baseline_if_needed(filepath)
1267
1268 # Format preview based on tool type
1269 preview = format_tool_preview(name, args)
1270 print(f"\n{GREEN}{name}{RESET}({DIM}{preview}{RESET})")
1271
1272 result = run_tool(name, args)
1273
1274 # Format result based on tool type
1275 result_preview = format_tool_result(name, result)
1276 print(f" {DIM}{result_preview}{RESET}")
1277
1278 # Track file modifications (only project files)
1279 if name in ['write', 'edit']:
1280 filepath = args.get('path')
1281 if filepath and is_file_in_project(filepath, session_manager.project_path):
1282 files_modified.add(filepath)
1283 files_modified_this_turn.add(filepath)
1284 session_manager.current_session.track_file_state(filepath)
1285
1286 tool_results.append({"type": "tool_result", "tool_use_id": block["id"], "content": result})
1287
1288 # Check stop_flag after each tool execution
1289 if stop_flag:
1290 print(f"{YELLOW}⚠ Tool execution stopped{RESET}")
1291 break
1292
1293 session_manager.current_session.messages.append({"role": "assistant", "content": blocks})
1294 if not tool_results or stop_flag: break
1295 session_manager.current_session.messages.append({"role": "user", "content": tool_results})
1296
1297 # Auto checkpoint after AI work (if project files were modified)
1298 checkpoint_id = None
1299 if auto_checkpoint and files_modified_this_turn:
1300 # files_modified_this_turn already filtered to project files only
1301 # Use parent_commit for first checkpoint of new session
1302 parent_commit = session_manager.parent_commit_for_next_checkpoint
1303 checkpoint_id = session_manager.checkpoint_manager.create_checkpoint(
1304 f"Auto: {user_input[:50]}",
1305 list(files_modified_this_turn),
1306 conversation_snapshot=session_manager.current_session.messages.copy(),
1307 parent_commit=parent_commit
1308 )
1309 # Clear parent after first checkpoint
1310 if parent_commit:
1311 session_manager.parent_commit_for_next_checkpoint = None
1312
1313 if checkpoint_id:
1314 # Generate summary using LLM with actual diff
1315 print(f"{DIM}Generating checkpoint summary...{RESET}", end="", flush=True)
1316 summary = summarize_changes(
1317 user_input,
1318 files_modified_this_turn,
1319 session_manager.checkpoint_manager,
1320 checkpoint_id
1321 )
1322 print(f"\r{' ' * 40}\r", end="", flush=True) # Clear the line
1323
1324 # Update commit message with better summary (only if different from temp message)
1325 temp_message = f"Auto: {user_input[:50]}"
1326 if summary != user_input[:50] and summary != temp_message:
1327 session_manager.checkpoint_manager._git_command(
1328 "--git-dir", session_manager.checkpoint_manager.bare_repo,
1329 "commit", "--amend", "-m", summary
1330 )
1331
1332 print(f"\n{YELLOW}📍 {checkpoint_id}: {summary}{RESET}")
1333 else:
1334 # Checkpoint creation failed (e.g., no actual diff)
1335 print(f"\n{DIM}(No project file changes to checkpoint){RESET}")
1336
1337 # Record this turn
1338 session_manager.current_session.add_turn(
1339 user_input,
1340 files_modified_this_turn,
1341 checkpoint_id=checkpoint_id
1342 )
1343
1344 # Auto-save session after each interaction
1345 session_manager.save_session()
1346
1347 print()
1348 except EOFError:
1349 session_manager.save_session()
1350 break
1351 except Exception as e: print(f"{RED}⏺ Error: {e}{RESET}")
1352
1353# ============================================================================
1354# Checkpoint & Session Management (Phase 1+2)
1355# ============================================================================
1356
1357class CheckpointManager:
1358 """Manage checkpoints using shadow bare git repository with session isolation"""
1359
1360 def __init__(self, project_path, session_id=None):
1361 self.project_path = project_path
1362 self.session_id = session_id
1363 self.nanocode_dir = os.path.join(project_path, ".nanocode")
1364 self.bare_repo = os.path.join(self.nanocode_dir, "checkpoint.git")
1365 self._init_bare_repo()
1366
1367 def set_session(self, session_id):
1368 """Set current session for checkpoint operations"""
1369 self.session_id = session_id
1370
1371 def _get_branch_name(self):
1372 """Get git branch name for current session"""
1373 if not self.session_id:
1374 return "main"
1375 return f"session_{self.session_id}"
1376
1377 def _init_bare_repo(self):
1378 """Initialize shadow bare repository"""
1379 if not os.path.exists(self.bare_repo):
1380 os.makedirs(self.bare_repo, exist_ok=True)
1381 try:
1382 subprocess.run(
1383 ["git", "init", "--bare", self.bare_repo],
1384 capture_output=True, check=True
1385 )
1386 except (subprocess.CalledProcessError, FileNotFoundError):
1387 # Git not available, will handle gracefully
1388 pass
1389
1390 def _git_command(self, *args, cwd=None):
1391 """Execute git command"""
1392 try:
1393 result = subprocess.run(
1394 ["git"] + list(args),
1395 cwd=cwd or self.project_path,
1396 capture_output=True,
1397 text=True,
1398 check=True
1399 )
1400 return result.stdout.strip()
1401 except (subprocess.CalledProcessError, FileNotFoundError) as e:
1402 return f"error: {e}"
1403
1404 def save_file_to_blob(self, filepath):
1405 """Save file to git blob storage
1406
1407 Returns:
1408 str: blob hash
1409 """
1410 try:
1411 result = subprocess.run(
1412 ["git", "--git-dir", self.bare_repo, "hash-object", "-w", filepath],
1413 capture_output=True, text=True, check=True
1414 )
1415 return result.stdout.strip()
1416 except Exception as e:
1417 return None
1418
1419 def restore_file_from_blob(self, blob_hash, filepath):
1420 """Restore file from git blob storage"""
1421 try:
1422 content = subprocess.run(
1423 ["git", "--git-dir", self.bare_repo, "cat-file", "-p", blob_hash],
1424 capture_output=True, check=True
1425 ).stdout
1426
1427 os.makedirs(os.path.dirname(filepath) or ".", exist_ok=True)
1428 with open(filepath, 'wb') as f:
1429 f.write(content)
1430 return True
1431 except Exception as e:
1432 return False
1433
1434 def get_file_git_info(self, filepath):
1435 """Get file's git info from project .git
1436
1437 Returns:
1438 dict: {"commit": "abc123", "has_changes": True/False} or None
1439 """
1440 try:
1441 # Check if file is tracked
1442 result = subprocess.run(
1443 ["git", "ls-files", "--error-unmatch", filepath],
1444 cwd=self.project_path,
1445 capture_output=True, text=True
1446 )
1447
1448 if result.returncode != 0:
1449 return None # Not tracked
1450
1451 # Get last commit for this file
1452 commit = subprocess.run(
1453 ["git", "log", "-1", "--format=%H", "--", filepath],
1454 cwd=self.project_path,
1455 capture_output=True, text=True, check=True
1456 ).stdout.strip()
1457
1458 # Check if file has local changes
1459 diff = subprocess.run(
1460 ["git", "diff", "HEAD", "--", filepath],
1461 cwd=self.project_path,
1462 capture_output=True, text=True, check=True
1463 ).stdout.strip()
1464
1465 return {
1466 "commit": commit,
1467 "has_changes": bool(diff)
1468 }
1469 except Exception as e:
1470 return None
1471
1472 def create_checkpoint(self, message, files_changed, conversation_snapshot=None, parent_commit=None):
1473 """Create a checkpoint on current session's branch
1474
1475 Args:
1476 message: Commit message
1477 files_changed: List of modified files
1478 conversation_snapshot: Conversation state to save
1479 parent_commit: Parent commit hash to branch from (for new sessions)
1480 """
1481 print(f"{DIM}[LOG] create_checkpoint: files_changed={files_changed}{RESET}", flush=True)
1482 if not files_changed or not self.session_id:
1483 return None
1484
1485 branch_name = self._get_branch_name()
1486
1487 # Save conversation snapshot
1488 if conversation_snapshot:
1489 snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1490 snapshots = {}
1491 if os.path.exists(snapshot_file):
1492 with open(snapshot_file, 'r') as f:
1493 snapshots = json.load(f)
1494
1495 # Create temp worktree for this session
1496 temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1497
1498 try:
1499 # Check if branch exists
1500 branch_exists = self._git_command("--git-dir", self.bare_repo, "rev-parse", "--verify", branch_name)
1501
1502 if not branch_exists or branch_exists.startswith("error"):
1503 # Create new branch
1504 os.makedirs(temp_worktree, exist_ok=True)
1505 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "config", "core.bare", "false")
1506
1507 # If parent_commit specified, branch from it
1508 if parent_commit:
1509 # Create branch from parent commit
1510 self._git_command("--git-dir", self.bare_repo, "branch", branch_name, parent_commit)
1511 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1512 else:
1513 # Create orphan branch (no parent)
1514 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", "--orphan", branch_name)
1515
1516 # Copy files to temp worktree
1517 for filepath in files_changed:
1518 print(f"{DIM}[LOG] checkpoint copying: {filepath}{RESET}", flush=True)
1519 if os.path.exists(filepath):
1520 file_size = os.path.getsize(filepath)
1521 print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1522 # Convert absolute path to relative path
1523 if os.path.isabs(filepath):
1524 rel_filepath = os.path.relpath(filepath, self.project_path)
1525 else:
1526 rel_filepath = filepath
1527 dest = os.path.join(temp_worktree, rel_filepath)
1528 os.makedirs(os.path.dirname(dest), exist_ok=True)
1529 with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1530 content = src.read()
1531 dst.write(content)
1532 print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1533 else:
1534 print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1535
1536 # Commit
1537 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1538 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1539 "commit", "-m", message, "--allow-empty")
1540
1541 commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1542 checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1543
1544 # Save conversation snapshot with checkpoint_id
1545 if checkpoint_id and conversation_snapshot:
1546 snapshots[checkpoint_id] = conversation_snapshot
1547 with open(snapshot_file, 'w') as f:
1548 json.dump(snapshots, f, indent=2)
1549
1550 return checkpoint_id
1551 else:
1552 # Branch exists, checkout and commit
1553 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1554
1555 # Update temp worktree
1556 for filepath in files_changed:
1557 print(f"{DIM}[LOG] checkpoint updating: {filepath}{RESET}", flush=True)
1558 if os.path.exists(filepath):
1559 file_size = os.path.getsize(filepath)
1560 print(f"{DIM}[LOG] source file exists: {filepath} ({file_size} bytes){RESET}", flush=True)
1561 # Convert absolute path to relative path
1562 if os.path.isabs(filepath):
1563 rel_filepath = os.path.relpath(filepath, self.project_path)
1564 else:
1565 rel_filepath = filepath
1566 dest = os.path.join(temp_worktree, rel_filepath)
1567 os.makedirs(os.path.dirname(dest), exist_ok=True)
1568 with open(filepath, 'rb') as src, open(dest, 'wb') as dst:
1569 content = src.read()
1570 dst.write(content)
1571 print(f"{DIM}[LOG] copied to temp_worktree: {dest} ({len(content)} bytes){RESET}", flush=True)
1572 else:
1573 print(f"{DIM}[LOG] source file NOT exists: {filepath}{RESET}", flush=True)
1574
1575 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "add", "-A")
1576 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1577 "commit", "-m", message, "--allow-empty")
1578
1579 commit_hash = self._git_command("--git-dir", self.bare_repo, "rev-parse", "HEAD")
1580 checkpoint_id = commit_hash[:8] if commit_hash and not commit_hash.startswith("error") else None
1581
1582 # Save conversation snapshot with checkpoint_id
1583 if checkpoint_id and conversation_snapshot:
1584 snapshots[checkpoint_id] = conversation_snapshot
1585 with open(snapshot_file, 'w') as f:
1586 json.dump(snapshots, f, indent=2)
1587
1588 return checkpoint_id
1589 except Exception as e:
1590 return None
1591
1592 def list_checkpoints(self, limit=10, show_all=False):
1593 """List recent checkpoints for current session
1594
1595 Args:
1596 limit: Maximum number of checkpoints to show
1597 show_all: If True, show all sessions; if False, only show current session
1598 """
1599 if not self.session_id and not show_all:
1600 return []
1601
1602 try:
1603 if show_all:
1604 # Show all branches
1605 args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", "--all"]
1606 else:
1607 # Show only current session's branch
1608 branch_name = self._get_branch_name()
1609 args = ["--git-dir", self.bare_repo, "log", f"--max-count={limit}", "--oneline", branch_name]
1610
1611 log = self._git_command(*args)
1612 if log and not log.startswith("error"):
1613 return [line.split(" ", 1) for line in log.split("\n") if line]
1614 return []
1615 except:
1616 return []
1617
1618 def restore_checkpoint(self, checkpoint_id, session_baseline_files):
1619 """Restore files to checkpoint state and reset current session's branch
1620
1621 This properly handles files that were added after the checkpoint by:
1622 1. Restoring files that exist in checkpoint
1623 2. Restoring files to baseline if they don't exist in checkpoint
1624
1625 Args:
1626 checkpoint_id: Checkpoint hash to restore to
1627 session_baseline_files: Dict of baseline file states from session
1628
1629 Returns:
1630 tuple: (success: bool, conversation_snapshot: dict or None)
1631 """
1632 if not self.session_id:
1633 return False, None
1634
1635 branch_name = self._get_branch_name()
1636 temp_worktree = os.path.join(self.nanocode_dir, f"temp_worktree_{self.session_id}")
1637
1638 try:
1639 # Checkout branch first
1640 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree, "checkout", branch_name, "-f")
1641
1642 # Reset branch to checkpoint (discards future commits on this branch)
1643 self._git_command("--git-dir", self.bare_repo, "reset", "--hard", checkpoint_id)
1644
1645 # Checkout to temp worktree
1646 self._git_command("--git-dir", self.bare_repo, "--work-tree", temp_worktree,
1647 "checkout", checkpoint_id, "-f")
1648
1649 # Get list of files in checkpoint
1650 files_in_checkpoint_str = self._git_command(
1651 "--git-dir", self.bare_repo,
1652 "ls-tree", "-r", "--name-only", checkpoint_id
1653 )
1654
1655 files_in_checkpoint = set()
1656 if files_in_checkpoint_str and not files_in_checkpoint_str.startswith("error"):
1657 files_in_checkpoint = set(f for f in files_in_checkpoint_str.split('\n') if f.strip())
1658
1659 print(f"\n{DIM}Files in checkpoint: {len(files_in_checkpoint)}{RESET}")
1660 print(f"{DIM}Files modified in session: {len(session_baseline_files)}{RESET}\n")
1661
1662 # Process each file that was modified in this session
1663 for filepath, baseline_source in session_baseline_files.items():
1664 # Convert to relative path for comparison
1665 if os.path.isabs(filepath):
1666 rel_filepath = os.path.relpath(filepath, self.project_path)
1667 else:
1668 rel_filepath = filepath
1669
1670 # Normalize path: remove leading ./
1671 normalized_rel_path = rel_filepath.lstrip('./')
1672
1673 if normalized_rel_path in files_in_checkpoint:
1674 # File exists in checkpoint - restore from checkpoint
1675 src = os.path.join(temp_worktree, normalized_rel_path)
1676 dest = os.path.join(self.project_path, normalized_rel_path)
1677
1678 dest_dir = os.path.dirname(dest)
1679 if dest_dir:
1680 os.makedirs(dest_dir, exist_ok=True)
1681
1682 with open(src, 'rb') as s, open(dest, 'wb') as d:
1683 d.write(s.read())
1684
1685 print(f" {GREEN}{RESET} {filepath} {DIM}(from checkpoint){RESET}")
1686 else:
1687 # File doesn't exist in checkpoint - restore to baseline
1688 abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1689
1690 if baseline_source["type"] == "git":
1691 # Restore from project .git (use normalized path for git)
1692 result = subprocess.run(
1693 ["git", "checkout", baseline_source["commit"], "--", normalized_rel_path],
1694 cwd=self.project_path,
1695 capture_output=True, text=True
1696 )
1697 if result.returncode == 0:
1698 print(f" {CYAN}{RESET} {filepath} {DIM}(to baseline: git {baseline_source['commit'][:8]}){RESET}")
1699
1700 elif baseline_source["type"] == "blob":
1701 # Restore from blob
1702 if self.restore_file_from_blob(baseline_source["hash"], abs_filepath):
1703 print(f" {CYAN}{RESET} {filepath} {DIM}(to baseline: blob {baseline_source['hash'][:8]}){RESET}")
1704
1705 elif baseline_source["type"] == "new":
1706 # Delete new file
1707 if os.path.exists(abs_filepath):
1708 os.remove(abs_filepath)
1709 print(f" {YELLOW}{RESET} {filepath} {DIM}(deleted: was added after checkpoint){RESET}")
1710
1711 # Load conversation snapshot
1712 snapshot_file = os.path.join(self.nanocode_dir, "conversation_snapshots.json")
1713 conversation_snapshot = None
1714 if os.path.exists(snapshot_file):
1715 with open(snapshot_file, 'r') as f:
1716 snapshots = json.load(f)
1717 conversation_snapshot = snapshots.get(checkpoint_id)
1718
1719 return True, conversation_snapshot
1720 except Exception as e:
1721 print(f"{RED}Error during restore: {e}{RESET}")
1722 return False, None
1723
1724
1725class Session:
1726 """Represents a conversation session"""
1727
1728 def __init__(self, session_id=None):
1729 self.session_id = session_id or self._generate_session_id()
1730 self.messages = []
1731 self.file_states = {}
1732 self.baseline_files = {} # Track original file versions for rollback
1733 self.turns = [] # Track conversation turns
1734 self.metadata = {
1735 'created_at': time.time(),
1736 'last_active': time.time(),
1737 'description': '',
1738 'cwd': os.getcwd(),
1739 'parent_checkpoint': None, # Track where this session branched from
1740 'parent_session': None, # Track which session it branched from
1741 'git_commit': None, # Project .git commit hash when session started
1742 'git_branch': None, # Project .git branch when session started
1743 'git_dirty': False, # Whether project had uncommitted changes
1744 }
1745
1746 def _generate_session_id(self):
1747 """Generate unique session ID"""
1748 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
1749 random_suffix = ''.join(random.choices('0123456789abcdef', k=4))
1750 return f"{timestamp}_{random_suffix}"
1751
1752 def _get_project_git_info(self):
1753 """Get current project git state"""
1754 try:
1755 cwd = self.metadata.get('cwd', os.getcwd())
1756
1757 # Get current commit
1758 commit = subprocess.run(
1759 ["git", "rev-parse", "HEAD"],
1760 cwd=cwd,
1761 capture_output=True, text=True, check=True
1762 ).stdout.strip()
1763
1764 # Get current branch
1765 branch = subprocess.run(
1766 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
1767 cwd=cwd,
1768 capture_output=True, text=True, check=True
1769 ).stdout.strip()
1770
1771 # Check if dirty
1772 status = subprocess.run(
1773 ["git", "status", "--porcelain"],
1774 cwd=cwd,
1775 capture_output=True, text=True, check=True
1776 ).stdout.strip()
1777
1778 return {
1779 'git_commit': commit[:8],
1780 'git_branch': branch,
1781 'git_dirty': bool(status)
1782 }
1783 except:
1784 return None
1785
1786 def capture_git_state(self):
1787 """Capture current project git state into metadata"""
1788 git_info = self._get_project_git_info()
1789 if git_info:
1790 self.metadata.update(git_info)
1791
1792 def track_file_state(self, filepath):
1793 """Track file state for conflict detection"""
1794 if os.path.exists(filepath):
1795 with open(filepath, 'rb') as f:
1796 content = f.read()
1797 file_hash = hashlib.md5(content).hexdigest()
1798 self.file_states[filepath] = {
1799 'hash': file_hash,
1800 'mtime': os.path.getmtime(filepath),
1801 'size': len(content)
1802 }
1803
1804 def detect_conflicts(self):
1805 """Detect if tracked files have been modified outside this session
1806
1807 Returns:
1808 list: List of conflicted file paths
1809 """
1810 conflicts = []
1811
1812 for filepath, saved_state in self.file_states.items():
1813 if os.path.exists(filepath):
1814 with open(filepath, 'rb') as f:
1815 content = f.read()
1816 current_hash = hashlib.md5(content).hexdigest()
1817 current_mtime = os.path.getmtime(filepath)
1818
1819 # Check if file has changed
1820 if (current_hash != saved_state['hash'] or
1821 current_mtime != saved_state['mtime']):
1822 conflicts.append(filepath)
1823 else:
1824 # File was deleted
1825 conflicts.append(f"{filepath} (deleted)")
1826
1827 return conflicts
1828
1829 def update_file_states(self):
1830 """Update all tracked file states to current state"""
1831 for filepath in list(self.file_states.keys()):
1832 if os.path.exists(filepath):
1833 self.track_file_state(filepath)
1834 else:
1835 # Remove deleted files from tracking
1836 del self.file_states[filepath]
1837
1838 def add_turn(self, user_input, files_modified, checkpoint_id=None):
1839 """Record a conversation turn
1840
1841 Args:
1842 user_input: User's input for this turn
1843 files_modified: Set of files modified in this turn
1844 checkpoint_id: Associated checkpoint ID if files were modified
1845 """
1846 turn_number = len(self.turns) + 1
1847 turn_data = {
1848 'turn_number': turn_number,
1849 'timestamp': time.time(),
1850 'user_input': user_input[:100], # Truncate for preview
1851 'files_modified': list(files_modified),
1852 'checkpoint_id': checkpoint_id,
1853 'message_count': len(self.messages) # Track message index
1854 }
1855 self.turns.append(turn_data)
1856 return turn_number
1857
1858 def to_dict(self):
1859 """Serialize to dict"""
1860 return {
1861 'session_id': self.session_id,
1862 'messages': self.messages,
1863 'file_states': self.file_states,
1864 'baseline_files': self.baseline_files,
1865 'turns': self.turns,
1866 'metadata': self.metadata
1867 }
1868
1869 @staticmethod
1870 def from_dict(data):
1871 """Deserialize from dict"""
1872 session = Session(session_id=data['session_id'])
1873 session.messages = data.get('messages', [])
1874 session.file_states = data.get('file_states', {})
1875 session.baseline_files = data.get('baseline_files', {})
1876 session.turns = data.get('turns', [])
1877 session.metadata = data.get('metadata', {})
1878 return session
1879
1880
1881class SessionManager:
1882 """Manage multiple sessions"""
1883
1884 def __init__(self, project_path):
1885 self.project_path = project_path
1886 self.sessions_dir = os.path.join(project_path, ".nanocode", "sessions")
1887 self.current_session = None
1888 self.checkpoint_manager = CheckpointManager(project_path)
1889 self.parent_commit_for_next_checkpoint = None # Track parent for first checkpoint
1890 os.makedirs(self.sessions_dir, exist_ok=True)
1891
1892 def save_baseline_if_needed(self, filepath):
1893 """Save file's baseline version before first modification
1894
1895 This is called before write/edit operations to preserve the original state.
1896 """
1897 if not self.current_session:
1898 return
1899
1900 # Already saved
1901 if filepath in self.current_session.baseline_files:
1902 return
1903
1904 print(f"{DIM}[LOG] Saving baseline for: {filepath}{RESET}", flush=True)
1905
1906 # Get file info from project .git
1907 git_info = self.checkpoint_manager.get_file_git_info(filepath)
1908
1909 if git_info:
1910 # File is tracked by project .git
1911 if git_info["has_changes"]:
1912 # Has local changes - save to blob
1913 blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1914 if blob_hash:
1915 self.current_session.baseline_files[filepath] = {
1916 "type": "blob",
1917 "hash": blob_hash
1918 }
1919 print(f"{DIM}[LOG] Saved dirty file to blob: {blob_hash[:8]}{RESET}", flush=True)
1920 else:
1921 # Clean - just record commit
1922 self.current_session.baseline_files[filepath] = {
1923 "type": "git",
1924 "commit": git_info["commit"]
1925 }
1926 print(f"{DIM}[LOG] Recorded git commit: {git_info['commit'][:8]}{RESET}", flush=True)
1927 else:
1928 # Untracked file or no .git
1929 if os.path.exists(filepath):
1930 # Save existing untracked file to blob
1931 blob_hash = self.checkpoint_manager.save_file_to_blob(filepath)
1932 if blob_hash:
1933 self.current_session.baseline_files[filepath] = {
1934 "type": "blob",
1935 "hash": blob_hash
1936 }
1937 print(f"{DIM}[LOG] Saved untracked file to blob: {blob_hash[:8]}{RESET}", flush=True)
1938 else:
1939 # New file - mark as new
1940 self.current_session.baseline_files[filepath] = {
1941 "type": "new"
1942 }
1943 print(f"{DIM}[LOG] Marked as new file{RESET}", flush=True)
1944
1945 # Auto-save session to persist baseline_files
1946 self.save_session()
1947
1948 def restore_baseline(self):
1949 """Restore all files to their baseline state
1950
1951 Returns:
1952 bool: Success status
1953 """
1954 if not self.current_session:
1955 return False
1956
1957 if not self.current_session.baseline_files:
1958 # No files modified = already at baseline state = success
1959 print(f"{DIM}No files to restore (already at baseline){RESET}")
1960 return True
1961
1962 print(f"\n{BOLD}Restoring {len(self.current_session.baseline_files)} files to baseline...{RESET}\n")
1963
1964 success_count = 0
1965 for filepath, source in self.current_session.baseline_files.items():
1966 try:
1967 # Normalize to absolute path
1968 abs_filepath = filepath if os.path.isabs(filepath) else os.path.join(self.project_path, filepath)
1969 # Get relative path for git operations
1970 rel_filepath = os.path.relpath(abs_filepath, self.project_path)
1971
1972 if source["type"] == "git":
1973 # Restore from project .git (use relative path)
1974 result = subprocess.run(
1975 ["git", "checkout", source["commit"], "--", rel_filepath],
1976 cwd=self.project_path,
1977 capture_output=True, text=True
1978 )
1979 if result.returncode == 0:
1980 print(f" {GREEN}{RESET} {filepath} {DIM}(from git {source['commit'][:8]}){RESET}")
1981 success_count += 1
1982 else:
1983 print(f" {RED}{RESET} {filepath} {DIM}(git checkout failed){RESET}")
1984
1985 elif source["type"] == "blob":
1986 # Restore from checkpoint.git blob (use absolute path)
1987 if self.checkpoint_manager.restore_file_from_blob(source["hash"], abs_filepath):
1988 print(f" {GREEN}{RESET} {filepath} {DIM}(from blob {source['hash'][:8]}){RESET}")
1989 success_count += 1
1990 else:
1991 print(f" {RED}{RESET} {filepath} {DIM}(blob restore failed){RESET}")
1992
1993 elif source["type"] == "new":
1994 # Delete new file (use absolute path)
1995 if os.path.exists(abs_filepath):
1996 os.remove(abs_filepath)
1997 print(f" {GREEN}{RESET} {filepath} {DIM}(deleted new file){RESET}")
1998 success_count += 1
1999 else:
2000 print(f" {DIM}{RESET} {filepath} {DIM}(already deleted){RESET}")
2001 success_count += 1
2002
2003 except Exception as e:
2004 print(f" {RED}{RESET} {filepath} {DIM}(error: {e}){RESET}")
2005
2006 print(f"\n{GREEN}✓ Restored {success_count}/{len(self.current_session.baseline_files)} files{RESET}")
2007 return success_count > 0
2008
2009 def create_session(self, description="", parent_checkpoint=None, parent_session=None):
2010 """Create new session
2011
2012 Args:
2013 description: Session description
2014 parent_checkpoint: Checkpoint ID this session branches from
2015 parent_session: Session ID this session branches from
2016 """
2017 session = Session()
2018 session.metadata['description'] = description
2019 session.metadata['parent_checkpoint'] = parent_checkpoint
2020 session.metadata['parent_session'] = parent_session
2021 # Capture project git state
2022 session.capture_git_state()
2023 self.current_session = session
2024 # Set checkpoint manager to use this session
2025 self.checkpoint_manager.set_session(session.session_id)
2026 # Store parent commit for first checkpoint
2027 self.parent_commit_for_next_checkpoint = parent_checkpoint
2028 self.save_session()
2029 return session
2030
2031 def save_session(self):
2032 """Save current session to disk"""
2033 if not self.current_session:
2034 return
2035
2036 self.current_session.metadata['last_active'] = time.time()
2037 session_file = os.path.join(
2038 self.sessions_dir,
2039 f"{self.current_session.session_id}.json"
2040 )
2041
2042 with open(session_file, 'w') as f:
2043 json.dump(self.current_session.to_dict(), f, indent=2)
2044
2045 def load_session(self, session_id):
2046 """Load session from disk"""
2047 session_file = os.path.join(self.sessions_dir, f"{session_id}.json")
2048
2049 if not os.path.exists(session_file):
2050 return None
2051
2052 with open(session_file, 'r') as f:
2053 data = json.load(f)
2054
2055 session = Session.from_dict(data)
2056 self.current_session = session
2057 # Set checkpoint manager to use this session
2058 self.checkpoint_manager.set_session(session.session_id)
2059 return session
2060
2061 def list_sessions(self):
2062 """List all sessions"""
2063 sessions = []
2064
2065 if not os.path.exists(self.sessions_dir):
2066 return sessions
2067
2068 for filename in os.listdir(self.sessions_dir):
2069 if filename.endswith('.json'):
2070 filepath = os.path.join(self.sessions_dir, filename)
2071 try:
2072 with open(filepath, 'r') as f:
2073 data = json.load(f)
2074 sessions.append({
2075 'session_id': data['session_id'],
2076 'metadata': data['metadata'],
2077 'message_count': len(data.get('messages', [])),
2078 })
2079 except:
2080 pass
2081
2082 return sorted(sessions, key=lambda x: x['metadata'].get('last_active', 0), reverse=True)
2083
2084 def load_last_session(self):
2085 """Load the most recent session"""
2086 sessions = self.list_sessions()
2087 if sessions:
2088 return self.load_session(sessions[0]['session_id'])
2089 return None
2090
2091
2092
2093
2094def handle_turns_command(session_manager, turn_number=None):
2095 """Handle /t or /turns command to display or restore conversation turns
2096
2097 Args:
2098 session_manager: SessionManager instance
2099 turn_number: If provided, restore to this turn; otherwise list turns
2100
2101 Returns:
2102 tuple: (success: bool, prefill_input: str or None)
2103 """
2104 if not session_manager.current_session:
2105 print(f"{YELLOW}⚠ No active session{RESET}")
2106 return False, None
2107
2108 turns = session_manager.current_session.turns
2109
2110 if not turns:
2111 print(f"{DIM}No conversation turns yet{RESET}")
2112 return False, None
2113
2114 # If turn_number provided, restore to that turn
2115 if turn_number is not None:
2116 return restore_to_turn(session_manager, turn_number)
2117
2118 # Otherwise, list all turns
2119 print(f"\n{BOLD}💬 Conversation Turns:{RESET}\n")
2120
2121 for turn in turns:
2122 turn_num = turn['turn_number']
2123 timestamp = turn['timestamp']
2124 user_input = turn['user_input']
2125 files_modified = turn.get('files_modified', [])
2126 checkpoint_id = turn.get('checkpoint_id')
2127
2128 # Calculate time ago
2129 time_ago = time.time() - timestamp
2130 if time_ago < 60:
2131 time_str = f"{int(time_ago)}s ago"
2132 elif time_ago < 3600:
2133 time_str = f"{int(time_ago/60)}m ago"
2134 elif time_ago < 86400:
2135 time_str = f"{int(time_ago/3600)}h ago"
2136 else:
2137 time_str = f"{int(time_ago/86400)}d ago"
2138
2139 # Format turn line
2140 checkpoint_marker = f" {YELLOW}[{checkpoint_id}]{RESET}" if checkpoint_id else ""
2141 files_marker = f" {GREEN}{RESET}" if files_modified else ""
2142
2143 print(f" {CYAN}turn_{turn_num}{RESET}{checkpoint_marker}{files_marker} {DIM}({time_str}){RESET}")
2144 print(f" {DIM}└─{RESET} {user_input}")
2145
2146 if files_modified:
2147 files_display = ", ".join(files_modified[:3])
2148 if len(files_modified) > 3:
2149 files_display += f" +{len(files_modified)-3} more"
2150 print(f" {DIM}Files: {files_display}{RESET}")
2151
2152 print()
2153
2154 print(f"{DIM}Tip: Use '/t <number>' to undo that turn and restart from previous{RESET}")
2155 return False, None # Return False to indicate no restore action
2156
2157
2158def restore_to_turn(session_manager, turn_number):
2159 """Restore files and conversation to a specific turn
2160
2161 Args:
2162 session_manager: SessionManager instance
2163 turn_number: Turn number to undo (1-indexed, will restore to turn_number-1)
2164
2165 Returns:
2166 tuple: (success: bool, prefill_input: str or None)
2167 """
2168 turns = session_manager.current_session.turns
2169
2170 # User wants to undo turn_N, so restore to turn_(N-1)
2171 # Special case: /t 1 means clear all turns
2172 if turn_number == 1:
2173 print(f"{YELLOW}⚠ This will undo turn_1 and reset to session start{RESET}")
2174 confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
2175 if confirm != 'y':
2176 print(f"{DIM}Cancelled{RESET}")
2177 return False, None
2178
2179 # Restore to baseline
2180 success = session_manager.restore_baseline()
2181 if not success:
2182 return False, None
2183
2184 # Clear all turns and messages
2185 session_manager.current_session.turns = []
2186 session_manager.current_session.messages = []
2187 session_manager.current_session.update_file_states()
2188 session_manager.parent_commit_for_next_checkpoint = None
2189
2190 print(f"{GREEN}✓ Reset to session start{RESET}")
2191
2192 # Return original turn_1 input for prefill
2193 original_input = turns[0]['user_input'] if turns else None
2194 return True, original_input
2195
2196 # Normal case: restore to turn_(N-1)
2197 restore_to = turn_number - 1
2198
2199 # Validate
2200 if restore_to < 1 or restore_to > len(turns):
2201 print(f"{RED}Invalid turn number. Valid range: 1-{len(turns)}{RESET}")
2202 return False, None
2203
2204 turn = turns[restore_to - 1]
2205
2206 # Find the most recent checkpoint at or before restore_to
2207 checkpoint_id = None
2208 checkpoint_turn = None
2209
2210 for i in range(restore_to - 1, -1, -1):
2211 if turns[i].get('checkpoint_id'):
2212 checkpoint_id = turns[i]['checkpoint_id']
2213 checkpoint_turn = i + 1
2214 break
2215
2216 # Get original input from the turn being undone
2217 # Note: turn['user_input'] is truncated to 100 chars, so get full input from messages
2218 original_input = None
2219 if turn_number <= len(turns):
2220 turn_to_undo = turns[turn_number - 1]
2221 # Find the user message for this turn
2222 # turn_to_undo['message_count'] is the total messages after this turn
2223 # The user message for this turn is at index (message_count - 2) if there's an assistant response
2224 # or (message_count - 1) if it's the last message
2225 msg_idx = turn_to_undo['message_count'] - 2 # Assume there's an assistant response
2226 if msg_idx >= 0 and msg_idx < len(session_manager.current_session.messages):
2227 msg = session_manager.current_session.messages[msg_idx]
2228 if msg['role'] == 'user' and isinstance(msg['content'], str):
2229 original_input = msg['content']
2230
2231 # Show warning
2232 future_turns = len(turns) - restore_to
2233 print(f"\n{YELLOW}⚠ This will undo turn_{turn_number} (restore to turn_{restore_to}){RESET}")
2234
2235 if checkpoint_id:
2236 if checkpoint_turn == restore_to:
2237 print(f"{YELLOW}⚠ Files: restored to turn_{restore_to} checkpoint ({checkpoint_id}){RESET}")
2238 else:
2239 print(f"{YELLOW}⚠ Files: restored to turn_{checkpoint_turn} checkpoint ({checkpoint_id}){RESET}")
2240 else:
2241 print(f"{YELLOW}⚠ Files: restored to baseline (no checkpoints before turn_{restore_to}){RESET}")
2242
2243 print(f"{YELLOW}⚠ Conversation: restored to turn_{restore_to} ({turn['message_count']} messages){RESET}")
2244
2245 if future_turns > 0:
2246 print(f"{YELLOW}⚠ Future turns ({restore_to + 1}-{len(turns)}) will be discarded{RESET}")
2247
2248 if original_input:
2249 print(f"\n{CYAN}Original turn_{turn_number} input will be prefilled:{RESET}")
2250 print(f" {DIM}{original_input[:100]}{'...' if len(original_input) > 100 else ''}{RESET}")
2251
2252 confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
2253
2254 if confirm != 'y':
2255 print(f"{DIM}Cancelled{RESET}")
2256 return False, None
2257
2258 # Restore files
2259 if checkpoint_id:
2260 success, _ = session_manager.checkpoint_manager.restore_checkpoint(
2261 checkpoint_id,
2262 session_manager.current_session.baseline_files
2263 )
2264 if not success:
2265 print(f"{RED}✗ Failed to restore files{RESET}")
2266 return False, None
2267 print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
2268 else:
2269 # No checkpoint before this turn, restore to baseline
2270 success = session_manager.restore_baseline()
2271 if not success:
2272 print(f"{RED}✗ Failed to restore to baseline{RESET}")
2273 return False, None
2274 print(f"{GREEN}✓ Restored files to baseline{RESET}")
2275
2276 # Restore conversation by truncating messages (modify session directly)
2277 session_manager.current_session.messages = session_manager.current_session.messages[:turn['message_count']]
2278
2279 # Truncate turns list to restore_to
2280 session_manager.current_session.turns = turns[:restore_to]
2281
2282 # Update file states to match restored files
2283 session_manager.current_session.update_file_states()
2284
2285 # Reset parent commit for next checkpoint
2286 session_manager.parent_commit_for_next_checkpoint = checkpoint_id if checkpoint_id else None
2287
2288 print(f"{GREEN}✓ Restored to turn_{restore_to} ({len(session_manager.current_session.messages)} messages){RESET}")
2289
2290 return True, original_input # Return success and prefill input
2291
2292
2293def handle_checkpoint_command(parts, session_manager, files_modified):
2294 """Handle /checkpoint or /c commands
2295
2296 Returns:
2297 messages: New messages list if conversation was restored, None otherwise
2298 """
2299 # Default to list if no subcommand
2300 if len(parts) < 2:
2301 parts.append("list")
2302
2303 cmd = parts[1]
2304
2305 # If cmd looks like a commit hash (7-8 hex chars), treat as restore
2306 if len(cmd) >= 7 and len(cmd) <= 8 and all(c in '0123456789abcdef' for c in cmd.lower()):
2307 cmd = "restore"
2308 checkpoint_id = parts[1]
2309 else:
2310 checkpoint_id = None
2311
2312 if cmd == "baseline" or cmd == "base":
2313 # Restore all files to baseline (session start state)
2314 if not session_manager.current_session.baseline_files:
2315 print(f"{YELLOW}⚠ No baseline: no files have been modified in this session{RESET}")
2316 return None
2317
2318 print(f"{YELLOW}⚠ This will restore all modified files to their original state{RESET}")
2319 print(f"{YELLOW}⚠ Files to restore: {len(session_manager.current_session.baseline_files)}{RESET}")
2320
2321 # Show files
2322 print(f"\n{DIM}Files:{RESET}")
2323 for filepath in list(session_manager.current_session.baseline_files.keys())[:10]:
2324 print(f" {DIM}{filepath}{RESET}")
2325 if len(session_manager.current_session.baseline_files) > 10:
2326 print(f" {DIM}... and {len(session_manager.current_session.baseline_files) - 10} more{RESET}")
2327
2328 confirm = input(f"\n{BOLD}Continue? (y/N): {RESET}").strip().lower()
2329
2330 if confirm != 'y':
2331 print(f"{DIM}Cancelled{RESET}")
2332 return None
2333
2334 success = session_manager.restore_baseline()
2335 if success:
2336 # Clear conversation
2337 print(f"{GREEN}✓ Conversation cleared{RESET}")
2338 return []
2339 else:
2340 return None
2341
2342 elif cmd == "list" or cmd == "all" or cmd == "--all":
2343 show_all = (cmd == "all" or "--all" in parts)
2344
2345 if show_all:
2346 # Show git graph of all branches
2347 print(f"\n{BOLD}📍 Checkpoint Graph:{RESET}\n")
2348
2349 # Use git log --graph --all to show the tree
2350 # Format: %h = short hash, %d = ref names, %s = subject, %ar = relative date
2351 graph_output = session_manager.checkpoint_manager._git_command(
2352 "--git-dir", session_manager.checkpoint_manager.bare_repo,
2353 "log", "--graph", "--all", "--oneline",
2354 "--format=%h %s (%ar)", "-20"
2355 )
2356
2357 if graph_output and not graph_output.startswith("error"):
2358 # Also get branch info for each commit
2359 branches_output = session_manager.checkpoint_manager._git_command(
2360 "--git-dir", session_manager.checkpoint_manager.bare_repo,
2361 "branch", "-a", "--contains"
2362 )
2363
2364 # Parse and display
2365 for line in graph_output.split('\n'):
2366 if not line.strip():
2367 continue
2368
2369 # Extract commit hash
2370 match = re.search(r'\b([0-9a-f]{7,8})\b', line)
2371 if match:
2372 commit_hash = match.group(1)
2373
2374 # Get branches containing this commit
2375 branch_info = session_manager.checkpoint_manager._git_command(
2376 "--git-dir", session_manager.checkpoint_manager.bare_repo,
2377 "branch", "-a", "--contains", commit_hash
2378 )
2379
2380 # Extract session names from branches
2381 session_names = []
2382 if branch_info and not branch_info.startswith("error"):
2383 for branch_line in branch_info.split('\n'):
2384 branch_line = branch_line.strip().lstrip('* ')
2385 if branch_line.startswith('session_'):
2386 # Shorten session name: session_20260130_103323_f7 -> s:20260130_103323_f7
2387 session_short = 's:' + branch_line[8:] # Remove 'session_' prefix
2388 session_names.append(session_short)
2389
2390 # Highlight commit hash
2391 line = line.replace(commit_hash, f"{CYAN}{commit_hash}{RESET}")
2392
2393 # Add session info if found
2394 if session_names:
2395 # Insert session names after commit hash
2396 session_str = f"{GREEN}[{', '.join(session_names[:2])}]{RESET}"
2397 line = line.replace(commit_hash + f"{RESET}", commit_hash + f"{RESET} {session_str}")
2398
2399
2400 print(f" {line}")
2401 print()
2402 else:
2403 print(f"{DIM}No checkpoints yet{RESET}\n")
2404
2405 print(f"{DIM}Restore: /c <hash>{RESET}")
2406 return None
2407 else:
2408 # Show current session's checkpoints
2409 checkpoints = session_manager.checkpoint_manager.list_checkpoints(show_all=False)
2410 if not checkpoints:
2411 print(f"{DIM}No checkpoints yet{RESET}")
2412 return None
2413
2414 print(f"\n{BOLD}📍 Checkpoints:{RESET}\n")
2415
2416 # Get checkpoint details from git log with timestamp
2417 for commit_hash, message in checkpoints[:10]: # Show first 10 (already newest first from git log)
2418 # Try to get timestamp from git
2419 timestamp_str = session_manager.checkpoint_manager._git_command(
2420 "--git-dir", session_manager.checkpoint_manager.bare_repo,
2421 "log", "-1", "--format=%ar", commit_hash
2422 )
2423 if timestamp_str.startswith("error"):
2424 timestamp_str = ""
2425
2426 # Get modified files
2427 files_str = session_manager.checkpoint_manager._git_command(
2428 "--git-dir", session_manager.checkpoint_manager.bare_repo,
2429 "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash
2430 )
2431 files = []
2432 if files_str and not files_str.startswith("error"):
2433 files = [f.strip() for f in files_str.split('\n') if f.strip()]
2434
2435 # Format: hash | time ago | message
2436 time_part = f"{DIM}{timestamp_str}{RESET}" if timestamp_str else ""
2437 print(f" {CYAN}{commit_hash}{RESET} {time_part}")
2438 print(f" {DIM}└─{RESET} {message}")
2439 if files:
2440 files_display = ", ".join(files[:3])
2441 if len(files) > 3:
2442 files_display += f" +{len(files)-3} more"
2443 print(f" {DIM} Files: {files_display}{RESET}")
2444 print()
2445
2446 print(f"{DIM}Tip: Use '/c all' or '/ca' to see git graph{RESET}")
2447 print(f"{DIM}Restore: /c <hash>{RESET}")
2448 return None
2449
2450 elif cmd == "restore":
2451 if not checkpoint_id:
2452 print(f"{RED}Usage: /c <checkpoint_id>{RESET}")
2453 return None
2454
2455 print(f"{YELLOW}⚠ This will restore files AND conversation to checkpoint {checkpoint_id}{RESET}")
2456 print(f"{YELLOW}⚠ Future checkpoints will be discarded from history{RESET}")
2457 confirm = input(f"{BOLD}Continue? (y/N): {RESET}").strip().lower()
2458
2459 if confirm != 'y':
2460 print(f"{DIM}Cancelled{RESET}")
2461 return None
2462
2463 success, conversation_snapshot = session_manager.checkpoint_manager.restore_checkpoint(
2464 checkpoint_id,
2465 session_manager.current_session.baseline_files
2466 )
2467 if success:
2468 print(f"{GREEN}✓ Restored files to checkpoint {checkpoint_id}{RESET}")
2469 if conversation_snapshot:
2470 print(f"{GREEN}✓ Restored conversation ({len(conversation_snapshot)} messages){RESET}")
2471 return conversation_snapshot
2472 else:
2473 print(f"{YELLOW}⚠ No conversation snapshot found for this checkpoint{RESET}")
2474 return None
2475 else:
2476 print(f"{RED}✗ Failed to restore checkpoint{RESET}")
2477 return None
2478
2479 else:
2480 print(f"{RED}Unknown command: {cmd}{RESET}")
2481 return None
2482
2483
2484if __name__ == "__main__":
2485 main()
2486