From 9c7e34409eb9e99c33da23d68b47da4c55ea5668 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:00:57 +0700 Subject: [PATCH 01/16] Add curses-based TUI mode with --tui flag --- hendrik.py | 18 ++- scripts/tui.py | 392 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 scripts/tui.py diff --git a/hendrik.py b/hendrik.py index 9342f78..47a49b9 100644 --- a/hendrik.py +++ b/hendrik.py @@ -143,9 +143,13 @@ def agent_loop(user_query, messages, llm_client): def main(): workspace = None query_parts = [] + tui_mode = False i = 1 while i < len(sys.argv): - if sys.argv[i] in ('-w', '--workspace') and i + 1 < 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): workspace = sys.argv[i + 1] i += 2 else: @@ -165,6 +169,18 @@ def main(): 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: diff --git a/scripts/tui.py b/scripts/tui.py new file mode 100644 index 0000000..c5e5e81 --- /dev/null +++ b/scripts/tui.py @@ -0,0 +1,392 @@ +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 + + 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): + curses.wrapper(self._main) + + 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 < 8 or self.w < 40: + stdscr.erase() + stdscr.addstr(0, 0, "Terminal too small (min 40x8)") + stdscr.refresh() + stdscr.getch() + continue + + self._draw(stdscr) + curses.curs_set(0 if self.processing else 2) + key = stdscr.getch() + if not self.processing: + self._handle_key(stdscr, key) + + # ---- colors ----------------------------------------------------------- + + def _init_colors(self): + curses.init_pair(self.C_HEADER, curses.COLOR_WHITE, 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_WHITE) + curses.init_pair(self.C_SEP, curses.COLOR_MAGENTA, -1) + curses.init_pair(self.C_ERROR, curses.COLOR_RED, -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 - 6 + 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 - 4 + blank = " " * (self.w - 2) + + for off, text in [ + (0, "\u250c" + "\u2500" * (self.w - 2) + "\u2510"), + (3, "\u2514" + "\u2500" * (self.w - 2) + "\u2518"), + ]: + try: + stdscr.addstr(iy + off, 0, text[:self.w]) + except curses.error: + pass + + total = len(self.input_buffer) + if total <= 2: + show = 0 + elif self.input_line <= 1: + show = max(0, total - 2) + elif self.input_line >= total - 2: + show = total - 2 + else: + show = self.input_line - 1 + + for i in range(2): + idx = show + i + y = iy + 1 + i + prefix = "> " if idx < total else " " + line = self.input_buffer[idx] if idx < total else "" + vis = prefix + line + if len(vis) > self.w - 2: + vis = vis[: self.w - 2] + fmt = "\u2502 " + vis + blank[len(vis):] + "\u2502" + stdscr.addstr(y, 0, fmt, curses.color_pair(self.C_INPUT)) + if idx == self.input_line and not self.processing: + col = min(2 + self.input_col, self.w - 3) + stdscr.move(y, col) + + # ---- status bar ------------------------------------------------------- + + def _draw_status(self, stdscr): + y = self.h - 5 + 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) + stdscr.addstr(y, 0, status[:self.w], attr) + + # ---- input handling --------------------------------------------------- + + def _handle_key(self, stdscr, key): + if key == 4: + self._submit() + 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 - 6)) + elif key == curses.KEY_NPAGE: + self.scroll += self.h - 6 + 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): + 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.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 + + 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 + for tc in response.tool_calls: + tname = tc["function"]["name"] + targs = tc["function"]["arguments"] + self._log("system", f" \u2192 {tname}") + self.scroll = 999999 + self._execute_tool(tc) + else: + self.messages.append({ + "role": "assistant", + "content": response.content, + }) + self._log("ai", response.content) + self._log("sep", "") + self.processing = False + self.scroll = 999999 + 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() From 18bbb0773792859f5540cb2a4e1c4715919d7c04 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:12:07 +0700 Subject: [PATCH 02/16] Fix input box border rendering and layout math --- scripts/tui.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index c5e5e81..f13505d 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -146,7 +146,6 @@ class HendrikTUI: def _draw_input(self, stdscr): iy = self.h - 4 - blank = " " * (self.w - 2) for off, text in [ (0, "\u250c" + "\u2500" * (self.w - 2) + "\u2510"), @@ -170,15 +169,20 @@ class HendrikTUI: for i in range(2): idx = show + i y = iy + 1 + i - prefix = "> " if idx < total else " " line = self.input_buffer[idx] if idx < total else "" - vis = prefix + line - if len(vis) > self.w - 2: - vis = vis[: self.w - 2] - fmt = "\u2502 " + vis + blank[len(vis):] + "\u2502" - stdscr.addstr(y, 0, fmt, curses.color_pair(self.C_INPUT)) + max_text = self.w - 6 + if len(line) > max_text: + line = line[:max_text] + if idx < total: + fmt = "\u2502 > " + line + " " * (max_text + 1 - len(line)) + "\u2502" + else: + fmt = "\u2502 " + " " * (self.w - 5) + "\u2502" + try: + stdscr.addstr(y, 0, fmt, curses.color_pair(self.C_INPUT)) + except curses.error: + pass if idx == self.input_line and not self.processing: - col = min(2 + self.input_col, self.w - 3) + col = 4 + min(self.input_col, max_text) stdscr.move(y, col) # ---- status bar ------------------------------------------------------- From 6f20e4b0b9e07c8e3b694092d751c08f4cff564d Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:13:53 +0700 Subject: [PATCH 03/16] Fix cursor position overwritten by addstr after loop --- scripts/tui.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index f13505d..fcd93d2 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -166,6 +166,7 @@ class HendrikTUI: else: show = self.input_line - 1 + cursor_yx = None for i in range(2): idx = show + i y = iy + 1 + i @@ -182,8 +183,10 @@ class HendrikTUI: except curses.error: pass if idx == self.input_line and not self.processing: - col = 4 + min(self.input_col, max_text) - stdscr.move(y, col) + cursor_yx = (y, 4 + min(self.input_col, max_text)) + + if cursor_yx: + stdscr.move(*cursor_yx) # ---- status bar ------------------------------------------------------- From 2c2927751a59feea68dbeccf3b346f8145c376c5 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:19:50 +0700 Subject: [PATCH 04/16] Expand input area to 6 visible lines and fix scroll logic --- scripts/tui.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index fcd93d2..ac6cc0c 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -47,9 +47,9 @@ class HendrikTUI: while self.running: self.h, self.w = stdscr.getmaxyx() - if self.h < 8 or self.w < 40: + if self.h < 14 or self.w < 40: stdscr.erase() - stdscr.addstr(0, 0, "Terminal too small (min 40x8)") + stdscr.addstr(0, 0, "Terminal too small (min 40x14)") stdscr.refresh() stdscr.getch() continue @@ -94,7 +94,7 @@ class HendrikTUI: def _draw_chat(self, stdscr): chat_top = 1 - chat_h = self.h - 6 + chat_h = self.h - 10 if chat_h <= 0: return @@ -145,11 +145,11 @@ class HendrikTUI: # ---- input area ------------------------------------------------------- def _draw_input(self, stdscr): - iy = self.h - 4 + iy = self.h - 8 for off, text in [ (0, "\u250c" + "\u2500" * (self.w - 2) + "\u2510"), - (3, "\u2514" + "\u2500" * (self.w - 2) + "\u2518"), + (7, "\u2514" + "\u2500" * (self.w - 2) + "\u2518"), ]: try: stdscr.addstr(iy + off, 0, text[:self.w]) @@ -157,17 +157,13 @@ class HendrikTUI: pass total = len(self.input_buffer) - if total <= 2: + if total <= 6: show = 0 - elif self.input_line <= 1: - show = max(0, total - 2) - elif self.input_line >= total - 2: - show = total - 2 else: - show = self.input_line - 1 + show = max(0, min(self.input_line - 3, total - 6)) cursor_yx = None - for i in range(2): + for i in range(6): idx = show + i y = iy + 1 + i line = self.input_buffer[idx] if idx < total else "" @@ -191,7 +187,7 @@ class HendrikTUI: # ---- status bar ------------------------------------------------------- def _draw_status(self, stdscr): - y = self.h - 5 + y = self.h - 9 ws = os.getcwd() mode = " PROCESSING " if self.processing else " READY " hints = " ^D:send ^W:workspace ^C:exit " @@ -258,9 +254,9 @@ class HendrikTUI: 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 - 6)) + self.scroll = max(0, self.scroll - (self.h - 10)) elif key == curses.KEY_NPAGE: - self.scroll += self.h - 6 + self.scroll += self.h - 10 elif key == curses.KEY_RESIZE: pass elif key == 9: From 33c9b48106858c9c3fffc818f6e167f94de5b5b1 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:23:40 +0700 Subject: [PATCH 05/16] Clean exit on Ctrl+C without traceback --- scripts/tui.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index ac6cc0c..3ab2295 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -34,7 +34,11 @@ class HendrikTUI: self.h, self.w = 0, 0 def run(self): - curses.wrapper(self._main) + try: + curses.wrapper(self._main) + except KeyboardInterrupt: + pass + print("Exiting.") def _main(self, stdscr): curses.use_default_colors() @@ -56,7 +60,10 @@ class HendrikTUI: self._draw(stdscr) curses.curs_set(0 if self.processing else 2) - key = stdscr.getch() + try: + key = stdscr.getch() + except KeyboardInterrupt: + break if not self.processing: self._handle_key(stdscr, key) From 293bd5d550e38d0dad2ac0e1c8043f149b6ccb19 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 10:33:52 +0700 Subject: [PATCH 06/16] Update TUI colors: black-on-blue header, blue border, yellow status bar --- scripts/tui.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index 3ab2295..33c3145 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -13,6 +13,7 @@ class HendrikTUI: 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): @@ -70,14 +71,15 @@ class HendrikTUI: # ---- colors ----------------------------------------------------------- def _init_colors(self): - curses.init_pair(self.C_HEADER, curses.COLOR_WHITE, curses.COLOR_BLUE) + 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_WHITE) + 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 -------------------------------------------------------- @@ -153,13 +155,14 @@ class HendrikTUI: 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]) + stdscr.addstr(iy + off, 0, text[:self.w], border_attr) except curses.error: pass @@ -170,6 +173,7 @@ class HendrikTUI: 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 @@ -177,14 +181,29 @@ class HendrikTUI: max_text = self.w - 6 if len(line) > max_text: line = line[:max_text] - if idx < total: - fmt = "\u2502 > " + line + " " * (max_text + 1 - len(line)) + "\u2502" - else: - fmt = "\u2502 " + " " * (self.w - 5) + "\u2502" + try: - stdscr.addstr(y, 0, fmt, curses.color_pair(self.C_INPUT)) + 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)) @@ -202,7 +221,7 @@ class HendrikTUI: if len(ws) > max_ws: ws = ".." + ws[-(max_ws - 2):] status = f" {ws} \u2502{mode}\u2502{hints}" - attr = curses.color_pair(self.C_STATUS) + attr = curses.color_pair(self.C_STATUS) | curses.A_BOLD stdscr.addstr(y, 0, status[:self.w], attr) # ---- input handling --------------------------------------------------- From d4ec96ff62e5bd8df7a63c06dcef6550fb86197d Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 12 May 2026 16:28:07 +0700 Subject: [PATCH 07/16] Pass stdscr to _submit for realtime screen updates during processing --- scripts/tui.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/tui.py b/scripts/tui.py index 33c3145..7004017 100644 --- a/scripts/tui.py +++ b/scripts/tui.py @@ -228,7 +228,7 @@ class HendrikTUI: def _handle_key(self, stdscr, key): if key == 4: - self._submit() + self._submit(stdscr) elif key == 23: self._workspace_popup(stdscr) elif key == 3: @@ -308,7 +308,7 @@ class HendrikTUI: "time": datetime.now().strftime("%H:%M"), }) - def _submit(self): + def _submit(self, stdscr): query = "\n".join(self.input_buffer).strip() if not query: return @@ -319,12 +319,16 @@ class HendrikTUI: 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) @@ -340,21 +344,27 @@ class HendrikTUI: 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"] - targs = tc["function"]["arguments"] self._log("system", f" \u2192 {tname}") self.scroll = 999999 + self._draw(stdscr) + stdscr.refresh() self._execute_tool(tc) else: - self.messages.append({ - "role": "assistant", - "content": response.content, - }) - self._log("ai", response.content) + 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.") From 7bda512be6a3619137d241e4fd124a48c2149793 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 09:26:35 +0700 Subject: [PATCH 08/16] Add ntro timing utility and integrate into agent loop --- scripts/ntro.py | 12 +++++ tui/agent.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 scripts/ntro.py create mode 100644 tui/agent.py diff --git a/scripts/ntro.py b/scripts/ntro.py new file mode 100644 index 0000000..05daa6b --- /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 + print(f"[ntropy] {elapsed_ms:.2f}ms") + stamp["_start"] = time.perf_counter_ns() + return elapsed_ms + diff --git a/tui/agent.py b/tui/agent.py new file mode 100644 index 0000000..52af6b8 --- /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 LLM...") + 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), + }) From b819e871a473ab64e329e571904cfc1a9c570b90 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 10:28:06 +0700 Subject: [PATCH 09/16] Change 'LLM...' to 'Thinking...' in step log message --- tui/agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/agent.py b/tui/agent.py index 52af6b8..22fa9be 100644 --- a/tui/agent.py +++ b/tui/agent.py @@ -42,7 +42,7 @@ def submit(app, stdscr): for step in range(app.agent_max_iterations): stamp_step = ntro.start() - log(app, "system", f" step {step + 1} \u2014 LLM...") + log(app, "system", f" step {step + 1} \u2014 Thinking...") app.scroll = 999999 draw(app, stdscr) stdscr.refresh() From de89ad270658113b95d541e52f6315cbcb0c598e Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 10:35:14 +0700 Subject: [PATCH 10/16] 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) From 1c6fdf2a4003495d2421b95a04eb586edb632cc3 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 10:35:18 +0700 Subject: [PATCH 11/16] Add ntro timing utility and integrate into agent loop --- scripts/ntro.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/ntro.py b/scripts/ntro.py index 05daa6b..6751cf2 100644 --- a/scripts/ntro.py +++ b/scripts/ntro.py @@ -1,12 +1,12 @@ import time def start(): - return {"_start": time.perf_counter_ns()} + return {"start": time.perf_counter_ns()} def end(stamp): - elapsed_ns = time.perf_counter_ns() - stamp["_start"] + elapsed_ns = time.perf_counter_ns() - stamp["start"] elapsed_ms = elapsed_ns / 1_000_000 - print(f"[ntropy] {elapsed_ms:.2f}ms") - stamp["_start"] = time.perf_counter_ns() + stamp["start"] = time.perf_counter_ns() # For start with multi end + # print(f"[ntropy] {elapsed_ms:.2f}ms") return elapsed_ms From 5e8976405b35503c69cfc2d3ecf8541c263fa64a Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 10:35:31 +0700 Subject: [PATCH 12/16] Add hendrik wrapper script for terminal command --- hendrik | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 hendrik 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" "$@" From 1f036e06a2ebbeec6201283cbd220fcbffd79028 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 11:38:13 +0700 Subject: [PATCH 13/16] Always call handle_key to allow scroll during processing --- tui/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tui/app.py b/tui/app.py index 947b8ff..bff4e12 100644 --- a/tui/app.py +++ b/tui/app.py @@ -65,5 +65,4 @@ class HendrikTUI: key = stdscr.getch() except KeyboardInterrupt: break - if not self.processing: - handle_key(self, stdscr, key) + handle_key(self, stdscr, key) From 46bf99b0abe61fb9ae080728832bd65d145ada8e Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 11:38:23 +0700 Subject: [PATCH 14/16] Improve chat rendering: word wrap, label on separate line, blank line separators --- tui/render.py | 144 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 43 deletions(-) diff --git a/tui/render.py b/tui/render.py index 3ad3dbe..b92555f 100644 --- a/tui/render.py +++ b/tui/render.py @@ -4,6 +4,7 @@ import curses import os +import textwrap # -- Color pair IDs (id 1-9, id 0 = default curses) -- C_HEADER = 1 # header bar: biru @@ -70,32 +71,62 @@ def draw_chat(app, stdscr): return # Render log ke list of (color, text) agar scroll calculation akurat + # Setiap baris di-wrap sesuai lebar terminal rendered = [] - for item in app.log: + + 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 - label = "" - color = None + + # 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']}) " - color = C_USER + rendered.append((C_USER, label)) + _wrap_render(text, indent=1, color=C_INPUT) elif role == "ai": label = f" Hendrik ({item['time']}) " - color = C_AI + rendered.append((C_AI, label)) + _wrap_render(text, indent=3, color=C_INPUT) elif role == "system": - label = " \u25e6 " - color = C_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 " - 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)) + 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) @@ -126,12 +157,15 @@ def draw_chat(app, stdscr): def draw_input(app, stdscr): - # Kotak input multi-line (maks 6 baris). + # 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 [ @@ -143,51 +177,75 @@ def draw_input(app, stdscr): except curses.error: pass - # Scroll input horizontal jika buffer > 6 baris - total = len(app.input_buffer) - if total <= 6: + # 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(app.input_line - 3, total - 6)) + show = max(0, min(cur_visual - 3, total_visual - 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] + 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 + # 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)) + # 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 - else: + + # 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, 2, " " * (w - 4), border_attr) + 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 - - # 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) From 475e900e186740df582cfc980ea641104edb3d6b Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 11:38:28 +0700 Subject: [PATCH 15/16] Add soft word wrap for input area and improve navigation --- tui/input.py | 83 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 25 deletions(-) diff --git a/tui/input.py b/tui/input.py index 0fe384b..f4aa532 100644 --- a/tui/input.py +++ b/tui/input.py @@ -7,20 +7,65 @@ 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 -- - if key == 4: # Ctrl+D → submit query ke LLM + 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 == 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 -- + # -- Enter: split logical line at cursor position -- elif key in (curses.KEY_ENTER, 10, 13): - app.input_buffer.insert(app.input_line + 1, "") + 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 @@ -40,17 +85,15 @@ def handle_key(app, stdscr, key): # -- 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]) - ) + 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 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]) - ) + 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 @@ -68,16 +111,6 @@ def handle_key(app, stdscr, key): 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] From 7777dea0ccd0f98d07de9d9a6815eed6234d7477 Mon Sep 17 00:00:00 2001 From: Dita Aji Pratama Date: Tue, 19 May 2026 14:50:53 +0700 Subject: [PATCH 16/16] Align AI text indent with user and add bold to colored chat text --- tui/render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tui/render.py b/tui/render.py index b92555f..c14458e 100644 --- a/tui/render.py +++ b/tui/render.py @@ -115,7 +115,7 @@ def draw_chat(app, stdscr): elif role == "ai": label = f" Hendrik ({item['time']}) " rendered.append((C_AI, label)) - _wrap_render(text, indent=3, color=C_INPUT) + _wrap_render(text, indent=1, color=C_INPUT) elif role == "system": lines = text.split("\n") rendered.append((C_SYSTEM, lines[0])) @@ -146,7 +146,7 @@ def draw_chat(app, stdscr): 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 + attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL if len(text) >= w: text = text[: w - 1] try: