266 lines
8.5 KiB
Python
266 lines
8.5 KiB
Python
# 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
|
|
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()
|
|
elif key == 5: # Ctrl+E → model selector popup
|
|
if not processing:
|
|
model_selector_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()
|
|
|
|
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()
|