This commit is contained in:
Dita Aji Pratama 2026-08-13 11:38:34 +07:00
parent aa4d6dd811
commit 4572fe6b60
7 changed files with 784 additions and 107 deletions

148
interfaces/tui/actions.py Normal file
View File

@ -0,0 +1,148 @@
# actions.py — Registry aksi TUI (single source of truth).
#
# Semua aksi didefinisikan satu kali di sini, lengkap dengan:
# * shortcut key langsung (dipakai oleh dispatch global)
# * spesifikasi item menu (dipakai oleh menubar)
# * handler & predikat enabled
# Dengan begitu shortcut dan menu selalu sinkron; developer cukup
# menambah/edit satu entri untuk fitur baru.
from .keycodes import (
KEY_CTRL_C,
KEY_CTRL_ENTER,
KEY_CTRL_N,
KEY_CTRL_O,
KEY_ESC,
KEY_F2,
KEY_F4,
KEY_F6,
)
from .input import (
new_session_popup,
session_browser_popup,
rename_popup,
model_selector_popup,
workspace_popup,
)
from .agent import submit, log
# ---------------------------------------------------------------- handlers ---
def _exit_app(app, stdscr):
app.running = False
def _cancel_inference(app, stdscr):
app.llm.cancel_requested = True
app.agent_done.set()
log(app, "system", " Inference cancelled by user")
def _send_prompt(app, stdscr):
submit(app, stdscr)
def _ready(app):
return not app.processing
# ------------------------------------------------------------- action spec ---
# Tiap aksi: key, label, mnemonic, shortcut (teks), handler, enabled.
ACTIONS = {
"new_session": {
"key": KEY_CTRL_N,
"label": "New Session", "mnemonic": "N", "shortcut": "Ctrl+N",
"handler": new_session_popup, "enabled": _ready,
},
"open_session": {
"key": KEY_CTRL_O,
"label": "Open Session", "mnemonic": "O", "shortcut": "Ctrl+O",
"handler": session_browser_popup, "enabled": _ready,
},
"rename_session": {
"key": KEY_F2,
"label": "Rename Session", "mnemonic": "R", "shortcut": "F2",
"handler": rename_popup,
"enabled": lambda app: _ready(app) and app.current_session is not None,
},
"exit_app": {
"key": KEY_CTRL_C,
"label": "Exit", "mnemonic": "E", "shortcut": "Ctrl+C",
"handler": _exit_app, "enabled": lambda app: True,
},
"send_prompt": {
"key": KEY_CTRL_ENTER,
"label": "Send Prompt", "mnemonic": "P", "shortcut": "Ctrl+Enter",
"handler": _send_prompt, "enabled": _ready,
},
"cancel_inference": {
"key": KEY_ESC,
"label": "Cancel Inference", "mnemonic": "C", "shortcut": "Esc",
"handler": _cancel_inference, "enabled": lambda app: app.processing,
},
"select_model": {
"key": KEY_F4,
"label": "Select Model", "mnemonic": "S", "shortcut": "F4",
"handler": model_selector_popup, "enabled": _ready,
},
"change_workspace": {
"key": KEY_F6,
"label": "Change Workspace", "mnemonic": "C", "shortcut": "F6",
"handler": workspace_popup, "enabled": _ready,
},
}
# ---------------------------------------------------------------- menu bar ---
def _item(action_id: str) -> dict:
item = dict(ACTIONS[action_id])
item["id"] = action_id
return item
# Struktur menu bar. Urutan & mnemonic sesuai spesifikasi:
# Alt+S → [S]ession, Alt+A → [A]gent, Alt+M → [M]odel, Alt+W → [W]orkspace
MENUS = [
{
"name": "Session",
"mnemonic": "S",
"items": [
_item("new_session"),
_item("open_session"),
_item("rename_session"),
"sep",
_item("exit_app"),
],
},
{
"name": "Agent",
"mnemonic": "A",
"items": [
_item("send_prompt"),
_item("cancel_inference"),
],
},
{
"name": "Model",
"mnemonic": "M",
"items": [
_item("select_model"),
],
},
{
"name": "Workspace",
"mnemonic": "W",
"items": [
_item("change_workspace"),
],
},
]
# ------------------------------------------------------------------ execute ---
def perform(app, stdscr, action_id: str):
# Jalankan aksi; abaikan jika disabled (mis. aksi READY-only saat processing).
spec = ACTIONS.get(action_id)
if not spec or not spec["enabled"](app):
return
spec["handler"](app, stdscr)

