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