MenuBar
This commit is contained in:
parent
aa4d6dd811
commit
4572fe6b60
148
interfaces/tui/actions.py
Normal file
148
interfaces/tui/actions.py
Normal 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)
|
||||
@ -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,6 +210,16 @@ class HendrikTUI:
|
||||
break
|
||||
key = -1
|
||||
|
||||
# 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():
|
||||
|
||||
@ -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
205
interfaces/tui/keycodes.py
Normal 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
292
interfaces/tui/menubar.py
Normal 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()
|
||||
@ -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
58
interfaces/tui/theme.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user