View File

@ -3,8 +3,10 @@ import json
import threading
from datetime import datetime
import config
from .render import init_colors, draw
from .theme import init_colors
from .render import draw
from .input import handle_key
from . import keycodes, menubar
from .agent import log, WELCOME_ART
from services.session_manager_neo import NeoSessionManager, NeoSession
from lib import ragroleplay
@ -32,6 +34,13 @@ class HendrikTUI:
self.running = True
self.h, self.w = 0, 0
# State menu bar
self.menu_active = False # menu bar sedang fokus (F10)
self.menu_open = False # dropdown menu sedang terbuka
self.active_menu = 0 # index menu aktif (0-3)
self.menu_sel = 0 # index item yang diseleksi di dropdown
self.cursor_yx = None # posisi kursor input (untuk restore)
self.agent_thread: threading.Thread | None = None
self.agent_done = threading.Event()
@ -163,6 +172,7 @@ class HendrikTUI:
init_colors()
stdscr.keypad(True)
curses.raw() # Ctrl+C sebagai key code 3, bukan SIGINT → KeyboardInterrupt
curses.set_escdelay(keycodes.ESC_PEEK_MS) # ESC/Alt/F-key langsung diproses, tidak menunggu 1s
stdscr.refresh()
self.messages = [{"role": "system", "content": self.build_system_prompt(
@ -187,13 +197,11 @@ class HendrikTUI:
draw(self, stdscr)
curses.curs_set(2)
if self.processing:
stdscr.timeout(100)
else:
stdscr.timeout(-1)
timeout_ms = 100 if self.processing else -1
stdscr.timeout(timeout_ms)
try:
key = stdscr.getch()
key = keycodes.read_key(stdscr, timeout_ms)
except KeyboardInterrupt:
if self.processing:
self.llm.cancel_requested = True
@ -202,7 +210,17 @@ class HendrikTUI:
break
key = -1
handle_key(self, stdscr, key)
# Routing key:
# 1. menu bar fokus → navigasi menu bar
# 2. shortcut global → aksi fitur (Ctrl+N, F2, F4, F6, ...)
# 3. mnemonic Alt+ → fokus menu
# 4. sisanya → editing input
if self.menu_active:
menubar.handle_menubar_key(self, stdscr, key)
else:
if not menubar.dispatch_global_key(self, stdscr, key):
if not menubar.handle_alt_menu_key(self, stdscr, key):
handle_key(self, stdscr, key)
if self.agent_done.is_set():
self.agent_thread.join()

View File

@ -1,11 +1,14 @@
# input.py — Keyboard handling dan workspace popup.
# handle_key() adalah dispatch besar yang menerjemahkan
# key code curses menjadi aksi pada state app.
# input.py — Editing input teks & semua popup TUI.
#
# handle_key() hanya menangani key editing (ketik, hapus, navigasi baris,
# scroll chat, dan beberapa shortcut non-menu seperti Ctrl+L / Ctrl+F / Ctrl+X).
# Shortcut fitur utama (Ctrl+N, F2, F4, F6, Ctrl+C, Ctrl+Enter, Esc)
# dirutekan oleh menubar.py → actions.py.
import curses
import os
import config
from .agent import submit, log
from .agent import log
from tools.coder import set_current_workspace
@ -37,50 +40,22 @@ def handle_key(app, stdscr, key):
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) --
# Check for Ctrl+C (key 3) and also potential curses representation of Ctrl+C
if key == 3 or key == 26: # 3 is Ctrl+C, 26 is Ctrl+Z (sometimes mapped)
if processing:
app.llm.cancel_requested = True
log(app, "system", " Stream cancelled by user")
else:
app.running = False
elif key == curses.KEY_PPAGE:
# -- Scroll & resize --
if 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
# -- Ctrl shortcuts --
elif key == 4: # Ctrl+D → submit query ke LLM
if not processing:
submit(app, stdscr)
elif key == 23: # Ctrl+W → popup ganti workspace
workspace_popup(app, stdscr)
# -- Shortcut non-menu (tidak masuk menu bar) --
elif key == 12: # Ctrl+L → clear chat log
app.log.clear()
elif key == 5: # Ctrl+E → model selector popup
if not processing:
model_selector_popup(app, stdscr)
# -- Session management shortcuts --
elif key == 14: # Ctrl+N → new session
if not processing:
new_session_popup(app, stdscr)
elif key == 15: # Ctrl+O → open session browser
if not processing:
session_browser_popup(app, stdscr)
elif key == 6: # Ctrl+F → search sessions
if not processing:
if not app.processing:
session_search_popup(app, stdscr)
elif key == 18: # Ctrl+R → rename current session
if not processing:
rename_popup(app, stdscr)
elif key == 24: # Ctrl+X → delete current session
if not processing:
if not app.processing:
delete_session_popup(app, stdscr)
# -- Enter: split logical line at cursor position --

205
interfaces/tui/keycodes.py Normal file
View File

@ -0,0 +1,205 @@
# keycodes.py — Konstanta key code & pembaca key untuk TUI.
#
# Terminologi:
# * Ctrl+<huruf> → kode ASCII murni (Ctrl+N = 14, dst).
# * F2/F4/F6/F10 → konstanta curses (KEY_F*).
# * Ctrl+Enter → tidak punya kode ASCII di terminal biasa, sehingga
# dibaca via read_key() dari escaped sequence CSI-u
# (misal "\x1b[13;5u" di kitty/foot/terminal modern,
# atau "\x1b[13;5~" di xterm modifyOtherKeys).
# * Alt+<huruf> → terminal mengirim "\x1b <huruf>"; read_key() menggabungkan
# keduanya menjadi kode sintetis KEY_ALT(<huruf>).
# Fallback CSI-u: "\x1b[<ascii>;3u" (kitty protocol).
#
# read_key() adalah pembungkus getch() yang menormalkan semua representasi
# escape sequence antar-terminal (xterm, kitty, foot, iTerm, WezTerm, dll)
# menjadi satu set kode yang bisa dibandingkan di actions.py.
import curses
import re
# Waktu (ms) menunggu byte lanjutan setelah ESC di read_key()
ESC_PEEK_MS = 30
# -- ASCII control codes (nilai = chr & 0x1f) --
KEY_CTRL_C = 3
KEY_CTRL_D = 4
KEY_CTRL_E = 5
KEY_CTRL_F = 6
KEY_CTRL_L = 12
KEY_CTRL_N = 14
KEY_CTRL_O = 15
KEY_CTRL_R = 18
KEY_CTRL_W = 23
KEY_CTRL_X = 24
# -- Tombol lain yang sudah punya representasi curses --
KEY_ESC = 27
KEY_ENTER = curses.KEY_ENTER
KEY_BACKSPACE = curses.KEY_BACKSPACE
KEY_UP = curses.KEY_UP
KEY_DOWN = curses.KEY_DOWN
KEY_LEFT = curses.KEY_LEFT
KEY_RIGHT = curses.KEY_RIGHT
KEY_PPAGE = curses.KEY_PPAGE
KEY_NPAGE = curses.KEY_NPAGE
KEY_HOME = curses.KEY_HOME
KEY_END = curses.KEY_END
KEY_INSERT = curses.KEY_IC
KEY_DC = curses.KEY_DC
KEY_F1 = curses.KEY_F1
KEY_F2 = curses.KEY_F2
KEY_F3 = curses.KEY_F3
KEY_F4 = curses.KEY_F4
KEY_F5 = curses.KEY_F5
KEY_F6 = curses.KEY_F6
KEY_F7 = curses.KEY_F7
KEY_F8 = curses.KEY_F8
KEY_F9 = curses.KEY_F9
KEY_F10 = curses.KEY_F10
KEY_F11 = curses.KEY_F11
KEY_F12 = curses.KEY_F12
# -- Kode sintetis untuk kombinasi tanpa representasi asli --
_KEY_SYNTH_BASE = 3000
KEY_CTRL_ENTER = _KEY_SYNTH_BASE + 1 # hasil parse CSI-u dari read_key()
# Alt+<huruf>: kode sintetis = _KEY_ALT_BASE + ord(huruf)
_KEY_ALT_BASE = _KEY_SYNTH_BASE + 32
def KEY_ALT(ch):
return _KEY_ALT_BASE + ord(ch)
KEY_ALT_S = KEY_ALT("s")
KEY_ALT_A = KEY_ALT("a")
KEY_ALT_M = KEY_ALT("m")
KEY_ALT_W = KEY_ALT("w")
# Kode CSI untuk F-keys & navigasi (xterm / kitty compatible)
_CSI_F_KEYS = {
"11": KEY_F1, "12": KEY_F2, "13": KEY_F3, "14": KEY_F4,
"15": KEY_F5, "17": KEY_F6, "18": KEY_F7, "19": KEY_F8,
"20": KEY_F9, "21": KEY_F10, "23": KEY_F11, "24": KEY_F12,
}
_CSI_NAV = {
"A": KEY_UP, "B": KEY_DOWN, "C": KEY_RIGHT, "D": KEY_LEFT,
"H": KEY_HOME, "F": KEY_END,
"1~": KEY_HOME, "4~": KEY_END,
"2~": KEY_INSERT, "3~": KEY_DC,
"5~": KEY_PPAGE, "6~": KEY_NPAGE,
}
def _csi_mod(mod_str: str) -> int:
# Modifier CSI-u: 1=default, 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl,
# 6=Shift+Ctrl, 7=Alt+Ctrl, 8=Shift+Alt+Ctrl.
# Ambil bit-nya: (mod - 1): bit0=Shift, bit1=Alt, bit2=Ctrl.
mod = int(mod_str) if mod_str else 1
bits = mod - 1
return {
"shift": bool(bits & 1),
"alt": bool(bits & 2),
"ctrl": bool(bits & 4),
}
def _parse_csi(data: bytes) -> int:
# data contoh:
# b'[12~' → F2 b'[21~' → F10
# b'[13;5u' → Ctrl+Enter b'[13;5~' → Ctrl+Enter
# b'[115;3u' → Alt+S (kitty) b'[115;5u' → Ctrl+S
# b'[A' / b'[B' → panah atas / bawah
try:
text = data.decode("ascii", "ignore")
except Exception:
return KEY_ESC
# Navigasi sederhana (kalau curses gagal menterjemahkan)
if text[1:] in _CSI_NAV:
return _CSI_NAV[text[1:]]
m = re.match(r"^\[(\d+)(?:;(\d+))?([u~])$", text)
if not m:
# Navigasi dengan prefiks angka (misal [1;5A = Ctrl+Up)
m2 = re.match(r"^\[(?:\d+;)?(\d*)([ABCDHF])$", text)
if m2 and m2.group(2) in _CSI_NAV:
return _CSI_NAV[m2.group(2)]
return KEY_ESC
code = int(m.group(1))
mod = _csi_mod(m.group(2))
terminator = m.group(3)
# F1-F12 dengan/tanpa modifier
if terminator in ("u", "~") and str(code) in _CSI_F_KEYS:
return _CSI_F_KEYS[str(code)]
# Enter + Ctrl → Ctrl+Enter
if code == 13 and mod["ctrl"] and terminator in ("u", "~"):
return KEY_CTRL_ENTER
# CSI-u untuk tombol printable (kitty protocol):
# Ctrl+<huruf> → kode ASCII murni (misal 's' → 19)
# Alt+<huruf> → KEY_ALT(<huruf>)
if terminator == "u" and 32 <= code <= 126:
ch = chr(code).lower()
if mod["ctrl"]:
if 97 <= ord(ch) <= 122:
return ord(ch) & 0x1f
return KEY_ESC
if mod["alt"]:
return KEY_ALT(ch)
return KEY_ESC
return KEY_ESC
def read_key(stdscr, timeout_ms: int = -1) -> int:
# Baca satu key dari stdscr. Jika ESC muncul, cek apakah itu:
# * ESC murni → kembalikan KEY_ESC
# * Alt+<huruf> → kembalikan KEY_ALT(<huruf>)
# * pembuka CSI seq → parse (F-keys, Ctrl+Enter, Alt+<huruf>, dst.)
key = stdscr.getch()
if key != KEY_ESC:
return key
# Nodelay sementara: tunggu sebentar byte lanjutan (kalau sequence-nya
# terpecah antar-read di terminal lambat/SSH), sisanya ESC murni.
stdscr.timeout(ESC_PEEK_MS)
try:
nxt = stdscr.getch()
finally:
stdscr.timeout(timeout_ms)
if nxt == -1:
return KEY_ESC
if nxt == ord("["):
# CSI sequence: kumpulkan sampai byte final (0x40..0x7e).
seq = [nxt]
while True:
c = stdscr.getch()
if c == -1:
break
seq.append(c)
if 0x40 <= c <= 0x7e:
break
return _parse_csi(bytes(seq))
if 32 <= nxt <= 126:
return KEY_ALT(chr(nxt)) # Alt+<huruf> = "\x1b <huruf>"
return KEY_ESC
def is_alt_key(key: int) -> bool:
return _KEY_ALT_BASE <= key <= _KEY_ALT_BASE + 126
def alt_key_char(key: int) -> str | None:
if not is_alt_key(key):
return None
return chr(key - _KEY_ALT_BASE).lower()

292
interfaces/tui/menubar.py Normal file
View File

@ -0,0 +1,292 @@
# menubar.py — Menu bar TUI: dispatch shortcut & interaksi menu.
#
# Bertanggung jawab atas:
# * dispatch shortcut langsung (Ctrl+N, F2, F4, F6, Ctrl+C, Ctrl+Enter, Esc)
# * aktivasi menu bar (F10) dan mnemonic (Alt+S/A/M/W)
# * navigasi dropdown menu (panah / Enter / Esc / huruf mnemonic)
# * menggambar menu bar dan dropdown-nya
# Semua definisi aksi/menu tinggal di actions.py — module ini hanya memakainya.
import curses
from . import theme
from .actions import ACTIONS, MENUS, perform
from .keycodes import (
alt_key_char,
KEY_ESC,
)
# Peta mnemonic menu (huruf kecil) → index menu
ALT_MENU = {m["mnemonic"].lower(): i for i, m in enumerate(MENUS)}
# ---------------------------------------------------- shortcut global (F10 dll) ---
def _global_bindings():
for action_id, spec in ACTIONS.items():
yield spec["key"], action_id, spec["enabled"]
def dispatch_global_key(app, stdscr, key) -> bool:
# Rutekan shortcut langsung. Mengembalikan True jika key dikonsumsi
# (sekalipun aksi disabled → no-op), False jika bukan shortcut global.
# F10: aktifkan/tutup menu bar, langsung buka dropdown menu pertama
# supaya ada feedback visual seketika.
if key == curses.KEY_F10:
if app.menu_active:
deactivate_menu(app)
else:
activate_menu(app, 0, open_dropdown=True)
return True
for shortcut, action_id, enabled in _global_bindings():
if shortcut == key:
if enabled(app):
perform(app, stdscr, action_id)
return True
return False
# ----------------------------------------------------------- mnemonic Alt+ ---
def alt_key_to_menu(key: int) -> int | None:
ch = alt_key_char(key)
if ch is None:
return None
return ALT_MENU.get(ch)
def handle_alt_menu_key(app, stdscr, key) -> bool:
# Alt+S / Alt+A / Alt+M / Alt+W → fokus menu terkait + buka dropdown.
menu_idx = alt_key_to_menu(key)
if menu_idx is None:
return False
activate_menu(app, menu_idx, open_dropdown=True)
return True
# -------------------------------------------------------------- state helper ---
def _selectable_indices(menu) -> list[int]:
return [i for i, item in enumerate(menu["items"]) if item != "sep"]
def _first_usable(app, menu) -> int:
sel = _selectable_indices(menu)
for i in sel:
if menu["items"][i]["enabled"](app):
return i
return sel[0] if sel else -1
def activate_menu(app, menu_idx: int, open_dropdown: bool = False):
app.menu_active = True
app.active_menu = menu_idx
app.menu_sel = _first_usable(app, MENUS[menu_idx])
app.menu_open = open_dropdown
def deactivate_menu(app):
app.menu_active = False
app.menu_open = False
# ------------------------------------------------------------- menubar nav ---
def _move_horizontal(app, delta: int):
total = len(MENUS)
app.active_menu = (app.active_menu + delta) % total
app.menu_open = False
app.menu_sel = _first_usable(app, MENUS[app.active_menu])
def _move_vertical(app, delta: int):
if not app.menu_open:
app.menu_open = True
return
sel = _selectable_indices(MENUS[app.active_menu])
if not sel:
return
pos = sel.index(app.menu_sel) if app.menu_sel in sel else 0
pos = (pos + delta) % len(sel)
app.menu_sel = sel[pos]
def _item_by_mnemonic(app, key: int) -> int | None:
ch = chr(key).lower() if 32 <= key <= 126 else None
if not ch:
return None
menu = MENUS[app.active_menu]
for i, item in enumerate(menu["items"]):
if item != "sep" and item.get("mnemonic"):
if item["mnemonic"].lower() == ch:
return i
return None
def _select_current(app, stdscr):
menu = MENUS[app.active_menu]
item = menu["items"][app.menu_sel]
if item == "sep" or not item["enabled"](app):
return
deactivate_menu(app)
perform(app, stdscr, item["id"])
def handle_menubar_key(app, stdscr, key):
# Dipanggil hanya ketika app.menu_active == True.
alt_idx = alt_key_to_menu(key)
if alt_idx is not None:
activate_menu(app, alt_idx, open_dropdown=True)
return
if not app.menu_open:
if key in (curses.KEY_LEFT,):
_move_horizontal(app, -1)
elif key in (curses.KEY_RIGHT,):
_move_horizontal(app, 1)
elif key in (curses.KEY_DOWN, curses.KEY_UP, curses.KEY_ENTER, 10, 13):
app.menu_open = True
app.menu_sel = _first_usable(app, MENUS[app.active_menu])
elif key in (KEY_ESC, curses.KEY_F10):
deactivate_menu(app)
else:
# Huruf mnemonic → lompat ke menu tersebut (mis. ESC dulu, lalu 'M').
ch = chr(key).lower() if 32 <= key <= 126 else None
if ch is not None and ch in ALT_MENU:
activate_menu(app, ALT_MENU[ch], open_dropdown=True)
return
# Dropdown terbuka
if key in (curses.KEY_UP,):
_move_vertical(app, -1)
elif key in (curses.KEY_DOWN,):
_move_vertical(app, 1)
elif key in (curses.KEY_LEFT,):
_move_horizontal(app, -1)
elif key in (curses.KEY_RIGHT,):
_move_horizontal(app, 1)
elif key in (curses.KEY_ENTER, 10, 13):
_select_current(app, stdscr)
elif key == KEY_ESC:
app.menu_open = False
elif key == curses.KEY_F10:
deactivate_menu(app)
else:
item_idx = _item_by_mnemonic(app, key)
if item_idx is not None:
app.menu_sel = item_idx
_select_current(app, stdscr)
# ---------------------------------------------------------------- rendering ---
def _menu_x(menu_idx: int) -> int:
# Posisi kolom awal menu ke-menu_idx pada baris menubar.
x = 1
for idx, menu in enumerate(MENUS):
if idx == menu_idx:
return x
x += len(f" [{menu['mnemonic']}]{menu['name']} ")
return 1
def draw_menubar(app, stdscr):
y = 0
base_attr = curses.color_pair(theme.C_MENU) | curses.A_BOLD
active_attr = curses.color_pair(theme.C_MENU_ACTIVE)
try:
stdscr.addstr(y, 0, " " * app.w, base_attr)
except curses.error:
pass
x = 1
for idx, menu in enumerate(MENUS):
is_active = app.menu_active and idx == app.active_menu
attr = active_attr if is_active else base_attr
text = f" [{menu['mnemonic']}]{menu['name']} "
try:
stdscr.addstr(y, x, text, attr)
except curses.error:
pass
x += len(text)
model = f" {app.llm.model} "
try:
stdscr.addstr(y, max(0, app.w - len(model)), model, base_attr)
except curses.error:
pass
def draw_menu_dropdown(app, stdscr):
menu = MENUS[app.active_menu]
items = menu["items"]
max_label = 0
for item in items:
if item != "sep":
max_label = max(max_label, len(item["label"]) + len(item.get("shortcut", "")))
bw = min(max(max_label + 10, 18), app.w - 2)
bh = min(len(items) + 2, app.h - 2)
if bh < 3:
return
px = min(_menu_x(app.active_menu), max(0, app.w - bw - 1))
py = 1
win = curses.newwin(bh, bw, py, px)
try:
win.box()
except curses.error:
pass
for i, item in enumerate(items):
y = 1 + i
if y >= bh - 1:
break
if item == "sep":
try:
win.addstr(y, 1, "\u2500" * max(0, bw - 2),
curses.color_pair(theme.C_MENU_DISABLED))
except curses.error:
pass
continue
selected = (i == app.menu_sel)
enabled = item["enabled"](app)
if selected and enabled:
attr = curses.color_pair(theme.C_MENU_SEL)
elif selected:
attr = curses.color_pair(theme.C_MENU_SEL) | curses.A_DIM
elif enabled:
attr = curses.color_pair(theme.C_MENU_DROPDOWN)
else:
attr = curses.color_pair(theme.C_MENU_DISABLED)
label = item["label"]
shortcut = item.get("shortcut", "")
mnemonic = item.get("mnemonic")
pos = label.upper().find(mnemonic.upper()) if mnemonic else -1
try:
x = 1
if pos > 0:
win.addstr(y, x, label[:pos], attr)
x += pos
if pos >= 0:
win.addstr(y, x, label[pos], attr | curses.A_UNDERLINE)
x += 1
if pos >= 0:
win.addstr(y, x, label[pos + 1:], attr)
x += len(label) - pos - 1
else:
win.addstr(y, x, label, attr)
x += len(label)
# Shortcut rata kanan
sx = bw - len(shortcut) - 2
if sx > x:
win.addstr(y, x, " " * (sx - x), attr)
win.addstr(y, sx, shortcut, attr)
except curses.error:
pass
win.refresh()

View File

@ -1,89 +1,67 @@
# render.py — Semua fungsi drawing / tampilan curses.
# Setiap fungsi menerima `app` (instance HendrikTUI) dan `stdscr`
# lalu membaca state dari `app` untuk menggambar di layar.
#
# Warna didefinisikan terpusat di theme.py; menu bar dirender oleh menubar.py.
import curses
import json
from tools.coder import get_current_workspace
# -- 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
from . import theme
from .menubar import draw_menubar, draw_menu_dropdown
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_BLACK, curses.COLOR_WHITE)
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
# Alias warna agar kode drawing di bawah tetap ringkas.
C_USER = theme.C_USER
C_AI = theme.C_AI
C_SYSTEM = theme.C_SYSTEM
C_INPUT = theme.C_INPUT
C_STATUS_READY = theme.C_STATUS_READY
C_STATUS_PROC = theme.C_STATUS_PROC
C_ERROR = theme.C_ERROR
C_INPUT_BORDER = theme.C_INPUT_BORDER
C_STATUS_INFO = theme.C_STATUS_INFO
C_HINT_DISABLED = theme.C_HINT_DISABLED
C_WELCOME = theme.C_WELCOME
C_TOOL_CALL = theme.C_TOOL_CALL
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.
# Panggil kelima fungsi gambar secara berurutan.
# Urutan penting: input digambar hampir terakhir supaya kursor
# bisa dipindah di atas layer paling atas; dropdown menu digambar
# paling akhir sebagai overlay di atas area chat.
draw_header(app, stdscr)
draw_chat(app, stdscr)
draw_status(app, stdscr)
draw_input(app, stdscr)
# Flush stdscr dulu. Tanpa ini, getch() di read_key() akan otomatis
# me-refresh stdscr yang sedang modified dan menimpa dropdown,
# sehingga dropdown tidak pernah terlihat.
stdscr.refresh()
if app.menu_active and app.menu_open:
draw_menu_dropdown(app, stdscr)
# Pulihkan posisi kursor (dropdown menimpa input cursor sementara)
if getattr(app, "cursor_yx", None):
try:
stdscr.move(*app.cursor_yx)
except curses.error:
pass
def draw_header(app, stdscr):
# Baris 1: " {app.character_name} AI Agent ─────── <model> "
w = app.w
name = f" {app.character_name} AI Agent "
model = f" {app.llm.model} "
# Perbaiki kalkulasi agar line1 tepat mengisi w kolom
mid = w - len(model) - 1
pad = max(1, mid - len(name) - 1)
line1 = name + "\u2500" * pad + " " + model
# Gunakan ljust(w) untuk memastikan background biru penuh sampai ujung kanan
full_line1 = line1.ljust(w)
attr1 = curses.color_pair(C_HEADER) | curses.A_BOLD
stdscr.addstr(0, 0, full_line1[:w], attr1)
# Baris 1: menu bar (dengan mnemonic + model di kanan)
draw_menubar(app, stdscr)
# Baris 2: Shortcut hints
# Tampilkan hanya shortcut yang aktif sesuai status processing
# Baris 2: shortcut hints kontekstual sesuai status processing
if app.processing:
# Hanya ^C (cancel) yang aktif saat processing
hints = " ^C:cancel "
hints = " F10:menu Esc:cancel "
else:
# Semua shortcut aktif saat READY
hints = " ^N:new ^O:open ^R:rename ^D:send ^E:model ^W:workspace ^C:exit "
# Align left and fill the rest of the width with spaces to keep background color
full_line = hints.ljust(w)
# Menggunakan C_HINT_DISABLED untuk warna abu-abu cerah
hints = (" F10:menu ^Enter:send ^N:new ^O:open F2:rename "
"F4:model F6:ws ^C:exit ")
full_line = hints.ljust(app.w)
attr2 = curses.color_pair(C_HINT_DISABLED)
stdscr.addstr(1, 0, full_line[:w], attr2)
stdscr.addstr(1, 0, full_line[:app.w], attr2)
def draw_chat(app, stdscr):
@ -367,10 +345,13 @@ def draw_input(app, stdscr):
pass
if cursor_yx:
app.cursor_yx = cursor_yx
try:
stdscr.move(*cursor_yx)
except curses.error:
pass
else:
app.cursor_yx = None
def draw_status(app, stdscr):

58
interfaces/tui/theme.py Normal file
View File

@ -0,0 +1,58 @@
# theme.py — Registrasi color pair curses.
# Satu-satunya tempat definisi warna untuk seluruh TUI,
# agar render.py dan menubar.py memakai skema yang konsisten.
import curses
# -- 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
# -- Menu bar colors (id 17+) --
C_MENU = 17 # background menubar (sama dengan header)
C_MENU_ACTIVE = 18 # item menu yang sedang aktif (highlight)
C_MENU_DROPDOWN = 19 # item dropdown normal
C_MENU_SEL = 20 # item dropdown terseleksi
C_MENU_DISABLED = 21 # item non-aktif / separator
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_BLACK, curses.COLOR_WHITE)
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
# Menu bar
curses.init_pair(C_MENU, curses.COLOR_BLACK, curses.COLOR_BLUE)
curses.init_pair(C_MENU_ACTIVE, curses.COLOR_WHITE, curses.COLOR_BLUE + 8)
curses.init_pair(C_MENU_DROPDOWN, curses.COLOR_WHITE, -1)
curses.init_pair(C_MENU_SEL, curses.COLOR_BLACK, curses.COLOR_CYAN)
curses.init_pair(C_MENU_DISABLED, 8, -1) # abu-abu