From de89ad270658113b95d541e52f6315cbcb0c598e Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 10:35:14 +0700 Subject: [PATCH] Refactor: restructure project into TUI modules --- config.py | 12 +- hendrik.py | 219 ++----------- scripts/ansicolor.py | 3 - llm_client.py => scripts/llm_client.py | 6 +- scripts/separator.py | 5 - scripts/tui.py | 431 ------------------------- tui/__init__.py | 2 + tui/app.py | 69 ++++ tui/input.py | 126 ++++++++ tui/render.py | 214 ++++++++++++ 10 files changed, 447 insertions(+), 640 deletions(-) delete mode 100644 scripts/ansicolor.py rename llm_client.py => scripts/llm_client.py (85%) delete mode 100644 scripts/separator.py delete mode 100644 scripts/tui.py create mode 100644 tui/__init__.py create mode 100644 tui/app.py create mode 100644 tui/input.py create mode 100644 tui/render.py diff --git a/config.py b/config.py index 287ffec..bc8dd3f 100644 --- a/config.py +++ b/config.py @@ -4,11 +4,11 @@ from dotenv import load_dotenv load_dotenv() # LLM Configuration -LLM_BASE_URL = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1") -LLM_MODEL = os.getenv("LLM_MODEL", default="granite4.1:8b") -LLM_API_KEY = os.getenv("LLM_API_KEY", default="ollama") -LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", default="600")) +llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" ) +llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" ) +llm_api_key = os.getenv("LLM_API_KEY", default="ollama" ) +llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) ) # Agent Configuration -AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default="10")) +AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="10" ) ) # Tool Configuration (for future use) -MAX_TOOL_OUTPUT = int(os.getenv("MAX_TOOL_OUTPUT", default="4000")) +MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="4000" ) ) diff --git a/hendrik.py b/hendrik.py index 47a49b9..eec8f26 100644 --- a/hendrik.py +++ b/hendrik.py @@ -1,161 +1,39 @@ -import os, sys, json, tty, termios +import os, sys import config -from llm_client import LLMClient + +from scripts.llm_client import LLMClient from tools import coder from scripts import gadget -from scripts.ansicolor import TEXT_COLOR_YELLOW, TEXT_COLOR_GREEN, TEXT_COLOR_RESET -from scripts.separator import separator +from tui import HendrikTUI +# Daftar tools yang tersedia tools_definition = [ - gadget.tools_mapping( coder.schema_read_file, coder.read_file ), - gadget.tools_mapping( coder.schema_write_file, coder.write_file ), - gadget.tools_mapping( coder.schema_edit_file, coder.edit_file ), - gadget.tools_mapping( coder.schema_run_bash, coder.run_bash ), - gadget.tools_mapping( coder.schema_search_code, coder.search_code ), - gadget.tools_mapping( coder.schema_git_operation, coder.git_operation ), + gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ), + gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ), + gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ), + gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ), + gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ), + gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ), ] -TOOLS = gadget.tool_schemas(tools_definition) -TOOL_HANDLERS = gadget.tool_handlers(tools_definition) - - -def interactive_input(header_mode='full'): - fd = sys.stdin.fileno() - old = termios.tcgetattr(fd) - - print() - if header_mode == 'full': - print(separator()) - print("Hendrik AI Agent - Interactive Mode") - print(separator()) - print(f"Workspace: {os.getcwd()}") - print(separator()) - print("[Ctrl+W] Change workspace | :workspace | [Ctrl+D] Submit") - print(separator()) - elif header_mode == 'workspace': - print(separator()) - print(f"Workspace: {os.getcwd()}") - print(separator()) - else: - print("[Ctrl+W] Change workspace | :workspace | [Ctrl+D] Submit") - print(separator()) - - buffer = bytearray() - try: - tty.setraw(fd) - while True: - ch = os.read(fd, 1) - if ch == b'\x03': # Ctrl+C → exit - termios.tcsetattr(fd, termios.TCSADRAIN, old) - print("\r\nExiting.") - sys.exit(0) - elif ch == b'\x04': # Ctrl+D → submit - break - elif ch == b'\x17': # Ctrl+W → change workspace - termios.tcsetattr(fd, termios.TCSADRAIN, old) - print("\r\n", end="") - ws = input("Workspace directory: ").strip() - if ws: - resolved = os.path.abspath(ws) - if not os.path.isdir(resolved): - print(f"Error: '{resolved}' is not a valid directory") - else: - os.chdir(resolved) - print(f"\u2192 Workspace changed to {os.getcwd()}") - return interactive_input(header_mode='workspace') - elif ch in (b'\r', b'\n'): # Enter - buffer.extend(b'\n') - sys.stdout.buffer.write(b'\r\n') - sys.stdout.flush() - elif ch == b'\x7f': # Backspace - if buffer: - buffer.pop() - sys.stdout.buffer.write(b'\b \b') - sys.stdout.flush() - elif ch >= b' ': # Printable characters - buffer.extend(ch) - sys.stdout.buffer.write(ch) - sys.stdout.flush() - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old) - - full_query = buffer.decode('utf-8', errors='replace').strip() - - if full_query.startswith(':workspace '): - ws = full_query[11:].strip() - resolved = os.path.abspath(ws) - if not os.path.isdir(resolved): - print(f"Error: '{resolved}' is not a valid directory") - return interactive_input(header_mode=header_mode) - os.chdir(resolved) - print(f"\u2192 Workspace changed to {os.getcwd()}") - return interactive_input(header_mode='workspace') - - return full_query - - -def agent_loop(user_query, messages, llm_client): - messages.append({"role": "user", "content": user_query}) - for _ in range(config.AGENT_MAX_ITERATIONS): - response = llm_client.chat(messages, tools=TOOLS) - if response.tool_calls: - assistant_msg = { - "role": "assistant", - "content": response.content, - "tool_calls": response.tool_calls - } - messages.append(assistant_msg) - for tool_call in response.tool_calls: - tool_name = tool_call['function']['name'] - tool_args = json.loads(tool_call['function']['arguments']) - handler = TOOL_HANDLERS.get(tool_name) - if not handler: - result = f"Tool {tool_name} not found" - else: - args_display = ", ".join(f"{k}={v!r}" for k, v in tool_args.items()) - print(f" \u2192 {tool_name}({args_display})") - try: - if tool_name == "search_code": - result = handler( - pattern=tool_args["pattern"], - search_type=tool_args["search_type"], - path=tool_args.get("path", ".") - ) - elif tool_name == "git_operation": - result = handler(args=tool_args["args"]) - else: - result = handler(**tool_args) - except Exception as e: - result = f"Error executing tool: {str(e)}" - messages.append({ - "role": "tool", - "tool_call_id": tool_call['id'], - "content": str(result) - }) - else: - messages.append({"role": "assistant", "content": response.content}) - return response.content, messages - msg = "Max iterations reached without final answer." - messages.append({"role": "assistant", "content": msg}) - return msg, messages - +# Ekstrak dari tools_definition ke dua format berbeda +TOOLS = gadget.tool_schemas (tools_definition) +TOOL_HANDLERS = gadget.tool_handlers (tools_definition) def main(): + llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout) + + # Parsing arguments `-w ` atau `--workspace ` workspace = None - query_parts = [] - tui_mode = False i = 1 while i < len(sys.argv): - if sys.argv[i] == '--tui': - tui_mode = True - i += 1 - elif sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv): + if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv): workspace = sys.argv[i + 1] i += 2 else: - query_parts.append(sys.argv[i]) i += 1 + # Apply workspace jika ada if workspace: resolved = os.path.abspath(workspace) if not os.path.isdir(resolved): @@ -163,57 +41,14 @@ def main(): sys.exit(1) os.chdir(resolved) - llm_client = LLMClient( - base_url=config.LLM_BASE_URL, - model=config.LLM_MODEL, - api_key=config.LLM_API_KEY - ) - - if tui_mode: - from scripts.tui import HendrikTUI - HendrikTUI( - llm_client=llm_client, - tools_definition=tools_definition, - TOOLS=TOOLS, - TOOL_HANDLERS=TOOL_HANDLERS, - build_system_prompt=gadget.build_system_prompt, - agent_max_iterations=config.AGENT_MAX_ITERATIONS, - ).run() - return - - messages = None - - if query_parts: - user_query = ' '.join(query_parts) - if not user_query: - print("No query provided.") - return - messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}] - print(f"\n{TEXT_COLOR_YELLOW}Thinking...{TEXT_COLOR_RESET}") - final_answer, messages = agent_loop(user_query, messages, llm_client) - print(f"\n{TEXT_COLOR_GREEN}Final Answer:{TEXT_COLOR_RESET}") - print(final_answer) - return - - first_interaction = True - while True: - user_query = interactive_input( - header_mode='full' if first_interaction else 'compact' - ) - first_interaction = False - if not user_query: - break - if user_query.lower() in ('/exit', '/quit'): - break - - if messages is None: - messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}] - - print(f"{TEXT_COLOR_YELLOW}Thinking...{TEXT_COLOR_RESET}") - final_answer, messages = agent_loop(user_query, messages, llm_client) - print(f"\n{TEXT_COLOR_GREEN}Final Answer:{TEXT_COLOR_RESET}") - print(final_answer) - + HendrikTUI( + llm_client = llm_client, + tools_definition = tools_definition, + TOOLS = TOOLS, + TOOL_HANDLERS = TOOL_HANDLERS, + build_system_prompt = gadget.build_system_prompt, + agent_max_iterations = config.AGENT_MAX_ITERATIONS, + ).run() # Luncurkan TUI if __name__ == "__main__": main() diff --git a/scripts/ansicolor.py b/scripts/ansicolor.py deleted file mode 100644 index 96823a0..0000000 --- a/scripts/ansicolor.py +++ /dev/null @@ -1,3 +0,0 @@ -TEXT_COLOR_YELLOW = '\033[93m' -TEXT_COLOR_GREEN = '\033[92m' -TEXT_COLOR_RESET = '\033[0m' diff --git a/llm_client.py b/scripts/llm_client.py similarity index 85% rename from llm_client.py rename to scripts/llm_client.py index 03ff885..05a84af 100644 --- a/llm_client.py +++ b/scripts/llm_client.py @@ -1,7 +1,6 @@ import json import urllib.request import urllib.error -from config import LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_TIMEOUT class LLMClient: class Message: @@ -9,10 +8,11 @@ class LLMClient: self.content = msg.get('content', '') self.tool_calls = msg.get('tool_calls', None) - def __init__(self, base_url=LLM_BASE_URL, model=LLM_MODEL, api_key=LLM_API_KEY): + def __init__(self, base_url, model, api_key, timeout=600): self.base_url = base_url.rstrip('/') self.model = model self.api_key = api_key + self.timeout = timeout def chat(self, messages, tools=None): url = f"{self.base_url}/chat/completions" @@ -30,7 +30,7 @@ class LLMClient: req.add_header('Authorization', f'Bearer {self.api_key}') try: - with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: response = json.loads(resp.read().decode('utf-8')) message = response['choices'][0]['message'] return self.Message(message) diff --git a/scripts/separator.py b/scripts/separator.py deleted file mode 100644 index fb14bcb..0000000 --- a/scripts/separator.py +++ /dev/null @@ -1,5 +0,0 @@ -import shutil - - -def separator(): - return "\u2500" * shutil.get_terminal_size().columns diff --git a/scripts/tui.py b/scripts/tui.py deleted file mode 100644 index 7004017..0000000 --- a/scripts/tui.py +++ /dev/null @@ -1,431 +0,0 @@ -import curses -import os -import json -from datetime import datetime - - -class HendrikTUI: - C_HEADER = 1 - C_USER = 2 - C_AI = 3 - C_SYSTEM = 4 - C_INPUT = 5 - C_STATUS = 6 - C_SEP = 7 - C_ERROR = 8 - C_INPUT_BORDER = 9 - - def __init__(self, llm_client, tools_definition, TOOLS, TOOL_HANDLERS, - build_system_prompt, agent_max_iterations): - self.llm = llm_client - self.tools_def = tools_definition - self.TOOLS = TOOLS - self.TOOL_HANDLERS = TOOL_HANDLERS - self.build_system_prompt = build_system_prompt - self.agent_max_iterations = agent_max_iterations - - 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 - - def run(self): - try: - curses.wrapper(self._main) - except KeyboardInterrupt: - pass - print("Exiting.") - - def _main(self, stdscr): - curses.use_default_colors() - self._init_colors() - stdscr.keypad(True) - stdscr.refresh() - - self.messages = [{"role": "system", - "content": self.build_system_prompt(self.tools_def)}] - - while self.running: - self.h, self.w = stdscr.getmaxyx() - if self.h < 14 or self.w < 40: - stdscr.erase() - stdscr.addstr(0, 0, "Terminal too small (min 40x14)") - stdscr.refresh() - stdscr.getch() - continue - - self._draw(stdscr) - curses.curs_set(0 if self.processing else 2) - try: - key = stdscr.getch() - except KeyboardInterrupt: - break - if not self.processing: - self._handle_key(stdscr, key) - - # ---- colors ----------------------------------------------------------- - - def _init_colors(self): - curses.init_pair(self.C_HEADER, curses.COLOR_BLACK, curses.COLOR_BLUE) - curses.init_pair(self.C_USER, curses.COLOR_CYAN, -1) - curses.init_pair(self.C_AI, curses.COLOR_GREEN, -1) - curses.init_pair(self.C_SYSTEM, curses.COLOR_YELLOW, -1) - curses.init_pair(self.C_INPUT, curses.COLOR_WHITE, -1) - curses.init_pair(self.C_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW) - curses.init_pair(self.C_SEP, curses.COLOR_MAGENTA, -1) - curses.init_pair(self.C_ERROR, curses.COLOR_RED, -1) - curses.init_pair(self.C_INPUT_BORDER, curses.COLOR_BLUE, -1) - - # ---- main draw -------------------------------------------------------- - - def _draw(self, stdscr): - stdscr.erase() - self._draw_header(stdscr) - self._draw_chat(stdscr) - self._draw_status(stdscr) - self._draw_input(stdscr) - - def _draw_header(self, stdscr): - name = " Hendrik AI Agent " - model = f" {self.llm.model} " - mid = self.w - len(model) - 1 - pad = max(1, mid - len(name) - 1) - line = name + "\u2500" * pad + " " + model - attr = curses.color_pair(self.C_HEADER) | curses.A_BOLD - stdscr.addstr(0, 0, line[:self.w], attr) - - # ---- chat area -------------------------------------------------------- - - def _draw_chat(self, stdscr): - chat_top = 1 - chat_h = self.h - 10 - if chat_h <= 0: - return - - rendered = [] - for item in self.log: - role, text = item["role"], item["text"] - if role == "sep": - rendered.append((None, "\u2500" * self.w)) - continue - label = "" - color = None - if role == "user": - label = f" You ({item['time']}) " - color = self.C_USER - elif role == "ai": - label = f" Hendrik ({item['time']}) " - color = self.C_AI - elif role == "system": - label = " \u25e6 " - color = self.C_SYSTEM - elif role == "error": - label = " \u2717 " - color = self.C_ERROR - - lines = text.split("\n") - rendered.append((color, label + (lines[0] if lines else ""))) - for line in lines[1:]: - rendered.append((color, " " + line)) - - total = len(rendered) - max_scroll = max(0, total - chat_h) - if self.scroll > max_scroll: - self.scroll = max_scroll - self.scroll = max(0, self.scroll) - - y = chat_top - for i in range(self.scroll, min(self.scroll + chat_h, total)): - color, text = rendered[i] - attr = curses.color_pair(color) if color else curses.A_NORMAL - if len(text) >= self.w: - text = text[: self.w - 1] - try: - stdscr.addstr(y, 0, text, attr) - except curses.error: - pass - y += 1 - - # ---- input area ------------------------------------------------------- - - def _draw_input(self, stdscr): - iy = self.h - 8 - border_attr = curses.color_pair(self.C_INPUT_BORDER) | curses.A_BOLD - - for off, text in [ - (0, "\u250c" + "\u2500" * (self.w - 2) + "\u2510"), - (7, "\u2514" + "\u2500" * (self.w - 2) + "\u2518"), - ]: - try: - stdscr.addstr(iy + off, 0, text[:self.w], border_attr) - except curses.error: - pass - - total = len(self.input_buffer) - if total <= 6: - show = 0 - else: - show = max(0, min(self.input_line - 3, total - 6)) - - cursor_yx = None - text_attr = curses.color_pair(self.C_INPUT) - for i in range(6): - idx = show + i - y = iy + 1 + i - line = self.input_buffer[idx] if idx < total else "" - max_text = self.w - 6 - if len(line) > max_text: - line = line[:max_text] - - try: - stdscr.addstr(y, 0, "\u2502 ", border_attr) - except curses.error: - pass - - if idx < total: - content = "> " + line + " " * (self.w - 4 - 2 - len(line)) - try: - stdscr.addstr(y, 2, content, text_attr) - except curses.error: - pass - else: - try: - stdscr.addstr(y, 2, " " * (self.w - 4), border_attr) - except curses.error: - pass - - try: - stdscr.addstr(y, self.w - 2, " \u2502", border_attr) - except curses.error: - pass - - if idx == self.input_line and not self.processing: - cursor_yx = (y, 4 + min(self.input_col, max_text)) - - if cursor_yx: - stdscr.move(*cursor_yx) - - # ---- status bar ------------------------------------------------------- - - def _draw_status(self, stdscr): - y = self.h - 9 - ws = os.getcwd() - mode = " PROCESSING " if self.processing else " READY " - hints = " ^D:send ^W:workspace ^C:exit " - max_ws = self.w - len(mode) - len(hints) - 4 - if len(ws) > max_ws: - ws = ".." + ws[-(max_ws - 2):] - status = f" {ws} \u2502{mode}\u2502{hints}" - attr = curses.color_pair(self.C_STATUS) | curses.A_BOLD - stdscr.addstr(y, 0, status[:self.w], attr) - - # ---- input handling --------------------------------------------------- - - def _handle_key(self, stdscr, key): - if key == 4: - self._submit(stdscr) - elif key == 23: - self._workspace_popup(stdscr) - elif key == 3: - self.running = False - elif key == 12: - self.log.clear() - elif key in (curses.KEY_ENTER, 10, 13): - self.input_buffer.insert(self.input_line + 1, "") - self.input_line += 1 - self.input_col = 0 - elif key in (curses.KEY_BACKSPACE, 127): - if self.input_col > 0: - line = self.input_buffer[self.input_line] - self.input_buffer[self.input_line] = ( - line[: self.input_col - 1] + line[self.input_col :] - ) - self.input_col -= 1 - elif self.input_line > 0: - carry = self.input_buffer.pop(self.input_line) - self.input_line -= 1 - self.input_col = len(self.input_buffer[self.input_line]) - self.input_buffer[self.input_line] += carry - elif key == curses.KEY_UP: - if self.input_line > 0: - self.input_line -= 1 - self.input_col = min( - self.input_col, len(self.input_buffer[self.input_line]) - ) - elif key == curses.KEY_DOWN: - if self.input_line < len(self.input_buffer) - 1: - self.input_line += 1 - self.input_col = min( - self.input_col, len(self.input_buffer[self.input_line]) - ) - elif key == curses.KEY_LEFT: - if self.input_col > 0: - self.input_col -= 1 - elif self.input_line > 0: - self.input_line -= 1 - self.input_col = len(self.input_buffer[self.input_line]) - elif key == curses.KEY_RIGHT: - if self.input_col < len(self.input_buffer[self.input_line]): - self.input_col += 1 - elif self.input_line < len(self.input_buffer) - 1: - self.input_line += 1 - self.input_col = 0 - elif key == curses.KEY_HOME: - self.input_col = 0 - elif key == curses.KEY_END: - self.input_col = len(self.input_buffer[self.input_line]) - elif key == curses.KEY_PPAGE: - self.scroll = max(0, self.scroll - (self.h - 10)) - elif key == curses.KEY_NPAGE: - self.scroll += self.h - 10 - elif key == curses.KEY_RESIZE: - pass - elif key == 9: - line = self.input_buffer[self.input_line] - self.input_buffer[self.input_line] = ( - line[: self.input_col] + " " + line[self.input_col :] - ) - self.input_col += 4 - elif 32 <= key <= 255: - ch = chr(key) - line = self.input_buffer[self.input_line] - self.input_buffer[self.input_line] = ( - line[: self.input_col] + ch + line[self.input_col :] - ) - self.input_col += 1 - - # ---- submit & process ------------------------------------------------- - - def _log(self, role, text): - self.log.append({ - "role": role, - "text": text, - "time": datetime.now().strftime("%H:%M"), - }) - - def _submit(self, stdscr): - query = "\n".join(self.input_buffer).strip() - if not query: - return - - self._log("user", query) - self.input_buffer = [""] - self.input_line = 0 - self.input_col = 0 - self.scroll = 999999 - self.processing = True - self._draw(stdscr) - stdscr.refresh() - - self.messages.append({"role": "user", "content": query}) - - for step in range(self.agent_max_iterations): - self._log("system", f" step {step + 1} \u2014 LLM...") - self.scroll = 999999 - self._draw(stdscr) - stdscr.refresh() - - response = self.llm.chat(self.messages, tools=self.TOOLS) - - self.log.pop() - - if response.tool_calls: - amsg = { - "role": "assistant", - "content": response.content, - "tool_calls": response.tool_calls, - } - self.messages.append(amsg) - if response.content and response.content.strip(): - self._log("ai", response.content) - self.scroll = 999999 - self._draw(stdscr) - stdscr.refresh() - for tc in response.tool_calls: - tname = tc["function"]["name"] - self._log("system", f" \u2192 {tname}") - self.scroll = 999999 - self._draw(stdscr) - stdscr.refresh() - self._execute_tool(tc) - else: - if response.content: - self.messages.append({ - "role": "assistant", - "content": response.content, - }) - self._log("ai", response.content) - self._log("sep", "") - self.processing = False - self.scroll = 999999 - self._draw(stdscr) - stdscr.refresh() - return - - self._log("error", "Max iterations reached without final answer.") - self.messages.append({"role": "assistant", - "content": "Max iterations reached without final answer."}) - self.processing = False - - def _execute_tool(self, tool_call): - tname = tool_call["function"]["name"] - targs = json.loads(tool_call["function"]["arguments"]) - handler = self.TOOL_HANDLERS.get(tname) - - if not handler: - result = f"Tool {tname} not found" - else: - try: - if tname == "search_code": - result = handler( - pattern=targs["pattern"], - search_type=targs["search_type"], - path=targs.get("path", "."), - ) - elif tname == "git_operation": - result = handler(args=targs["args"]) - else: - result = handler(**targs) - except Exception as e: - result = f"Error executing tool: {str(e)}" - - self.messages.append({ - "role": "tool", - "tool_call_id": tool_call["id"], - "content": str(result), - }) - - # ---- workspace popup -------------------------------------------------- - - def _workspace_popup(self, stdscr): - pw = min(60, self.w - 4) - ph = 3 - px = (self.w - pw) // 2 - py = self.h // 2 - 1 - - win = curses.newwin(ph, pw, py, px) - win.box() - win.addstr(0, 2, " Workspace path: ") - win.addstr(1, 2, " " * (pw - 4)) - - curses.echo() - ws = win.getstr(1, 2, pw - 5).decode("utf-8") - curses.noecho() - del win - - ws = ws.strip() - if ws: - resolved = os.path.abspath(ws) - if os.path.isdir(resolved): - os.chdir(resolved) - self._log("system", f"Workspace \u2192 {resolved}") - else: - self._log("error", f"Invalid directory: {resolved}") - - stdscr.touchwin() - stdscr.refresh() diff --git a/tui/__init__.py b/tui/__init__.py new file mode 100644 index 0000000..2a333b3 --- /dev/null +++ b/tui/__init__.py @@ -0,0 +1,2 @@ +# Re-export HendrikTUI agar import dari luar cukup: from tui import HendrikTUI +from .app import HendrikTUI diff --git a/tui/app.py b/tui/app.py new file mode 100644 index 0000000..947b8ff --- /dev/null +++ b/tui/app.py @@ -0,0 +1,69 @@ +import curses +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 + self.TOOL_HANDLERS = TOOL_HANDLERS + 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) + + 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 + print("Exiting.") + + 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.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)") + stdscr.refresh() + stdscr.getch() + continue + + draw(self, stdscr) + # Sembunyikan kursor saat processing, tampilkan high visibility saat idle + curses.curs_set(0 if self.processing else 2) + try: + key = stdscr.getch() + except KeyboardInterrupt: + break + if not self.processing: + handle_key(self, stdscr, key) diff --git a/tui/input.py b/tui/input.py new file mode 100644 index 0000000..0fe384b --- /dev/null +++ b/tui/input.py @@ -0,0 +1,126 @@ +# input.py — Keyboard handling dan workspace popup. +# handle_key() adalah dispatch besar yang menerjemahkan +# key code curses menjadi aksi pada state app. + +import curses +import os +from .agent import submit, log + + +def handle_key(app, stdscr, key): + # -- Ctrl shortcuts -- + if key == 4: # Ctrl+D → submit query ke LLM + submit(app, stdscr) + elif key == 23: # Ctrl+W → popup ganti workspace + workspace_popup(app, stdscr) + elif key == 3: # Ctrl+C → exit + app.running = False + elif key == 12: # Ctrl+L → clear chat log + app.log.clear() + + # -- Enter: buat baris baru di input buffer -- + elif key in (curses.KEY_ENTER, 10, 13): + app.input_buffer.insert(app.input_line + 1, "") + app.input_line += 1 + app.input_col = 0 + + # -- Backspace: hapus karakter sebelumnya atau gabung baris -- + elif key in (curses.KEY_BACKSPACE, 127): + if app.input_col > 0: + line = app.input_buffer[app.input_line] + app.input_buffer[app.input_line] = ( + line[: app.input_col - 1] + line[app.input_col :] + ) + app.input_col -= 1 + elif app.input_line > 0: + carry = app.input_buffer.pop(app.input_line) + app.input_line -= 1 + app.input_col = len(app.input_buffer[app.input_line]) + app.input_buffer[app.input_line] += carry + + # -- Navigation arrows -- + elif key == curses.KEY_UP: + if app.input_line > 0: + app.input_line -= 1 + app.input_col = min( + app.input_col, len(app.input_buffer[app.input_line]) + ) + elif key == curses.KEY_DOWN: + if app.input_line < len(app.input_buffer) - 1: + app.input_line += 1 + app.input_col = min( + app.input_col, len(app.input_buffer[app.input_line]) + ) + elif key == curses.KEY_LEFT: + if app.input_col > 0: + app.input_col -= 1 + elif app.input_line > 0: + app.input_line -= 1 + app.input_col = len(app.input_buffer[app.input_line]) + elif key == curses.KEY_RIGHT: + if app.input_col < len(app.input_buffer[app.input_line]): + app.input_col += 1 + elif app.input_line < len(app.input_buffer) - 1: + app.input_line += 1 + app.input_col = 0 + elif key == curses.KEY_HOME: + app.input_col = 0 + elif key == curses.KEY_END: + app.input_col = len(app.input_buffer[app.input_line]) + + # -- Page Up / Down: scroll chat area -- + elif key == curses.KEY_PPAGE: + app.scroll = max(0, app.scroll - (app.h - 10)) + elif key == curses.KEY_NPAGE: + app.scroll += app.h - 10 + + # -- Resize terminal: tidak perlu action, next loop akan baca ukuran baru -- + elif key == curses.KEY_RESIZE: + pass + + # -- Tab: insert 4 spasi -- + elif key == 9: + line = app.input_buffer[app.input_line] + app.input_buffer[app.input_line] = ( + line[: app.input_col] + " " + line[app.input_col :] + ) + app.input_col += 4 + + # -- Printable characters -- + elif 32 <= key <= 255: + ch = chr(key) + line = app.input_buffer[app.input_line] + app.input_buffer[app.input_line] = ( + line[: app.input_col] + ch + line[app.input_col :] + ) + app.input_col += 1 + + +def workspace_popup(app, stdscr): + # Overlay window kecil di tengah layar untuk input path workspace + pw = min(60, app.w - 4) + ph = 3 + px = (app.w - pw) // 2 + py = app.h // 2 - 1 + + win = curses.newwin(ph, pw, py, px) + win.box() + win.addstr(0, 2, " Workspace path: ") + win.addstr(1, 2, " " * (pw - 4)) + + curses.echo() # tampilkan input user + ws = win.getstr(1, 2, pw - 5).decode("utf-8") + curses.noecho() + del win + + ws = ws.strip() + if ws: + resolved = os.path.abspath(ws) + if os.path.isdir(resolved): + os.chdir(resolved) + log(app, "system", f"Workspace \u2192 {resolved}") + else: + log(app, "error", f"Invalid directory: {resolved}") + + stdscr.touchwin() + stdscr.refresh() diff --git a/tui/render.py b/tui/render.py new file mode 100644 index 0000000..3ad3dbe --- /dev/null +++ b/tui/render.py @@ -0,0 +1,214 @@ +# render.py — Semua fungsi drawing / tampilan curses. +# Setiap fungsi menerima `app` (instance HendrikTUI) dan `stdscr` +# lalu membaca state dari `app` untuk menggambar di layar. + +import curses +import os + +# -- Color pair IDs (id 1-9, id 0 = default curses) -- +C_HEADER = 1 # header bar: biru +C_USER = 2 # user message: cyan +C_AI = 3 # AI response: hijau +C_SYSTEM = 4 # system log: kuning +C_INPUT = 5 # text input: putih +C_STATUS = 6 # status bar: hitam di atas kuning +C_STATUS_READY = 10 # status READY: hijau +C_STATUS_PROC = 11 # status PROCESSING: kuning +C_SEP = 7 # separator line: magenta +C_ERROR = 8 # error message: merah +C_INPUT_BORDER = 9 # border input box: biru +C_STATUS_INFO = 12 # status info (workspace/hints): putih + + +def init_colors(): + # Daftarkan semua color pair sekali di awal. + # -1 = foreground/background default terminal. + curses.init_pair(C_HEADER, curses.COLOR_BLACK, curses.COLOR_BLUE) + curses.init_pair(C_USER, curses.COLOR_CYAN, -1) + curses.init_pair(C_AI, curses.COLOR_GREEN, -1) + curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1) + curses.init_pair(C_INPUT, curses.COLOR_WHITE, -1) + curses.init_pair(C_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW) + curses.init_pair(C_STATUS_READY, curses.COLOR_WHITE, curses.COLOR_GREEN) + curses.init_pair(C_STATUS_PROC, curses.COLOR_BLACK, curses.COLOR_YELLOW) + curses.init_pair(C_STATUS_INFO, curses.COLOR_WHITE, -1) + curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1) + curses.init_pair(C_ERROR, curses.COLOR_RED, -1) + curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1) + + +def draw(app, stdscr): + # Panggil keempat fungsi gambar secara berurutan. + # Urutan penting: input digambar paling akhir supaya kursor + # bisa dipindah di atas layer paling atas. + draw_header(app, stdscr) + draw_chat(app, stdscr) + draw_status(app, stdscr) + draw_input(app, stdscr) + + +def draw_header(app, stdscr): + # Baris paling atas: " Hendrik AI Agent ─────── " + w = app.w + name = " Hendrik AI Agent " + model = f" {app.llm.model} " + mid = w - len(model) - 1 + pad = max(1, mid - len(name) - 1) + line = name + "\u2500" * pad + " " + model + attr = curses.color_pair(C_HEADER) | curses.A_BOLD + stdscr.addstr(0, 0, line[:w], attr) + + +def draw_chat(app, stdscr): + # Area chat — dari baris 1 sampai baris (h - 10). + # Bisa di-scroll dengan Page Up / Page Down. + # app.log berisi daftar item (role, text, time) untuk display. + h, w = app.h, app.w + chat_top = 1 + chat_h = h - 10 + if chat_h <= 0: + return + + # Render log ke list of (color, text) agar scroll calculation akurat + rendered = [] + for item in app.log: + role, text = item["role"], item["text"] + if role == "sep": + rendered.append((None, "")) + rendered.append((None, "")) + continue + label = "" + color = None + if role == "user": + label = f" You ({item['time']}) " + color = C_USER + elif role == "ai": + label = f" Hendrik ({item['time']}) " + color = C_AI + elif role == "system": + label = " \u25e6 " + color = C_SYSTEM + elif role == "error": + label = " \u2717 " + color = C_ERROR + + lines = text.split("\n") + rendered.append((color, label + (lines[0] if lines else ""))) + for line in lines[1:]: + rendered.append((color, " " + line)) + + # Clamp scroll agar tidak melebihi total baris + total = len(rendered) + max_scroll = max(0, total - chat_h) + if app.scroll > max_scroll: + app.scroll = max_scroll + app.scroll = max(0, app.scroll) + + # Gambar baris yang terlihat (scroll sampai scroll + chat_h) + # Clear area chat dulu biar nggak ada text lama yang nyisa + for clear_y in range(chat_top, chat_top + chat_h): + try: + stdscr.addstr(clear_y, 0, " " * w, curses.A_NORMAL) + except curses.error: + pass + + y = chat_top + for i in range(app.scroll, min(app.scroll + chat_h, total)): + color, text = rendered[i] + attr = curses.color_pair(color) if color else curses.A_NORMAL + if len(text) >= w: + text = text[: w - 1] + try: + stdscr.addstr(y, 0, text, attr) + except curses.error: + pass + y += 1 + + +def draw_input(app, stdscr): + # Kotak input multi-line (maks 6 baris). + # Ada border atas dan bawah. + # Baris aktif ditandai "> " di depannya. + h, w = app.h, app.w + iy = h - 8 + border_attr = curses.color_pair(C_INPUT_BORDER) | curses.A_BOLD + + # Border atas ┌───┐ dan bawah └───┘ + for off, text in [ + (0, "\u250c" + "\u2500" * (w - 2) + "\u2510"), + (7, "\u2514" + "\u2500" * (w - 2) + "\u2518"), + ]: + try: + stdscr.addstr(iy + off, 0, text[:w], border_attr) + except curses.error: + pass + + # Scroll input horizontal jika buffer > 6 baris + total = len(app.input_buffer) + if total <= 6: + show = 0 + else: + show = max(0, min(app.input_line - 3, total - 6)) + + cursor_yx = None + text_attr = curses.color_pair(C_INPUT) + for i in range(6): + idx = show + i + y = iy + 1 + i + line = app.input_buffer[idx] if idx < total else "" + max_text = w - 6 + if len(line) > max_text: + line = line[:max_text] + + # Border kiri + try: + stdscr.addstr(y, 0, "\u2502 ", border_attr) + except curses.error: + pass + + # Isi baris + if idx < total: + content = "> " + line + " " * (w - 4 - 2 - len(line)) + try: + stdscr.addstr(y, 2, content, text_attr) + except curses.error: + pass + else: + try: + stdscr.addstr(y, 2, " " * (w - 4), border_attr) + except curses.error: + pass + + # Border kanan + try: + stdscr.addstr(y, w - 2, " \u2502", border_attr) + except curses.error: + pass + + # Catat posisi kursor (hanya untuk baris aktif) + if idx == app.input_line and not app.processing: + cursor_yx = (y, 4 + min(app.input_col, max_text)) + + if cursor_yx: + stdscr.move(*cursor_yx) + + +def draw_status(app, stdscr): + # Status bar di baris h-9: workspace, mode (READY/PROCESSING), shortcut hints + h, w = app.h, app.w + y = h - 9 + ws = os.getcwd() + mode = " PROCESSING " if app.processing else " READY " + hints = " ^D:send ^W:workspace ^C:exit " + max_ws = w - len(mode) - len(hints) - 4 + if len(ws) > max_ws: + ws = ".." + ws[-(max_ws - 2):] + + status = f" {ws} \u2502{mode}\u2502{hints}" + stdscr.addstr(y, 0, status[:w], curses.color_pair(C_STATUS_INFO)) + + # Highlight mode dengan warna berbeda + mode_start = len(f" {ws} \u2502") + mode_end = mode_start + len(mode) + mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC) + stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)