Last active 1 month ago

Revision 6c0b653ddbfb799fa52b2e1e17827254f25f8cd1

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