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 b/hendrik new file mode 100644 index 0000000..3f7d21d --- /dev/null +++ b/hendrik @@ -0,0 +1,13 @@ +#!/bin/bash +# hendrik — wrapper to run the TUI agent from anywhere + +# Set HENDRIK_DIR env var to override, or update the default below +DEFAULT_DIR="/home/ambadar-aji/experiment/hendrik" +PROJECT_DIR="${HENDRIK_DIR:-$DEFAULT_DIR}" + +if [ ! -d "$PROJECT_DIR/.venv" ]; then + echo "Error: .venv not found at $PROJECT_DIR" + exit 1 +fi + +exec "$PROJECT_DIR/.venv/bin/python" "$PROJECT_DIR/hendrik.py" "$@" diff --git a/hendrik.py b/hendrik.py index 9342f78..eec8f26 100644 --- a/hendrik.py +++ b/hendrik.py @@ -1,157 +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 = [] i = 1 while i < 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): @@ -159,45 +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 - ) - - 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/ntro.py b/scripts/ntro.py new file mode 100644 index 0000000..6751cf2 --- /dev/null +++ b/scripts/ntro.py @@ -0,0 +1,12 @@ +import time + +def start(): + return {"start": time.perf_counter_ns()} + +def end(stamp): + elapsed_ns = time.perf_counter_ns() - stamp["start"] + elapsed_ms = elapsed_ns / 1_000_000 + stamp["start"] = time.perf_counter_ns() # For start with multi end + # print(f"[ntropy] {elapsed_ms:.2f}ms") + return elapsed_ms + 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/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/agent.py b/tui/agent.py new file mode 100644 index 0000000..22fa9be --- /dev/null +++ b/tui/agent.py @@ -0,0 +1,129 @@ +# 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 +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, + "time": datetime.now().strftime("%H:%M"), + }) + + +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.processing = True + draw(app, stdscr) + stdscr.refresh() + + app.messages.append({"role": "user", "content": query}) + + 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, + "tool_calls": response.tool_calls, + } + app.messages.append(amsg) + 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", + "content": response.content, + }) + log(app, "ai", response.content) + log(app, "sep", "") + app.processing = False + app.scroll = 999999 + draw(app, stdscr) + stdscr.refresh() + ntro.end(stamp) + 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) + + +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) + + 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)}" + + # Hasil tool disimpan ke messages agar bisa dikirim balik ke LLM + app.messages.append({ + "role": "tool", + "tool_call_id": tool_call["id"], + "content": str(result), + }) diff --git a/tui/app.py b/tui/app.py new file mode 100644 index 0000000..bff4e12 --- /dev/null +++ b/tui/app.py @@ -0,0 +1,68 @@ +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 + handle_key(self, stdscr, key) diff --git a/tui/input.py b/tui/input.py new file mode 100644 index 0000000..f4aa532 --- /dev/null +++ b/tui/input.py @@ -0,0 +1,159 @@ +# 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 _build_visual(buffer, max_chars): + # Build list of (logical_line_idx, start_col) for each visual line. + visual = [] + for i, line in enumerate(buffer): + if not line: + visual.append((i, 0)) + else: + for start in range(0, max(len(line), 1), max_chars): + visual.append((i, start)) + return visual + + +def _find_visual(visual, logical_line, col): + # Find visual line index for given logical line and column. + best = 0 + for idx, (li, start) in enumerate(visual): + if li == logical_line: + best = idx + if start <= col: + break + return best + + +def handle_key(app, stdscr, key): + max_chars = app.w - 6 # usable width in input box + visual = _build_visual(app.input_buffer, max_chars) + cur_visual = _find_visual(visual, app.input_line, app.input_col) + + processing = app.processing + + # -- Always allowed (even during processing) -- + if key == 3: # Ctrl+C → exit + app.running = False + elif key == curses.KEY_PPAGE: + app.scroll = max(0, app.scroll - (app.h - 10) // 2) + elif key == curses.KEY_NPAGE: + app.scroll += (app.h - 10) // 2 + elif key == curses.KEY_RESIZE: + pass + + # -- Blocked during processing -- + elif processing: + pass # ignore all other keys while processing + + # -- Ctrl shortcuts -- + elif 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 == 12: # Ctrl+L → clear chat log + app.log.clear() + + # -- Enter: split logical line at cursor position -- + elif key in (curses.KEY_ENTER, 10, 13): + line = app.input_buffer[app.input_line] + left = line[:app.input_col] + right = line[app.input_col:] + app.input_buffer[app.input_line] = left + app.input_buffer.insert(app.input_line + 1, right) + 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 cur_visual > 0: + prev_li, prev_start = visual[cur_visual - 1] + app.input_line = prev_li + app.input_col = min(app.input_col, len(app.input_buffer[prev_li])) + elif key == curses.KEY_DOWN: + if cur_visual < len(visual) - 1: + next_li, next_start = visual[cur_visual + 1] + app.input_line = next_li + app.input_col = min(app.input_col, len(app.input_buffer[next_li])) + 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]) + + # -- 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..c14458e --- /dev/null +++ b/tui/render.py @@ -0,0 +1,272 @@ +# 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 +import textwrap + +# -- 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 + # Setiap baris di-wrap sesuai lebar terminal + rendered = [] + + def _wrap_render(text, indent=0, color=C_INPUT): + available = w - indent + if available <= 0: + rendered.append((color, " " * indent)) + return + for line in text.split("\n"): + if not line: + rendered.append((color, " " * indent)) + continue + start = 0 + while start < len(line): + chunk = line[start:start + available] + rendered.append((color, " " * indent + chunk)) + start += available + + for idx, item in enumerate(app.log): + role, text = item["role"], item["text"] + if role == "sep": + rendered.append((None, "")) + rendered.append((None, "")) + continue + + # Tambah blank line sebelum system log setelah user/ai response + if role == "system" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"): + rendered.append((None, "")) + + # Tambah blank line sebelum ai response setelah user (langsung, tanpa tools) + if role == "ai" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"): + rendered.append((None, "")) + + # Tambah blank line sebelum ai response setelah system log + if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system": + rendered.append((None, "")) + + if role == "user": + label = f" You ({item['time']}) " + rendered.append((C_USER, label)) + _wrap_render(text, indent=1, color=C_INPUT) + elif role == "ai": + label = f" Hendrik ({item['time']}) " + rendered.append((C_AI, label)) + _wrap_render(text, indent=1, color=C_INPUT) + elif role == "system": + lines = text.split("\n") + rendered.append((C_SYSTEM, lines[0])) + for line in lines[1:]: + rendered.append((C_SYSTEM, " " + line)) + elif role == "error": + label = " \u2717 " + lines = text.split("\n") + rendered.append((C_ERROR, label + (lines[0] if lines else ""))) + for line in lines[1:]: + rendered.append((C_ERROR, " " + 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) | curses.A_BOLD 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 visual). + # Ada border atas dan bawah. + # Baris aktif ditandai "> " di depannya. + # Soft word wrap: logical line panjang dipecah jadi beberapa visual line. + h, w = app.h, app.w + iy = h - 8 + border_attr = curses.color_pair(C_INPUT_BORDER) | curses.A_BOLD + text_attr = curses.color_pair(C_INPUT) + max_chars = w - 6 # space for text (border + "> " = 4 chars, border = 2 chars) + + # 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 + + # Build visual lines dari buffer dengan soft wrap + visual = [] # list of (logical_line_idx, start_col, text_chunk) + for i, line in enumerate(app.input_buffer): + if not line: + visual.append((i, 0, "")) + else: + for start in range(0, max(len(line), 1), max_chars): + chunk = line[start:start + max_chars] + visual.append((i, start, chunk)) + + total_visual = len(visual) + + # Find cursor visual line + cur_visual = 0 + for idx, (li, start, text) in enumerate(visual): + if li == app.input_line and start <= app.input_col <= start + len(text): + cur_visual = idx + break + if li == app.input_line and app.input_col > start + len(text): + cur_visual = idx # edge: cursor at end of last chunk + + # Scroll input visual lines supaya cursor terlihat + if total_visual <= 6: + show = 0 + else: + show = max(0, min(cur_visual - 3, total_visual - 6)) + + cursor_yx = None + for i in range(6): + idx = show + i + y = iy + 1 + i + if idx < total_visual: + li, start, chunk = visual[idx] + line_is_active = (li == app.input_line) + + # Border kiri + try: + stdscr.addstr(y, 0, "\u2502 ", border_attr) + except curses.error: + pass + + # Isi baris + if line_is_active: + prefix = "> " + else: + prefix = " " + + content = prefix + chunk + " " * (w - 4 - len(prefix) - len(chunk)) + try: + stdscr.addstr(y, 2, content, text_attr) + except curses.error: + pass + + # Cursor position + if line_is_active and not app.processing: + col_on_visual = app.input_col - start + cursor_yx = (y, 4 + min(col_on_visual, len(chunk))) + + # Border kanan + try: + stdscr.addstr(y, w - 2, " \u2502", border_attr) + except curses.error: + pass + else: + # Empty row + try: + stdscr.addstr(y, 0, "\u2502" + " " * (w - 2) + "\u2502", border_attr) + except curses.error: + pass + + 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)