hendrik/tui/render.py

215 lines
7.0 KiB
Python

# 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 ─────── <model> "
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)