# 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)