348 lines
13 KiB
Python
348 lines
13 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 json
|
|
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
|
|
C_HINT_DISABLED = 13 # hint disabled (abu-abu)
|
|
C_WELCOME = 14 # welcome art: light blue
|
|
C_TOOL_CALL = 15 # tool call: kuning terang
|
|
C_TOOL_RESULT = 16 # tool result: magenta muda
|
|
|
|
|
|
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_BLACK, curses.COLOR_GREEN + 8)
|
|
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)
|
|
curses.init_pair(C_HINT_DISABLED, 8, -1) # abu-abu di atas bg default
|
|
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
|
|
curses.init_pair(C_TOOL_CALL, curses.COLOR_YELLOW + 8, -1) # bright yellow
|
|
curses.init_pair(C_TOOL_RESULT, curses.COLOR_MAGENTA + 8, -1) # bright magenta
|
|
|
|
|
|
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 rows; setiap row = list of (color, text) segments.
|
|
# Ini memungkinkan satu baris punya multi-warna (misal label tool_call).
|
|
# Setiap baris di-wrap sesuai lebar terminal.
|
|
rendered = [] # list of list of (color, text)
|
|
|
|
def _add_row(segments):
|
|
# segments: list of (color, text)
|
|
rendered.append(segments)
|
|
|
|
def _add_blank():
|
|
rendered.append([(None, "")])
|
|
|
|
def _wrap_render(text, indent=0, color=C_INPUT):
|
|
available = w - indent - 1 # sisakan 1 kolom margin kanan
|
|
if available <= 0:
|
|
_add_row([(color, " " * indent)])
|
|
return
|
|
for line in text.split("\n"):
|
|
if not line:
|
|
_add_row([(color, " " * indent)])
|
|
continue
|
|
start = 0
|
|
while start < len(line):
|
|
chunk = line[start:start + available]
|
|
_add_row([(color, " " * indent + chunk)])
|
|
start += available
|
|
|
|
for idx, item in enumerate(app.log):
|
|
role, text = item["role"], item["text"]
|
|
if role == "sep":
|
|
_add_blank()
|
|
_add_blank()
|
|
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"):
|
|
_add_blank()
|
|
|
|
# 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"):
|
|
_add_blank()
|
|
|
|
# Tambah blank line sebelum ai response setelah system log
|
|
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system":
|
|
_add_blank()
|
|
|
|
if role == "user":
|
|
label = f" You ({item['time']}) "
|
|
_add_row([(C_USER, label)])
|
|
_wrap_render(text, indent=1, color=C_INPUT)
|
|
elif role == "ai":
|
|
label = f" Hendrik ({item['time']}) "
|
|
_add_row([(C_AI, label)])
|
|
_wrap_render(text, indent=1, color=C_INPUT)
|
|
elif role == "system":
|
|
lines = text.split("\n")
|
|
_add_row([(C_SYSTEM, lines[0])])
|
|
for line in lines[1:]:
|
|
_add_row([(C_SYSTEM, " " + line)])
|
|
elif role == "welcome":
|
|
lines = text.split("\n")
|
|
for line in lines:
|
|
_add_row([(C_WELCOME, " " + line)])
|
|
elif role == "tool_call":
|
|
# Format:
|
|
# (blank line)
|
|
# Hendrik run_bash (HH:MM) ← "Hendrik" hijau, "run_bash (HH:MM)" kuning
|
|
# { ← arguments indent 1 spasi, kuning
|
|
# "command": "ls -la"
|
|
# }
|
|
# (blank line)
|
|
# Blank line di atas (kecuali sebelumnya sudah blank dari role lain)
|
|
if idx > 0 and app.log[idx - 1]["role"] not in ("sep", "welcome"):
|
|
_add_blank()
|
|
try:
|
|
tc = json.loads(text)
|
|
tname = tc["name"]
|
|
targs_raw = tc["arguments"]
|
|
# Pretty-print arguments
|
|
try:
|
|
targs = json.loads(targs_raw) if isinstance(targs_raw, str) else targs_raw
|
|
args_str = json.dumps(targs, indent=2, ensure_ascii=False)
|
|
except Exception:
|
|
args_str = str(targs_raw)
|
|
|
|
# Label: "Hendrik" hijau + "tool_name" kuning + "(HH:MM)" hijau
|
|
_add_row([
|
|
(C_AI, " Hendrik "),
|
|
(C_TOOL_CALL, tname),
|
|
(C_AI, f" ({item['time']}) "),
|
|
])
|
|
for aline in args_str.split("\n"):
|
|
_add_row([(C_INPUT, " " + aline)])
|
|
except Exception:
|
|
_add_row([
|
|
(C_AI, " Hendrik "),
|
|
(C_TOOL_CALL, "unknown"),
|
|
(C_AI, f" ({item['time']}) "),
|
|
])
|
|
# Blank line di bawah
|
|
_add_blank()
|
|
elif role == "error":
|
|
label = " \u2717 "
|
|
lines = text.split("\n")
|
|
_add_row([(C_ERROR, label + (lines[0] if lines else ""))])
|
|
for line in lines[1:]:
|
|
_add_row([(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)):
|
|
segments = rendered[i]
|
|
x = 0
|
|
for color, text in segments:
|
|
if not text:
|
|
continue
|
|
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
|
|
remaining = w - x
|
|
if remaining <= 0:
|
|
break
|
|
display = text[:remaining]
|
|
try:
|
|
stdscr.addstr(y, x, display, attr)
|
|
except curses.error:
|
|
pass
|
|
x += len(display)
|
|
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 — always on the visual chunk that contains the cursor
|
|
if idx == cur_visual:
|
|
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:
|
|
try:
|
|
stdscr.move(*cursor_yx)
|
|
except curses.error:
|
|
pass
|
|
|
|
|
|
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}").ljust(w)[:w]
|
|
stdscr.addstr(y, 0, status, 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)
|
|
|
|
# ^D:send — abu-abu saat processing, bold putih saat idle
|
|
if app.processing:
|
|
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED))
|
|
else:
|
|
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD)
|