Compare commits

..

No commits in common. "6256501b7d027a0e8620c180219bd9c6bfc23559" and "4572fe6b602a6d7288b1a20480170a198733f3b9" have entirely different histories.

4 changed files with 41 additions and 87 deletions

View File

@ -185,13 +185,6 @@ class HendrikTUI:
self.messages.append({"role": "system", "content": context}) self.messages.append({"role": "system", "content": context})
log(self, "welcome", WELCOME_ART) log(self, "welcome", WELCOME_ART)
keycodes.enable_extended_keys()
try:
self._run_loop(stdscr)
finally:
keycodes.disable_extended_keys()
def _run_loop(self, stdscr):
while self.running: while self.running:
self.h, self.w = stdscr.getmaxyx() self.h, self.w = stdscr.getmaxyx()
if self.h < 14 or self.w < 40: if self.h < 14 or self.w < 40:

View File

@ -16,9 +16,7 @@
# menjadi satu set kode yang bisa dibandingkan di actions.py. # menjadi satu set kode yang bisa dibandingkan di actions.py.
import curses import curses
import os
import re import re
import sys
# Waktu (ms) menunggu byte lanjutan setelah ESC di read_key() # Waktu (ms) menunggu byte lanjutan setelah ESC di read_key()
ESC_PEEK_MS = 30 ESC_PEEK_MS = 30
@ -112,9 +110,7 @@ def _parse_csi(data: bytes) -> int:
# data contoh: # data contoh:
# b'[12~' → F2 b'[21~' → F10 # b'[12~' → F2 b'[21~' → F10
# b'[13;5u' → Ctrl+Enter b'[13;5~' → Ctrl+Enter # b'[13;5u' → Ctrl+Enter b'[13;5~' → Ctrl+Enter
# b'[27;5;13~' → Ctrl+Enter (xterm modifyOtherKeys) # b'[115;3u' → Alt+S (kitty) b'[115;5u' → Ctrl+S
# b'[115;3u' → Alt+S (kitty) b'[27;3;115~' → Alt+S (xterm modifyOtherKeys)
# b'[13u' → Enter (CSI-u) b'[13~' → F3 (legacy)
# b'[A' / b'[B' → panah atas / bawah # b'[A' / b'[B' → panah atas / bawah
try: try:
text = data.decode("ascii", "ignore") text = data.decode("ascii", "ignore")
@ -125,61 +121,40 @@ def _parse_csi(data: bytes) -> int:
if text[1:] in _CSI_NAV: if text[1:] in _CSI_NAV:
return _CSI_NAV[text[1:]] return _CSI_NAV[text[1:]]
# Format standar CSI-u: ESC[<code>;<mod>u / ESC[<code>;mod~ (legacy)
m = re.match(r"^\[(\d+)(?:;(\d+))?([u~])$", text) m = re.match(r"^\[(\d+)(?:;(\d+))?([u~])$", text)
if m: if not m:
code = int(m.group(1)) # Navigasi dengan prefiks angka (misal [1;5A = Ctrl+Up)
mod = _csi_mod(m.group(2)) m2 = re.match(r"^\[(?:\d+;)?(\d*)([ABCDHF])$", text)
terminator = m.group(3) if m2 and m2.group(2) in _CSI_NAV:
else: return _CSI_NAV[m2.group(2)]
# Format legacy xterm modifyOtherKeys: ESC[27;<mod>;<code>[u~] return KEY_ESC
m27 = re.match(r"^\[27;(\d+);(\d+)([u~])$", text)
if not m27:
# 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(m27.group(2))
mod = _csi_mod(m27.group(1))
terminator = m27.group(3)
result = _map_modified_key(code, mod, terminator) code = int(m.group(1))
if result is not None: mod = _csi_mod(m.group(2))
return result terminator = m.group(3)
return KEY_ESC
def _map_modified_key(code: int, mod, terminator: str) -> int | None:
# Enter (code 13) khusus karena bentrok dengan F3:
# * Ctrl+Enter → KEY_CTRL_ENTER (\x1b[13;5u / \x1b[13;5~ / \x1b[27;5;13~)
# * \x1b[13~ tanpa modifier → F3 legacy
# * selain itu (Enter CSI-u/kitty/modifier lain) → Enter (13)
if code == 13:
if mod["ctrl"]:
return KEY_CTRL_ENTER
if not (mod["shift"] or mod["alt"]) and terminator == "~":
return _CSI_F_KEYS["13"] # F3
return 13
# F1-F12 dengan/tanpa modifier # F1-F12 dengan/tanpa modifier
if str(code) in _CSI_F_KEYS: if terminator in ("u", "~") and str(code) in _CSI_F_KEYS:
return _CSI_F_KEYS[str(code)] return _CSI_F_KEYS[str(code)]
# CSI-u untuk tombol printable (kitty protocol / xterm modifyOtherKeys): # 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) # Ctrl+<huruf> → kode ASCII murni (misal 's' → 19)
# Alt+<huruf> → KEY_ALT(<huruf>) # Alt+<huruf> → KEY_ALT(<huruf>)
if terminator in ("u", "~") and 32 <= code <= 126: if terminator == "u" and 32 <= code <= 126:
ch = chr(code).lower() ch = chr(code).lower()
if mod["ctrl"]: if mod["ctrl"]:
if 97 <= ord(ch) <= 122: if 97 <= ord(ch) <= 122:
return ord(ch) & 0x1f return ord(ch) & 0x1f
return None return KEY_ESC
if mod["alt"]: if mod["alt"]:
return KEY_ALT(ch) return KEY_ALT(ch)
return None return KEY_ESC
return None return KEY_ESC
def read_key(stdscr, timeout_ms: int = -1) -> int: def read_key(stdscr, timeout_ms: int = -1) -> int:
@ -228,23 +203,3 @@ def alt_key_char(key: int) -> str | None:
if not is_alt_key(key): if not is_alt_key(key):
return None return None
return chr(key - _KEY_ALT_BASE).lower() return chr(key - _KEY_ALT_BASE).lower()
# -------------------------------------------------------------------- protokol keyboard modern ---
# Supaya Ctrl+Enter (dan kombinasi Alt/Ctrl lain) punya escape sequence sendiri
# (bukan jatuh jadi \r / ESC+huruf), aktifkan protokol keyboard di terminal:
# * \x1b[>1u → kitty keyboard protocol (kitty, foot, WezTerm, iTerm2)
# * \x1b[>4;2m → xterm modifyOtherKeys mode 2 (xterm)
# Terminal yang tidak mendukung akan meng-ignore sekuen ini dengan aman.
ENABLE_EXTENDED_KEYS = b"\x1b[>1u\x1b[>4;2m"
DISABLE_EXTENDED_KEYS = b"\x1b[<1u\x1b[>4m"
def enable_extended_keys():
os.write(sys.stdout.fileno(), ENABLE_EXTENDED_KEYS)
sys.stdout.flush()
def disable_extended_keys():
os.write(sys.stdout.fileno(), DISABLE_EXTENDED_KEYS)
sys.stdout.flush()

