hendrik/tui/input.py

542 lines
16 KiB
Python
Raw Normal View History

# input.py — Keyboard handling dan workspace popup.
# handle_key() adalah dispatch besar yang menerjemahkan
# key code curses menjadi aksi pada state app.
import curses
import os
2026-06-16 14:59:40 +07:00
import config
from .agent import submit, log
def _build_visual(buffer, max_chars):
# Build list of (logical_line_idx, start_col) for each visual line.
visual = []
for i, line in enumerate(buffer):
if not line:
visual.append((i, 0))
else:
for start in range(0, max(len(line), 1), max_chars):
visual.append((i, start))
return visual
def _find_visual(visual, logical_line, col):
# Find visual line index for given logical line and column.
best = 0
for idx, (li, start) in enumerate(visual):
if li == logical_line:
best = idx
if start <= col:
break
return best
def handle_key(app, stdscr, key):
max_chars = app.w - 6 # usable width in input box
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) --
if key == 3: # Ctrl+C → exit
app.running = False
elif 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)
elif key == 12: # Ctrl+L → clear chat log
app.log.clear()
2026-06-16 14:59:40 +07:00
elif key == 5: # Ctrl+E → model selector popup
if not processing:
model_selector_popup(app, stdscr)
2026-06-17 16:00:26 +07:00
# -- 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:
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:
delete_session_popup(app, stdscr)
# -- Enter: split logical line at cursor position --
elif key in (curses.KEY_ENTER, 10, 13):
line = app.input_buffer[app.input_line]
left = line[:app.input_col]
right = line[app.input_col:]
app.input_buffer[app.input_line] = left
app.input_buffer.insert(app.input_line + 1, right)
app.input_line += 1
app.input_col = 0
# -- Backspace: hapus karakter sebelumnya atau gabung baris --
elif key in (curses.KEY_BACKSPACE, 127):
if app.input_col > 0:
line = app.input_buffer[app.input_line]
app.input_buffer[app.input_line] = (
line[: app.input_col - 1] + line[app.input_col :]
)
app.input_col -= 1
elif app.input_line > 0:
carry = app.input_buffer.pop(app.input_line)
app.input_line -= 1
app.input_col = len(app.input_buffer[app.input_line])
app.input_buffer[app.input_line] += carry
# -- Navigation arrows --
elif key == curses.KEY_UP:
if cur_visual > 0:
prev_li, prev_start = visual[cur_visual - 1]
app.input_line = prev_li
app.input_col = min(app.input_col, len(app.input_buffer[prev_li]))
elif key == curses.KEY_DOWN:
if cur_visual < len(visual) - 1:
next_li, next_start = visual[cur_visual + 1]
app.input_line = next_li
app.input_col = min(app.input_col, len(app.input_buffer[next_li]))
elif key == curses.KEY_LEFT:
if app.input_col > 0:
app.input_col -= 1
elif app.input_line > 0:
app.input_line -= 1
app.input_col = len(app.input_buffer[app.input_line])
elif key == curses.KEY_RIGHT:
if app.input_col < len(app.input_buffer[app.input_line]):
app.input_col += 1
elif app.input_line < len(app.input_buffer) - 1:
app.input_line += 1
app.input_col = 0
elif key == curses.KEY_HOME:
app.input_col = 0
elif key == curses.KEY_END:
app.input_col = len(app.input_buffer[app.input_line])
# -- Tab: insert 4 spasi --
elif key == 9:
line = app.input_buffer[app.input_line]
app.input_buffer[app.input_line] = (
line[: app.input_col] + " " + line[app.input_col :]
)
app.input_col += 4
# -- Printable characters --
elif 32 <= key <= 255:
ch = chr(key)
line = app.input_buffer[app.input_line]
app.input_buffer[app.input_line] = (
line[: app.input_col] + ch + line[app.input_col :]
)
app.input_col += 1
def workspace_popup(app, stdscr):
# Overlay window kecil di tengah layar untuk input path workspace
pw = min(60, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Workspace path: ")
win.addstr(1, 2, " " * (pw - 4))
curses.echo() # tampilkan input user
ws = win.getstr(1, 2, pw - 5).decode("utf-8")
curses.noecho()
del win
ws = ws.strip()
if ws:
resolved = os.path.abspath(ws)
if os.path.isdir(resolved):
os.chdir(resolved)
log(app, "system", f"Workspace \u2192 {resolved}")
else:
log(app, "error", f"Invalid directory: {resolved}")
stdscr.touchwin()
stdscr.refresh()
2026-06-16 14:59:40 +07:00
def model_selector_popup(app, stdscr):
current_base = app.llm.base_url.rstrip("/")
current_model = app.llm.model
# Group by provider
providers: list[tuple[str, list[dict]]] = []
seen = {}
for item in config.MODELS_ITEMS:
p = item["provider"]
if p not in seen:
seen[p] = []
providers.append((p, seen[p]))
seen[p].append(item)
items = [] # (type, data, label)
selectable = [] # indices into items for model entries
current_idx = 0
for pname, plist in providers:
items.append(("header", None, pname))
for entry in plist:
idx = len(items)
items.append(("model", entry, entry["model"]))
selectable.append(idx)
if entry["base_url"] == current_base and entry["model"] == current_model:
current_idx = len(selectable) - 1
if not selectable:
return
pw = min(50, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
px = (app.w - pw) // 2
py = (app.h - ph) // 2
if ph < 6:
return
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
while True:
win.erase()
win.box()
win.addstr(0, 2, " Pilih Model (Ctrl+E) ", curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(selectable[current_idx] - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
typ, data, label = items[idx]
y = 1 + i
if typ == "header":
win.addstr(y, 2, f" {label} ", curses.A_BOLD | curses.A_UNDERLINE)
else:
is_cur = (idx == selectable[current_idx])
is_active = (data["base_url"] == current_base and data["model"] == current_model)
if is_cur:
prefix = " \u25b6 " if is_active else " > "
elif is_active:
prefix = " \u2192 "
else:
prefix = " "
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, display.ljust(pw - 4), attr)
footer = " \u2191\u2193 nav \u21b5 select esc/q close "
win.addstr(ph - 1, 2, footer[:pw - 4], curses.A_DIM)
try:
target_y = selectable[current_idx] - scroll + 1
if 0 <= target_y < ph - 1:
win.move(target_y, 4)
except curses.error:
pass
win.refresh()
key = win.getch()
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
sel_item = items[selectable[current_idx]]
if sel_item[0] == "model":
app.switch_model(sel_item[1])
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(selectable) - 1:
current_idx += 1
del win
stdscr.touchwin()
stdscr.refresh()
2026-06-17 16:00:26 +07:00
def new_session_popup(app, stdscr):
if not app.current_session:
app.new_session()
return
pw = min(50, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Start new session?", curses.A_BOLD)
win.addstr(1, 2, " [Y]es [N]o ")
win.refresh()
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
app.new_session()
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()
def session_browser_popup(app, stdscr):
sessions = app.session_mgr.list()
if not sessions:
curses.flash()
return
items = []
for s in sessions:
dt = s["updated_at"][:16].replace("T", " ")
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
items.append((s["doc_id"], label))
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
2026-06-17 17:13:28 +07:00
if ph < 5:
curses.flash()
return
2026-06-17 16:00:26 +07:00
px = (app.w - pw) // 2
py = (app.h - ph) // 2
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
current_idx = 0
while True:
win.erase()
win.box()
win.addstr(0, 2, " Sessions (\u2191\u2193 nav \u21b5 select D delete esc close)",
curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
doc_id, label = items[idx]
y = 1 + i
is_cur = (idx == current_idx)
is_active = (app.current_session and doc_id == app.current_session.doc_id)
if is_cur:
prefix = " \u25b6 " if is_active else " > "
elif is_active:
prefix = " \u2192 "
else:
prefix = " "
display = prefix + label
if len(display) > pw - 4:
display = display[:pw - 4]
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, display.ljust(pw - 4), attr)
win.refresh()
key = win.getch()
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
doc_id, _ = items[current_idx]
if not app.current_session or doc_id != app.current_session.doc_id:
app.switch_session(doc_id)
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(items) - 1:
current_idx += 1
elif key in (ord("d"), ord("D")):
doc_id, label = items[current_idx]
if app.current_session and doc_id == app.current_session.doc_id:
continue
app.session_mgr.delete(doc_id)
sessions = app.session_mgr.list()
items = [(s["doc_id"],
f"{s['name']} ({s['updated_at'][:16].replace('T', ' ')}, {s['message_count']} msg)")
for s in sessions]
current_idx = min(current_idx, len(items) - 1)
if not items:
break
del win
stdscr.touchwin()
stdscr.refresh()
def session_search_popup(app, stdscr):
pw = min(60, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Search sessions: ")
win.addstr(1, 2, " " * (pw - 4))
curses.echo()
query = win.getstr(1, 2, pw - 5).decode("utf-8")
curses.noecho()
del win
stdscr.touchwin()
stdscr.refresh()
query = query.strip()
if not query:
return
results = app.session_mgr.search(query)
if not results:
log(app, "system", f"No sessions matching: {query}")
return
items = []
for s in results:
dt = s["updated_at"][:16].replace("T", " ")
label = f"{s['name']} ({dt}, {s['message_count']} msg)"
items.append((s["doc_id"], label))
pw = min(60, app.w - 4)
ph = min(len(items) + 4, app.h - 4)
2026-06-17 17:13:28 +07:00
if ph < 5:
curses.flash()
return
2026-06-17 16:00:26 +07:00
px = (app.w - pw) // 2
py = (app.h - ph) // 2
win = curses.newwin(ph, pw, py, px)
win.keypad(True)
current_idx = 0
while True:
win.erase()
win.box()
win.addstr(0, 2, f" Results for \"{query}\" (\u2191\u2193 nav \u21b5 select esc close)",
curses.A_BOLD)
visible_h = ph - 2
total = len(items)
scroll = max(0, min(current_idx - visible_h // 2, total - visible_h))
for i in range(visible_h):
idx = scroll + i
if idx >= total:
break
doc_id, label = items[idx]
y = 1 + i
is_cur = (idx == current_idx)
attr = curses.A_REVERSE if is_cur else curses.A_NORMAL
win.addstr(y, 2, f" {label}"[:pw - 4].ljust(pw - 4), attr)
win.refresh()
key = win.getch()
if key in (27, ord("q"), ord("Q")):
break
elif key in (curses.KEY_ENTER, 10, 13):
doc_id, _ = items[current_idx]
if not app.current_session or doc_id != app.current_session.doc_id:
app.switch_session(doc_id)
break
elif key == curses.KEY_UP:
if current_idx > 0:
current_idx -= 1
elif key == curses.KEY_DOWN:
if current_idx < len(items) - 1:
current_idx += 1
del win
stdscr.touchwin()
stdscr.refresh()
def rename_popup(app, stdscr):
if not app.current_session:
return
pw = min(60, app.w - 4)
ph = 3
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Rename session: ")
win.addstr(1, 2, " " * (pw - 4))
curses.echo()
new_name = win.getstr(1, 2, pw - 5).decode("utf-8")
curses.noecho()
del win
stdscr.touchwin()
stdscr.refresh()
new_name = new_name.strip()
if new_name:
app.session_mgr.rename(app.current_session.doc_id, new_name)
app.current_session.name = new_name
def delete_session_popup(app, stdscr):
if not app.current_session:
return
pw = min(50, app.w - 4)
ph = 4
px = (app.w - pw) // 2
py = app.h // 2 - 1
win = curses.newwin(ph, pw, py, px)
win.box()
win.addstr(0, 2, " Delete current session?", curses.A_BOLD)
name = app.current_session.name
display = name if len(name) <= pw - 6 else name[:pw - 9] + "..."
win.addstr(1, 2, f" \"{display}\"")
win.addstr(2, 2, " [Y]es [N]o ")
win.refresh()
while True:
key = win.getch()
if key in (ord("y"), ord("Y")):
doc_id = app.current_session.doc_id
app.session_mgr.delete(doc_id)
app.new_session()
break
elif key in (ord("n"), ord("N"), 27):
break
del win
stdscr.touchwin()
stdscr.refresh()