Last active 1 month ago

Revision 11291fb4fefa8e832746bc660aed8e9176e2b62f

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