diff --git a/tui/agent.py b/tui/agent.py index 22fa9be..3e4c606 100644 --- a/tui/agent.py +++ b/tui/agent.py @@ -1,15 +1,10 @@ -# agent.py — Agent loop dan tool execution. -# submit() adalah entry point: membaca input buffer, mengirim ke LLM, -# memproses tool calls, dan menampilkan hasil di log. - import json +import threading from datetime import datetime -from .render import draw from scripts import ntro def log(app, role, text): - # Simpan item ke app.log untuk di-render oleh render.py app.log.append({ "role": role, "text": text, @@ -18,42 +13,41 @@ def log(app, role, text): def submit(app, stdscr): - # Kirim query dari input buffer ke LLM. - # Loop sampai LLM mengembalikan final answer (tanpa tool_calls) - # atau mencapai max_iterations. - query = "\n".join(app.input_buffer).strip() if not query: return log(app, "user", query) - # Reset input buffer app.input_buffer = [""] app.input_line = 0 app.input_col = 0 - app.scroll = 999999 # scroll ke paling bawah + app.scroll = 999999 app.processing = True - draw(app, stdscr) - stdscr.refresh() app.messages.append({"role": "user", "content": query}) + app.agent_done.clear() + app.agent_thread = threading.Thread( + target=_agent_loop, + args=(app,), + daemon=True, + ) + app.agent_thread.start() + + +def _agent_loop(app): stamp = ntro.start() for step in range(app.agent_max_iterations): stamp_step = ntro.start() log(app, "system", f" step {step + 1} \u2014 Thinking...") app.scroll = 999999 - draw(app, stdscr) - stdscr.refresh() response = app.llm.chat(app.messages, tools=app.TOOLS) - # Hapus "step N — LLM..." log, ganti dengan hasil aktual app.log.pop() if response.tool_calls: - # LLM meminta menjalankan tool(s) amsg = { "role": "assistant", "content": response.content, @@ -63,17 +57,12 @@ def submit(app, stdscr): if response.content and response.content.strip(): log(app, "ai", response.content) app.scroll = 999999 - draw(app, stdscr) - stdscr.refresh() for tc in response.tool_calls: tname = tc["function"]["name"] log(app, "system", f" \u2192 {tname}") app.scroll = 999999 - draw(app, stdscr) - stdscr.refresh() execute_tool(app, tc) else: - # Final answer — tidak ada tool_calls if response.content: app.messages.append({ "role": "assistant", @@ -81,25 +70,19 @@ def submit(app, stdscr): }) log(app, "ai", response.content) log(app, "sep", "") - app.processing = False - app.scroll = 999999 - draw(app, stdscr) - stdscr.refresh() ntro.end(stamp) + app.agent_done.set() return ntro.end(stamp_step) - # Timeout — max iterations tercapai tanpa final answer log(app, "error", "Max iterations reached without final answer.") app.messages.append({"role": "assistant", "content": "Max iterations reached without final answer."}) - app.processing = False ntro.end(stamp) + app.agent_done.set() def execute_tool(app, tool_call): - # Dispatch tool_call ke handler yang terdaftar di TOOL_HANDLERS. - # search_code dan git_operation butuh penanganan argumen khusus. tname = tool_call["function"]["name"] targs = json.loads(tool_call["function"]["arguments"]) handler = app.TOOL_HANDLERS.get(tname) @@ -121,7 +104,6 @@ def execute_tool(app, tool_call): except Exception as e: result = f"Error executing tool: {str(e)}" - # Hasil tool disimpan ke messages agar bisa dikirim balik ke LLM app.messages.append({ "role": "tool", "tool_call_id": tool_call["id"], diff --git a/tui/app.py b/tui/app.py index 2d7d5b2..4e69ca7 100644 --- a/tui/app.py +++ b/tui/app.py @@ -1,15 +1,12 @@ import curses +import threading from .render import init_colors, draw from .input import handle_key class HendrikTUI: - # State holder & orchestrator. - # Semua data UI disimpan di sini, method lain (render, input, agent) - # menerima `app` (self) sebagai parameter pertama. def __init__(self, llm_client, tools_definition, TOOLS, TOOL_HANDLERS, build_system_prompt, agent_max_iterations): - # -- dependencies (disuntik dari luar) -- self.llm = llm_client self.tools_def = tools_definition self.TOOLS = TOOLS @@ -17,39 +14,36 @@ class HendrikTUI: self.build_system_prompt = build_system_prompt self.agent_max_iterations = agent_max_iterations - # -- UI state -- - self.messages = None # chat history yg dikirim ke LLM API - self.log = [] # rendered log (display-only, ada timestamp) - self.input_buffer = [""] # baris-baris input multi-line - self.input_line = 0 # index baris aktif di buffer - self.input_col = 0 # kolom kursor di baris aktif - self.scroll = 0 # scroll offset chat area - self.processing = False # true saat agent sedang memproses - self.running = True # false → keluar dari main loop - self.h, self.w = 0, 0 # ukuran terminal (height, width) + self.messages = None + self.log = [] + self.input_buffer = [""] + self.input_line = 0 + self.input_col = 0 + self.scroll = 0 + self.processing = False + self.running = True + self.h, self.w = 0, 0 + + self.agent_thread: threading.Thread | None = None + self.agent_done = threading.Event() def run(self): - # Masuk ke curses wrapper. wrapper() setup/teardown terminal - # dan menangani restore terminal meskipun terjadi error. try: curses.wrapper(self._main) except KeyboardInterrupt: pass def _main(self, stdscr): - # Main loop: draw → getch → handle curses.use_default_colors() init_colors() - stdscr.keypad(True) # enable key codes (KEY_UP, KEY_DOWN, dll) + stdscr.keypad(True) stdscr.refresh() - # Init system prompt — sekali di awal self.messages = [{"role": "system", "content": self.build_system_prompt(self.tools_def)}] while self.running: self.h, self.w = stdscr.getmaxyx() - # Minimal ukuran terminal biar UI gak rusak if self.h < 14 or self.w < 40: stdscr.erase() stdscr.addstr(0, 0, "Terminal too small (min 40x14)") @@ -58,10 +52,22 @@ class HendrikTUI: continue draw(self, stdscr) - # Tampilkan kursor (high visibility) agar user bisa ngetik walau processing curses.curs_set(2) + + if self.processing: + stdscr.timeout(100) + else: + stdscr.timeout(-1) + try: key = stdscr.getch() except KeyboardInterrupt: break + handle_key(self, stdscr, key) + + if self.agent_done.is_set(): + self.agent_thread.join() + self.agent_done.clear() + self.processing = False + self.agent_thread = None diff --git a/tui/render.py b/tui/render.py index b073343..1f93836 100644 --- a/tui/render.py +++ b/tui/render.py @@ -230,8 +230,8 @@ def draw_input(app, stdscr): except curses.error: pass - # Cursor position — only on the visual chunk that contains the cursor - if idx == cur_visual and not app.processing: + # Cursor position — always on the visual chunk that contains the cursor + if idx == cur_visual: col_on_visual = app.input_col - start cursor_yx = (y, 4 + min(col_on_visual, len(chunk)))