yncli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ import os
2
+ import platform
3
+ import subprocess
4
+ import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, Any
7
+
8
+ from yncli.clean_text import clean_text_for_terminal
9
+
10
+
11
+ def get_current_datetime_str() -> str:
12
+ now = datetime.datetime.now()
13
+ return now.strftime("%A, %d %B %Y %H:%M:%S %Z")
14
+
15
+
16
+ def get_system_info(cwd: str = ".") -> Dict[str, Any]:
17
+ now = datetime.datetime.now()
18
+ os_name = platform.system()
19
+ os_release = platform.release()
20
+ shell = "powershell.exe" if os_name == "Windows" else os.getenv("SHELL", "/bin/bash")
21
+
22
+ info = {
23
+ "datetime": get_current_datetime_str(),
24
+ "date_iso": now.isoformat(),
25
+ "os": f"{os_name} {os_release} ({platform.machine()})",
26
+ "shell": shell,
27
+ "cwd": str(Path(cwd).resolve()),
28
+ "user": os.getenv("USERNAME", os.getenv("USER", "developer")),
29
+ }
30
+ return info
31
+
32
+
33
+ def change_directory(path: str, current_cwd: str = ".") -> Dict[str, Any]:
34
+ """
35
+ Changes the active working directory for the agent.
36
+ """
37
+ target = Path(current_cwd) / Path(path) if not Path(path).is_absolute() else Path(path)
38
+ target = target.resolve()
39
+
40
+ if not target.exists():
41
+ return {
42
+ "success": False,
43
+ "message": f"[ERROR] Directory tidak ditemukan: {target}",
44
+ "new_cwd": str(Path(current_cwd).resolve())
45
+ }
46
+ if not target.is_dir():
47
+ return {
48
+ "success": False,
49
+ "message": f"[ERROR] Path bukan direktori: {target}",
50
+ "new_cwd": str(Path(current_cwd).resolve())
51
+ }
52
+
53
+ try:
54
+ os.chdir(str(target))
55
+ return {
56
+ "success": True,
57
+ "message": f"[SUCCESS] Berhasil pindah ke direktori: {target}",
58
+ "new_cwd": str(target)
59
+ }
60
+ except Exception as e:
61
+ return {
62
+ "success": False,
63
+ "message": f"[ERROR] Gagal berpindah direktori: {str(e)}",
64
+ "new_cwd": str(Path(current_cwd).resolve())
65
+ }
66
+
67
+
68
+ def save_plan_document(content: str, current_cwd: str = ".") -> str:
69
+ """
70
+ Saves the Technical Implementation Plan / PRD into plan.md in the current workspace.
71
+ Sanitizes content to ensure clean UTF-8 formatting.
72
+ """
73
+ plan_path = Path(current_cwd).resolve() / "plan.md"
74
+ try:
75
+ cleaned_content = clean_text_for_terminal(content)
76
+ with open(plan_path, "w", encoding="utf-8") as f:
77
+ f.write(cleaned_content)
78
+ return f"[SUCCESS] Dokumen PRD dan rencana implementasi berhasil disimpan ke: {plan_path}"
79
+ except Exception as e:
80
+ return f"[ERROR] Gagal menyimpan plan.md: {str(e)}"
81
+
82
+
83
+ def run_terminal_command(command: str, timeout: int = 60, cwd: str = ".") -> str:
84
+ work_dir = Path(cwd).resolve()
85
+ if not work_dir.exists():
86
+ return f"[ERROR] Working directory does not exist: {work_dir}"
87
+
88
+ is_windows = platform.system() == "Windows"
89
+
90
+ try:
91
+ if is_windows:
92
+ process = subprocess.run(
93
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", command],
94
+ cwd=str(work_dir),
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=timeout,
98
+ encoding="utf-8",
99
+ errors="replace"
100
+ )
101
+ else:
102
+ process = subprocess.run(
103
+ command,
104
+ shell=True,
105
+ cwd=str(work_dir),
106
+ capture_output=True,
107
+ text=True,
108
+ timeout=timeout,
109
+ encoding="utf-8",
110
+ errors="replace"
111
+ )
112
+
113
+ stdout = process.stdout.strip()
114
+ stderr = process.stderr.strip()
115
+ exit_code = process.returncode
116
+
117
+ output_parts = []
118
+ if stdout:
119
+ output_parts.append(f"STDOUT:\n{stdout}")
120
+ if stderr:
121
+ output_parts.append(f"STDERR:\n{stderr}")
122
+ if not stdout and not stderr:
123
+ output_parts.append("(Perintah selesai tanpa output)")
124
+
125
+ output_parts.append(f"\n[Process exited with code {exit_code}]")
126
+ return "\n".join(output_parts)
127
+
128
+ except subprocess.TimeoutExpired:
129
+ return f"[ERROR] Perintah melebihi batas waktu ({timeout} detik)."
130
+ except Exception as e:
131
+ return f"[ERROR] Gagal mengeksekusi perintah: {str(e)}"
132
+
133
+
134
+ def git_status(cwd: str = ".") -> str:
135
+ return run_terminal_command("git status --short", timeout=15, cwd=cwd)
136
+
137
+
138
+ def git_diff(cwd: str = ".", staged: bool = False) -> str:
139
+ cmd = "git diff --cached" if staged else "git diff"
140
+ return run_terminal_command(cmd, timeout=20, cwd=cwd)
yncli/tui.py ADDED
@@ -0,0 +1,536 @@
1
+ import os
2
+ import sys
3
+ import datetime
4
+ from pathlib import Path
5
+ from typing import List, Dict, Any, Optional
6
+
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.markdown import Markdown
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+ from rich.syntax import Syntax
13
+ from rich.status import Status
14
+ from rich.columns import Columns
15
+
16
+ from yncli.agent import Agent
17
+ from yncli.config import save_config, CONFIG_DIR
18
+ from yncli.clean_text import clean_text_for_terminal
19
+
20
+
21
+ class TerminalUI:
22
+ def __init__(self, agent: Agent):
23
+ self.agent = agent
24
+ self.console = Console(force_terminal=True, soft_wrap=True)
25
+ self.session = None
26
+ self._active_spinner: Optional[Status] = None
27
+
28
+ def _stop_spinner(self) -> None:
29
+ if self._active_spinner:
30
+ try:
31
+ self._active_spinner.stop()
32
+ except Exception:
33
+ pass
34
+ self._active_spinner = None
35
+
36
+ def _start_spinner(self, text: str) -> None:
37
+ self._stop_spinner()
38
+ try:
39
+ self._active_spinner = self.console.status(text, spinner="dots")
40
+ self._active_spinner.start()
41
+ except Exception:
42
+ pass
43
+
44
+ def _get_prompt_session(self):
45
+ if self.session is None:
46
+ try:
47
+ from prompt_toolkit import PromptSession
48
+ from prompt_toolkit.history import FileHistory
49
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
50
+ from prompt_toolkit.styles import Style
51
+
52
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
53
+ history_file = CONFIG_DIR / "history.txt"
54
+
55
+ prompt_style = Style.from_dict({
56
+ "prompt": "#38bdf8",
57
+ "arrow": "#a855f7",
58
+ })
59
+
60
+ self.session = PromptSession(
61
+ history=FileHistory(str(history_file)),
62
+ auto_suggest=AutoSuggestFromHistory(),
63
+ style=prompt_style
64
+ )
65
+ except Exception:
66
+ self.session = False
67
+ return self.session
68
+
69
+ def print_banner(self) -> None:
70
+ cwd = Path(self.agent.workspace_dir).resolve()
71
+ folder_name = cwd.name or str(cwd)
72
+ cwd_str = str(cwd).replace(str(Path.home()), "~")
73
+ mode_title = self.agent.mode.capitalize()
74
+ mode_color = "#38bdf8" if self.agent.mode == "plan" else ("#10b981" if self.agent.mode == "build" else "#f59e0b")
75
+
76
+ # Top pill badge
77
+ self.console.print()
78
+ pill = Text()
79
+ pill.append(f" {folder_name} ", style="bold black on #f59e0b")
80
+ pill.append(" yncli", style="dim white")
81
+ self.console.print(pill)
82
+ self.console.print()
83
+
84
+ # Rainbow pixel letter 'Y' logo
85
+ logo = Text()
86
+ logo.append("██ ██\n", style="bold #ef4444")
87
+ logo.append(" ██ ██ \n", style="bold #f97316")
88
+ logo.append(" ██ ██ \n", style="bold #eab308")
89
+ logo.append(" ██ ██ \n", style="bold #22c55e")
90
+ logo.append(" ███ \n", style="bold #06b6d4")
91
+ logo.append(" █ \n", style="bold #3b82f6")
92
+ logo.append(" █ ", style="bold #a855f7")
93
+
94
+ # Info metadata
95
+ info = Text()
96
+ info.append("yncli 1.0.0\n", style="bold #60a5fa")
97
+ info.append("Autonomous Polyglot AI Coding Agent\n", style="dim white")
98
+ info.append(f"{self.agent.model} ", style="white")
99
+ info.append(f"({mode_title} Mode)\n", style=mode_color)
100
+ info.append(f"{cwd_str}\n", style="dim")
101
+
102
+ cols = Columns([logo, info], padding=(0, 3))
103
+ self.console.print(cols)
104
+ self.console.print("─" * 60, style="dim #334155")
105
+
106
+ loaded_count = len(self.agent.workspace_memory.indexed_files)
107
+ total_files = len(self.agent.workspace_memory.file_tree)
108
+ self.console.print(f"[dim]Project: [cyan]{self.agent.workspace_memory.project_type}[/cyan] ({loaded_count}/{total_files} files loaded into active memory)[/dim]\n")
109
+
110
+ def print_help(self) -> None:
111
+ table = Table(title="Panduan Perintah", border_style="dim cyan", show_header=True)
112
+ table.add_column("Perintah", style="yellow")
113
+ table.add_column("Keterangan", style="white")
114
+
115
+ table.add_row("/model, /models", "Buka popup interaktif untuk memilih model AI (klik mouse / panah)")
116
+ table.add_row("/model <nama>", "Ganti model secara langsung")
117
+ table.add_row("/plan", "Masuk ke Plan Mode (Riset & susun PRD plan.md otomatis)")
118
+ table.add_row("/build", "Masuk ke Build Mode (Eksekusi autonomous pembuatan & editing kode)")
119
+ table.add_row("/ask", "Masuk ke Ask Mode (Konsultasi murni tanpa modifikasi file)")
120
+ table.add_row("/cd <folder>", "Pindah direktori kerja aktif")
121
+ table.add_row("/pwd", "Lihat path direktori saat ini")
122
+ table.add_row("/approval [on|off]", "Atur persetujuan tool (on = otomatis, off = tanya dulu)")
123
+ table.add_row("/thinking [on|off]", "Tampilkan atau sembunyikan live reasoning")
124
+ table.add_row("/skill <nama>", "Pilih skill (ultrabrain, system_architect, debug_oracle, dll)")
125
+ table.add_row("/clear", "Bersihkan memori percakapan sesi ini")
126
+ table.add_row("/save [file]", "Simpan percakapan ke file Markdown")
127
+ table.add_row("/exit, /quit", "Keluar dari yncli")
128
+
129
+ self.console.print(table)
130
+
131
+ def open_model_popup_selector(self) -> None:
132
+ self._start_spinner("[dim cyan]Mengambil daftar model dari server...[/dim cyan]")
133
+ try:
134
+ models = self.agent.client.list_models()
135
+ finally:
136
+ self._stop_spinner()
137
+
138
+ if not models:
139
+ self.console.print("[red]Gagal mengambil daftar model dari server.[/red]")
140
+ return
141
+
142
+ values = []
143
+ for m in models:
144
+ mid = m.get("id", "")
145
+ caps = []
146
+ mcap = m.get("capabilities", {})
147
+ if mcap.get("reasoning"):
148
+ caps.append("Thinking")
149
+ if mcap.get("tools"):
150
+ caps.append("Tools")
151
+ cap_str = f" [{', '.join(caps)}]" if caps else ""
152
+ label = f"{mid}{cap_str}"
153
+ values.append((mid, label))
154
+
155
+ try:
156
+ from prompt_toolkit.shortcuts import radiolist_dialog
157
+ from prompt_toolkit.styles import Style
158
+
159
+ dialog_style = Style.from_dict({
160
+ "dialog": "bg:#1e1e2e",
161
+ "dialog.body": "bg:#181825 #cdd6f4",
162
+ "dialog.title": "bg:#313244 #89b4fa bold",
163
+ "dialog.border": "#89b4fa",
164
+ "button.focused": "bg:#a6e3a1 #11111b bold",
165
+ "radio-selected": "bold #a6e3a1",
166
+ })
167
+
168
+ result = radiolist_dialog(
169
+ title="Pilih Model AI (Gunakan Panah / Klik Mouse)",
170
+ text=f"Model aktif: {self.agent.model}\nPilih model di bawah lalu klik OK atau tekan Enter:",
171
+ values=values,
172
+ default=self.agent.model,
173
+ style=dialog_style
174
+ ).run()
175
+
176
+ if result:
177
+ self.agent.set_model(result)
178
+ save_config({"model": result})
179
+ self.console.print(f"[green]Model berhasil diubah ke: [cyan]{result}[/cyan][/green]")
180
+ self.console.print(f"[dim]Memory refreshed: {len(self.agent.workspace_memory.indexed_files)} files active in memory[/dim]")
181
+ else:
182
+ self.console.print("[dim]Pemilihan model dibatalkan.[/dim]")
183
+
184
+ except Exception:
185
+ self.console.print("[cyan]Daftar Model Tersedia:[/cyan]")
186
+ for idx, (mid, label) in enumerate(values, start=1):
187
+ marker = "(*)" if mid == self.agent.model else "( )"
188
+ self.console.print(f" {idx:2d}. {marker} [cyan]{mid}[/cyan]")
189
+ self.console.print("\nMasukkan nomor model atau ID model: ", end="")
190
+ try:
191
+ choice = input().strip()
192
+ if choice.isdigit() and 1 <= int(choice) <= len(values):
193
+ sel = values[int(choice) - 1][0]
194
+ self.agent.set_model(sel)
195
+ save_config({"model": sel})
196
+ self.console.print(f"[green]Model diubah ke: [cyan]{sel}[/cyan][/green]")
197
+ elif choice in [v[0] for v in values]:
198
+ self.agent.set_model(choice)
199
+ save_config({"model": choice})
200
+ self.console.print(f"[green]Model diubah ke: [cyan]{choice}[/cyan][/green]")
201
+ except Exception:
202
+ pass
203
+
204
+ def handle_slash_command(self, cmd_line: str) -> bool:
205
+ parts = cmd_line.strip().split()
206
+ if not parts:
207
+ return True
208
+ cmd = parts[0].lower()
209
+ args = parts[1:]
210
+
211
+ if cmd in ("/exit", "/quit", "exit", "quit"):
212
+ self.console.print("\n[yellow]Sesi selesai. Sampai jumpa![/yellow]\n")
213
+ sys.exit(0)
214
+
215
+ elif cmd == "/help":
216
+ self.print_help()
217
+ return True
218
+
219
+ elif cmd in ("/model", "/models"):
220
+ if not args:
221
+ self.open_model_popup_selector()
222
+ return True
223
+ new_model = args[0].strip()
224
+ self.agent.set_model(new_model)
225
+ save_config({"model": new_model})
226
+ self.console.print(f"[green]Model berhasil diganti ke: [cyan]{new_model}[/cyan][/green]")
227
+ return True
228
+
229
+ elif cmd == "/plan":
230
+ self.agent.set_mode("plan")
231
+ save_config({"mode": "plan"})
232
+ self.console.print("[green]Mode aktif: [cyan]Plan Mode[/cyan] (Riset arsitektur & susun PRD plan.md).[/green]")
233
+ return True
234
+
235
+ elif cmd == "/build":
236
+ self.agent.set_mode("build")
237
+ save_config({"mode": "build"})
238
+ self.console.print("[green]Mode aktif: [cyan]Build Mode[/cyan] (Eksekusi autonomous pembuatan & editing kode).[/green]")
239
+ return True
240
+
241
+ elif cmd == "/ask":
242
+ self.agent.set_mode("ask")
243
+ save_config({"mode": "ask"})
244
+ self.console.print("[green]Mode aktif: [cyan]Ask Mode[/cyan] (Konsultasi murni tanpa modifikasi file).[/green]")
245
+ return True
246
+
247
+ elif cmd in ("/mode", "/modes"):
248
+ if not args:
249
+ self.console.print(f"Mode saat ini: [cyan]{self.agent.mode.capitalize()} Mode[/cyan]")
250
+ self.console.print("Pilihan mode: [yellow]/plan[/yellow], [yellow]/build[/yellow], [yellow]/ask[/yellow]")
251
+ return True
252
+ m = args[0].lower()
253
+ if m in ("plan", "build", "ask"):
254
+ self.agent.set_mode(m)
255
+ save_config({"mode": m})
256
+ self.console.print(f"[green]Mode diubah ke: [cyan]{m.capitalize()} Mode[/cyan][/green]")
257
+ else:
258
+ self.console.print("[red]Mode tidak valid. Pilihan: plan, build, ask[/red]")
259
+ return True
260
+
261
+ elif cmd == "/cd":
262
+ if not args:
263
+ self.console.print(f"Direktori saat ini: [cyan]{Path(self.agent.workspace_dir).resolve()}[/cyan]")
264
+ self.console.print("Gunakan: [yellow]/cd <path_direktori>[/yellow]")
265
+ return True
266
+ target_path = " ".join(args).strip()
267
+ from yncli.tools.system_tools import change_directory
268
+ res = change_directory(target_path, current_cwd=self.agent.workspace_dir)
269
+ if res.get("success"):
270
+ self.agent.set_workspace_dir(res["new_cwd"])
271
+ self.console.print(f"[green]Direktori dipindahkan ke: [cyan]{res['new_cwd']}[/cyan][/green]")
272
+ self.console.print(f"[dim]Workspace re-indexed: {len(self.agent.workspace_memory.indexed_files)} files loaded[/dim]")
273
+ else:
274
+ self.console.print(f"[red]Gagal pindah direktori: {res.get('message')}[/red]")
275
+ return True
276
+
277
+ elif cmd == "/pwd":
278
+ self.console.print(f"Direktori kerja: [cyan]{Path(self.agent.workspace_dir).resolve()}[/cyan]")
279
+ return True
280
+
281
+ elif cmd == "/thinking":
282
+ if not args:
283
+ cur = "Aktif" if self.agent.show_thinking else "Mati"
284
+ self.console.print(f"Status Thinking: [cyan]{cur}[/cyan] (Gunakan: /thinking on | /thinking off)")
285
+ return True
286
+ val = args[0].lower() in ("on", "true", "1", "yes", "enable")
287
+ self.agent.set_show_thinking(val)
288
+ save_config({"show_thinking": val})
289
+ status = "Diaktifkan" if val else "Dimatikan"
290
+ self.console.print(f"[green]Thinking {status}.[/green]")
291
+ return True
292
+
293
+ elif cmd in ("/approval", "/auto"):
294
+ if not args:
295
+ cur = "Otomatis" if self.agent.auto_approve else "Tanya dulu"
296
+ self.console.print(f"Persetujuan tool: [cyan]{cur}[/cyan] (Gunakan: /approval on | /approval off)")
297
+ return True
298
+ val = args[0].lower() in ("on", "auto", "true", "1", "yes", "otomatis")
299
+ self.agent.set_auto_approve(val)
300
+ save_config({"auto_approve": val})
301
+ status = "Otomatis (Langsung eksekusi)" if val else "Tanya dulu (Konfirmasi manual)"
302
+ self.console.print(f"[green]Persetujuan tool diubah ke: [cyan]{status}[/cyan][/green]")
303
+ return True
304
+
305
+ elif cmd == "/clear":
306
+ self.agent.reset_history()
307
+ self.agent.workspace_memory.refresh()
308
+ self.console.print("[green]Riwayat percakapan telah dibersihkan & memori workspace diperbarui.[/green]")
309
+ return True
310
+
311
+ elif cmd == "/skills":
312
+ skills = self.agent.skills_mgr.list_skills()
313
+ current = self.agent.skills_mgr.active_skill
314
+ self.console.print(f"Skill aktif: [green]{current}[/green]\nDaftar skill yang tersedia:")
315
+ for s in skills:
316
+ marker = "* " if s == current else " "
317
+ self.console.print(f"{marker}[cyan]{s}[/cyan]")
318
+ return True
319
+
320
+ elif cmd == "/skill":
321
+ if not args:
322
+ self.console.print("Gunakan: [yellow]/skill <nama_skill>[/yellow] (contoh: /skill ultrabrain)")
323
+ return True
324
+ skill_name = args[0].lower().strip()
325
+ if self.agent.skills_mgr.set_active_skill(skill_name):
326
+ self.console.print(f"[green]Skill aktif: [cyan]{skill_name}[/cyan][/green]")
327
+ else:
328
+ self.console.print(f"[red]Skill '{skill_name}' tidak ditemukan.[/red]")
329
+ return True
330
+
331
+ elif cmd == "/tools":
332
+ from yncli.tools import AGENT_TOOLS
333
+ table = Table(title="Daftar Alat (Tools)", border_style="dim magenta")
334
+ table.add_column("Nama", style="yellow")
335
+ table.add_column("Fungsi", style="white")
336
+ for t in AGENT_TOOLS:
337
+ fn = t.get("function", {})
338
+ table.add_row(fn.get("name", ""), fn.get("description", ""))
339
+ self.console.print(table)
340
+ return True
341
+
342
+ elif cmd == "/save":
343
+ fname = args[0] if args else f"chat_export_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
344
+ lines = [f"# yncli export - {datetime.datetime.now().isoformat()}\n\n"]
345
+ for msg in self.agent.history:
346
+ role = msg.get("role", "").capitalize()
347
+ content = msg.get("content") or ""
348
+ lines.append(f"### {role}\n\n{content}\n\n---\n")
349
+ with open(fname, "w", encoding="utf-8") as f:
350
+ f.writelines(lines)
351
+ self.console.print(f"[green]Percakapan disimpan ke: [cyan]{fname}[/cyan][/green]")
352
+ return True
353
+
354
+ else:
355
+ self.console.print(f"[red]Perintah tidak dikenal:[/red] '{cmd}'. Ketik [yellow]/help[/yellow] untuk bantuan.")
356
+ return True
357
+
358
+ def run_interactive_loop(self) -> None:
359
+ self.print_banner()
360
+ ps = self._get_prompt_session()
361
+
362
+ while True:
363
+ try:
364
+ self.console.print()
365
+ prompt_label = f"{self.agent.mode} > "
366
+ if ps:
367
+ user_input = ps.prompt([
368
+ ("class:prompt", prompt_label),
369
+ ]).strip()
370
+ else:
371
+ self.console.print(f"[cyan]{prompt_label}[/cyan]", end="")
372
+ user_input = input().strip()
373
+
374
+ if not user_input:
375
+ continue
376
+
377
+ if user_input.startswith("/"):
378
+ self.handle_slash_command(user_input)
379
+ continue
380
+
381
+ self.execute_turn(user_input)
382
+
383
+ except (KeyboardInterrupt, EOFError):
384
+ self.console.print("\n[dim](Sesi dihentikan. Ketik /exit untuk keluar)[/dim]")
385
+ except Exception as e:
386
+ self.console.print(f"\n[red]Error: {str(e)}[/red]")
387
+
388
+ def ask_tool_confirmation(self, name: str, args: Dict[str, Any]) -> bool:
389
+ self._stop_spinner()
390
+ self.console.print()
391
+
392
+ target_path = args.get("file_path", args.get("path", args.get("command", "")))
393
+ content = args.get("content", args.get("replacement_content", ""))
394
+
395
+ if name in ("write_file", "edit_file_replace", "save_plan_document") and content:
396
+ lines = content.strip().split("\n")
397
+ line_count = len(lines)
398
+ self.console.print(f"[bold white]{target_path}[/bold white] [dim #38bdf8]+{line_count}[/dim #38bdf8]")
399
+
400
+ preview_lines = lines[:12]
401
+ for idx, line in enumerate(preview_lines, start=1):
402
+ clean_l = clean_text_for_terminal(line[:80])
403
+ self.console.print(f" [dim]{idx:2d}[/dim] [green]+[/green] [dim cyan]{clean_l}[/dim cyan]")
404
+
405
+ if line_count > 12:
406
+ self.console.print(f" [dim]... {line_count - 12} baris lainnya[/dim]")
407
+ self.console.print("Reason: File modification in workspace\n", style="dim")
408
+ prompt_title = f"Allow execution of {name} on this file?"
409
+ else:
410
+ args_summary = ", ".join(f"{k}={repr(v)[:50]}" for k, v in args.items())
411
+ self.console.print(f"[bold white]{name}[/bold white]({args_summary})")
412
+ self.console.print("Reason: Terminal / Tool execution\n", style="dim")
413
+ prompt_title = f"Allow execution of {name}?"
414
+
415
+ self.console.print(f"[bold white]{prompt_title}[/bold white]")
416
+ self.console.print(" [bold #38bdf8]> 1. Yes, allow execution[/bold #38bdf8]")
417
+ self.console.print(" 2. Yes, and always allow (enable auto-approve)")
418
+ self.console.print(" 3. No, deny execution")
419
+ self.console.print("\nPilih (1/2/3 atau y/a/n): ", end="")
420
+
421
+ try:
422
+ choice = input().strip().lower()
423
+ if choice in ("2", "a", "all", "always"):
424
+ self.agent.set_auto_approve(True)
425
+ save_config({"auto_approve": True})
426
+ self.console.print("[green]Auto-approve diaktifkan untuk aksi berikutnya.[/green]\n")
427
+ return True
428
+ elif choice in ("1", "y", "ya", "yes", ""):
429
+ self.console.print()
430
+ return True
431
+ else:
432
+ self.console.print("[dim red]Aksi dibatalkan.[/dim red]\n")
433
+ return False
434
+ except (KeyboardInterrupt, EOFError):
435
+ self.console.print("\n[dim red]Aksi dibatalkan.[/dim red]\n")
436
+ return False
437
+
438
+ def execute_turn(self, user_prompt: str) -> None:
439
+ content_chunks = []
440
+ is_thinking = False
441
+
442
+ def on_status_update(status_text: str) -> None:
443
+ if not is_thinking:
444
+ self._start_spinner(f"[dim cyan]{status_text}[/dim cyan]")
445
+
446
+ def on_thinking(chunk: str) -> None:
447
+ nonlocal is_thinking
448
+ self._stop_spinner()
449
+ clean_chunk = clean_text_for_terminal(chunk)
450
+ if not is_thinking:
451
+ self.console.print("[dim cyan]Thinking:[/dim cyan]\n", style="dim")
452
+ is_thinking = True
453
+ sys.stdout.write(clean_chunk)
454
+ sys.stdout.flush()
455
+
456
+ def on_content(chunk: str) -> None:
457
+ nonlocal is_thinking
458
+ clean_chunk = clean_text_for_terminal(chunk)
459
+ if is_thinking:
460
+ self.console.print("\n")
461
+ is_thinking = False
462
+ content_chunks.append(clean_chunk)
463
+
464
+ def on_tool_start(name: str, args: Dict[str, Any]) -> None:
465
+ nonlocal is_thinking
466
+ if is_thinking:
467
+ self.console.print("\n")
468
+ is_thinking = False
469
+
470
+ target = args.get("file_path", args.get("path", args.get("command", args.get("query", ""))))
471
+ if len(str(target)) > 45:
472
+ target = str(target)[:45] + "..."
473
+
474
+ tool_display_map = {
475
+ "list_directory": "ListDir",
476
+ "read_file": "Read",
477
+ "write_file": "Write",
478
+ "edit_file_replace": "Edit",
479
+ "save_plan_document": "SavePlan",
480
+ "run_terminal_command": "Shell",
481
+ "web_search": "WebSearch",
482
+ "fetch_webpage": "Fetch",
483
+ "change_directory": "Cd"
484
+ }
485
+ display_name = tool_display_map.get(name, name)
486
+
487
+ # In-place dynamic single line
488
+ self._start_spinner(f" [bold #3b82f6]●[/bold #3b82f6] [bold #f59e0b]{display_name}[/bold #f59e0b][dim]({target})[/dim] Working...")
489
+
490
+ def on_tool_end(name: str, output: str) -> None:
491
+ pass
492
+
493
+ self._start_spinner(f"Working ({self.agent.model})...")
494
+ try:
495
+ res = self.agent.run_turn(
496
+ user_prompt=user_prompt,
497
+ on_thinking=on_thinking,
498
+ on_content=on_content,
499
+ on_tool_start=on_tool_start,
500
+ on_tool_end=on_tool_end,
501
+ on_tool_confirm=self.ask_tool_confirmation,
502
+ on_status_update=on_status_update
503
+ )
504
+ finally:
505
+ self._stop_spinner()
506
+
507
+ # Render full Markdown with Rich (renders **bold**, headers, syntax highlighting)
508
+ final_text = clean_text_for_terminal("".join(content_chunks) or res.get("content", ""))
509
+ if final_text:
510
+ self.console.print()
511
+ try:
512
+ self.console.print(Markdown(final_text))
513
+ except Exception:
514
+ self.console.print(final_text)
515
+
516
+ # Plan Mode transition workflow: Auto prompt to Build
517
+ if self.agent.mode == "plan" and final_text:
518
+ plan_file = Path(self.agent.workspace_dir).resolve() / "plan.md"
519
+ if plan_file.exists():
520
+ self.console.print(f"\n[green]• Dokumen PRD rencana implementasi telah dibuat di: [bold cyan]{plan_file}[/bold cyan][/green]")
521
+
522
+ self.console.print("\n[bold white]Mau langsung dieksekusi / build sekarang?[/bold white]")
523
+ self.console.print(" [bold #10b981]> 1. Ya, beralih ke Build Mode dan mulai bangun kode[/bold #10b981]")
524
+ self.console.print(" 2. Tidak, tetap di Plan Mode")
525
+ self.console.print("\nPilih (1/2 atau y/n): ", end="")
526
+ try:
527
+ answer = input().strip().lower()
528
+ if answer in ("1", "y", "ya", "yes", ""):
529
+ self.agent.set_mode("build")
530
+ save_config({"mode": "build"})
531
+ self.console.print("\n[green]Beralih ke Build Mode. Memulai eksekusi implementasi rencana...[/green]\n")
532
+ self.execute_turn("Rencana di plan.md telah disetujui. Buka plan.md dan mulai eksekusi pembuatan/pembaruan kode secara tuntas.")
533
+ else:
534
+ self.console.print("[dim]Tetap berada dalam Plan Mode.[/dim]")
535
+ except (KeyboardInterrupt, EOFError):
536
+ pass