# 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) # -- 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() 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() 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) if ph < 5: curses.flash() return 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) if ph < 5: curses.flash() return 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()