View File

@ -208,7 +208,7 @@ def draw_menubar(app, stdscr):
pass pass
x += len(text) x += len(text)
model = " F10:menu " # sementara: petunjuk tombol, bukan nama model model = f" {app.llm.model} "
try: try:
stdscr.addstr(y, max(0, app.w - len(model)), model, base_attr) stdscr.addstr(y, max(0, app.w - len(model)), model, base_attr)
except curses.error: except curses.error:

View File

@ -21,6 +21,7 @@ C_STATUS_PROC = theme.C_STATUS_PROC
C_ERROR = theme.C_ERROR C_ERROR = theme.C_ERROR
C_INPUT_BORDER = theme.C_INPUT_BORDER C_INPUT_BORDER = theme.C_INPUT_BORDER
C_STATUS_INFO = theme.C_STATUS_INFO C_STATUS_INFO = theme.C_STATUS_INFO
C_HINT_DISABLED = theme.C_HINT_DISABLED
C_WELCOME = theme.C_WELCOME C_WELCOME = theme.C_WELCOME
C_TOOL_CALL = theme.C_TOOL_CALL C_TOOL_CALL = theme.C_TOOL_CALL
@ -49,16 +50,26 @@ def draw(app, stdscr):
def draw_header(app, stdscr): def draw_header(app, stdscr):
# Baris 1: menu bar (dengan mnemonic + petunjuk F10 di kanan) # Baris 1: menu bar (dengan mnemonic + model di kanan)
draw_menubar(app, stdscr) draw_menubar(app, stdscr)
# Baris 2: shortcut hints kontekstual sesuai status processing
if app.processing:
hints = " F10:menu Esc:cancel "
else:
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[:app.w], attr2)
def draw_chat(app, stdscr): def draw_chat(app, stdscr):
# Area chat — dari baris 2 sampai baris (h - 11). # Area chat — dari baris 2 sampai baris (h - 11).
# Bisa di-scroll dengan Page Up / Page Down. # Bisa di-scroll dengan Page Up / Page Down.
# app.log berisi daftar item (role, text, time) untuk display. # app.log berisi daftar item (role, text, time) untuk display.
h, w = app.h, app.w h, w = app.h, app.w
chat_top = 1 chat_top = 2
chat_h = h - 11 chat_h = h - 11
if chat_h <= 0: if chat_h <= 0:
return return
@ -344,7 +355,7 @@ def draw_input(app, stdscr):
def draw_status(app, stdscr): def draw_status(app, stdscr):
# Status bar di baris h-9: mode, model, workspace, session # Status bar di baris h-9: mode, workspace, session
h, w = app.h, app.w h, w = app.h, app.w
y = h - 9 y = h - 9
ws = get_current_workspace() ws = get_current_workspace()
@ -353,19 +364,18 @@ def draw_status(app, stdscr):
if app.current_session: if app.current_session:
session_tag = f" {app.current_session.name} " session_tag = f" {app.current_session.name} "
model = app.llm.model
mode = " PROCESSING " if app.processing else " READY " mode = " PROCESSING " if app.processing else " READY "
# Format: [MODE] model workspace session (menggunakan spasi sebagai pemisah) # Format: [MODE] workspace session (menggunakan spasi sebagai pemisah)
status_text = f"{mode} {model} {ws} {session_tag}" status_text = f"{mode} {ws} {session_tag}"
# Jika terlalu panjang, potong bagian workspace-nya saja agar tetap readable # Jika terlalu panjang, potong bagian workspace-nya saja agar tetap readable
ws_display = ws ws_display = ws
if len(status_text) > w: if len(status_text) > w:
max_ws_len = w - len(mode) - len(model) - len(session_tag) - 3 max_ws_len = w - len(mode) - len(session_tag) - 2
if len(ws) > max_ws_len: if len(ws) > max_ws_len:
ws_display = ".." + ws[-(max_ws_len - 2):] ws_display = ".." + ws[-(max_ws_len - 2):]
status_text = f"{mode} {model} {ws_display} {session_tag}" status_text = f"{mode} {ws_display} {session_tag}"
# Gambar background dasar # Gambar background dasar
full_status = status_text.ljust(w)[:w] full_status = status_text.ljust(w)[:w]
@ -375,18 +385,14 @@ def draw_status(app, stdscr):
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC) mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
stdscr.addstr(y, 0, mode, mode_attr | curses.A_BOLD) stdscr.addstr(y, 0, mode, mode_attr | curses.A_BOLD)
# Highlight Model, Workspace dan Session dengan warna Putih-Bold # Highlight Workspace dan Session dengan warna Putih-Bold
highlight_attr = curses.color_pair(C_STATUS_INFO) | curses.A_BOLD highlight_attr = curses.color_pair(C_STATUS_INFO) | curses.A_BOLD
try: try:
# Mode sudah digambar, kita cari posisi setelah mode # Mode sudah digambar, kita cari posisi setelah mode
model_start = len(mode) + 1 # melewati mode + 1 spasi ws_start = len(mode) + 1 # melewati mode + 1 spasi
ws_start = len(mode) + len(model) + 2
ws_len = len(ws_display) ws_len = len(ws_display)
# Gambar Model
stdscr.addstr(y, model_start, model, highlight_attr)
# Gambar Workspace # Gambar Workspace
stdscr.addstr(y, ws_start, ws_display, highlight_attr) stdscr.addstr(y, ws_start, ws_display, highlight_attr)