diff --git a/interfaces/tui/actions.py b/interfaces/tui/actions.py new file mode 100644 index 0000000..0afee3e --- /dev/null +++ b/interfaces/tui/actions.py @@ -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) diff --git a/interfaces/tui/app.py b/interfaces/tui/app.py index a23b31f..4ee5295 100644 --- a/interfaces/tui/app.py +++ b/interfaces/tui/app.py @@ -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() diff --git a/interfaces/tui/input.py b/interfaces/tui/input.py index b864466..035cc7e 100644 --- a/interfaces/tui/input.py +++ b/interfaces/tui/input.py @@ -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 -- diff --git a/interfaces/tui/keycodes.py b/interfaces/tui/keycodes.py new file mode 100644 index 0000000..241b7d1 --- /dev/null +++ b/interfaces/tui/keycodes.py @@ -0,0 +1,205 @@ +# keycodes.py — Konstanta key code & pembaca key untuk TUI. +# +# Terminologi: +# * Ctrl+ → 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+ → terminal mengirim "\x1b "; read_key() menggabungkan +# keduanya menjadi kode sintetis KEY_ALT(). +# Fallback CSI-u: "\x1b[;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+: 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+ → kode ASCII murni (misal 's' → 19) + # Alt+ → KEY_ALT() + 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+ → kembalikan KEY_ALT() + # * pembuka CSI seq → parse (F-keys, Ctrl+Enter, Alt+, 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+ = "\x1b " + + 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() diff --git a/interfaces/tui/menubar.py b/interfaces/tui/menubar.py new file mode 100644 index 0000000..dbcfdb8 --- /dev/null +++ b/interfaces/tui/menubar.py @@ -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() diff --git a/interfaces/tui/render.py b/interfaces/tui/render.py index c1f437e..b6bac7c 100644 --- a/interfaces/tui/render.py +++ b/interfaces/tui/render.py @@ -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 ─────── " - 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): diff --git a/interfaces/tui/theme.py b/interfaces/tui/theme.py new file mode 100644 index 0000000..2186ce1 --- /dev/null +++ b/interfaces/tui/theme.py @@ -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