feat(tui): non-blocking agent loop — cursor visible, user can type during processing

This commit is contained in:
Dita Aji Pratama 2026-05-26 14:03:40 +07:00
parent f4fe53b8c0
commit f3c3d8d1ad
3 changed files with 43 additions and 55 deletions

View File

@ -1,15 +1,10 @@
# agent.py — Agent loop dan tool execution.
# submit() adalah entry point: membaca input buffer, mengirim ke LLM,
# memproses tool calls, dan menampilkan hasil di log.
import json
import threading
from datetime import datetime
from .render import draw
from scripts import ntro
def log(app, role, text):
# Simpan item ke app.log untuk di-render oleh render.py
app.log.append({
"role": role,
"text": text,
@ -18,42 +13,41 @@ def log(app, role, text):
def submit(app, stdscr):
# Kirim query dari input buffer ke LLM.
# Loop sampai LLM mengembalikan final answer (tanpa tool_calls)
# atau mencapai max_iterations.
query = "\n".join(app.input_buffer).strip()
if not query:
return
log(app, "user", query)
# Reset input buffer
app.input_buffer = [""]
app.input_line = 0
app.input_col = 0
app.scroll = 999999 # scroll ke paling bawah
app.scroll = 999999
app.processing = True
draw(app, stdscr)
stdscr.refresh()
app.messages.append({"role": "user", "content": query})
app.agent_done.clear()
app.agent_thread = threading.Thread(
target=_agent_loop,
args=(app,),
daemon=True,
)
app.agent_thread.start()
def _agent_loop(app):
stamp = ntro.start()
for step in range(app.agent_max_iterations):
stamp_step = ntro.start()
log(app, "system", f" step {step + 1} \u2014 Thinking...")
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
response = app.llm.chat(app.messages, tools=app.TOOLS)
# Hapus "step N — LLM..." log, ganti dengan hasil aktual
app.log.pop()
if response.tool_calls:
# LLM meminta menjalankan tool(s)
amsg = {
"role": "assistant",
"content": response.content,
@ -63,17 +57,12 @@ def submit(app, stdscr):
if response.content and response.content.strip():
log(app, "ai", response.content)
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
for tc in response.tool_calls:
tname = tc["function"]["name"]
log(app, "system", f" \u2192 {tname}")
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
execute_tool(app, tc)
else:
# Final answer — tidak ada tool_calls
if response.content:
app.messages.append({
"role": "assistant",
@ -81,25 +70,19 @@ def submit(app, stdscr):
})
log(app, "ai", response.content)
log(app, "sep", "")
app.processing = False
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
ntro.end(stamp)
app.agent_done.set()
return
ntro.end(stamp_step)
# Timeout — max iterations tercapai tanpa final answer
log(app, "error", "Max iterations reached without final answer.")
app.messages.append({"role": "assistant",
"content": "Max iterations reached without final answer."})
app.processing = False
ntro.end(stamp)
app.agent_done.set()
def execute_tool(app, tool_call):
# Dispatch tool_call ke handler yang terdaftar di TOOL_HANDLERS.
# search_code dan git_operation butuh penanganan argumen khusus.
tname = tool_call["function"]["name"]
targs = json.loads(tool_call["function"]["arguments"])
handler = app.TOOL_HANDLERS.get(tname)
@ -121,7 +104,6 @@ def execute_tool(app, tool_call):
except Exception as e:
result = f"Error executing tool: {str(e)}"
# Hasil tool disimpan ke messages agar bisa dikirim balik ke LLM
app.messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],

View File

@ -1,15 +1,12 @@
import curses
import threading
from .render import init_colors, draw
from .input import handle_key
class HendrikTUI:
# State holder & orchestrator.
# Semua data UI disimpan di sini, method lain (render, input, agent)
# menerima `app` (self) sebagai parameter pertama.
def __init__(self, llm_client, tools_definition, TOOLS, TOOL_HANDLERS,
build_system_prompt, agent_max_iterations):
# -- dependencies (disuntik dari luar) --
self.llm = llm_client
self.tools_def = tools_definition
self.TOOLS = TOOLS
@ -17,39 +14,36 @@ class HendrikTUI:
self.build_system_prompt = build_system_prompt
self.agent_max_iterations = agent_max_iterations
# -- UI state --
self.messages = None # chat history yg dikirim ke LLM API
self.log = [] # rendered log (display-only, ada timestamp)
self.input_buffer = [""] # baris-baris input multi-line
self.input_line = 0 # index baris aktif di buffer
self.input_col = 0 # kolom kursor di baris aktif
self.scroll = 0 # scroll offset chat area
self.processing = False # true saat agent sedang memproses
self.running = True # false → keluar dari main loop
self.h, self.w = 0, 0 # ukuran terminal (height, width)
self.messages = None
self.log = []
self.input_buffer = [""]
self.input_line = 0
self.input_col = 0
self.scroll = 0
self.processing = False
self.running = True
self.h, self.w = 0, 0
self.agent_thread: threading.Thread | None = None
self.agent_done = threading.Event()
def run(self):
# Masuk ke curses wrapper. wrapper() setup/teardown terminal
# dan menangani restore terminal meskipun terjadi error.
try:
curses.wrapper(self._main)
except KeyboardInterrupt:
pass
def _main(self, stdscr):
# Main loop: draw → getch → handle
curses.use_default_colors()
init_colors()
stdscr.keypad(True) # enable key codes (KEY_UP, KEY_DOWN, dll)
stdscr.keypad(True)
stdscr.refresh()
# Init system prompt — sekali di awal
self.messages = [{"role": "system",
"content": self.build_system_prompt(self.tools_def)}]
while self.running:
self.h, self.w = stdscr.getmaxyx()
# Minimal ukuran terminal biar UI gak rusak
if self.h < 14 or self.w < 40:
stdscr.erase()
stdscr.addstr(0, 0, "Terminal too small (min 40x14)")
@ -58,10 +52,22 @@ class HendrikTUI:
continue
draw(self, stdscr)
# Tampilkan kursor (high visibility) agar user bisa ngetik walau processing
curses.curs_set(2)
if self.processing:
stdscr.timeout(100)
else:
stdscr.timeout(-1)
try:
key = stdscr.getch()
except KeyboardInterrupt:
break
handle_key(self, stdscr, key)
if self.agent_done.is_set():
self.agent_thread.join()
self.agent_done.clear()
self.processing = False
self.agent_thread = None

View File

@ -230,8 +230,8 @@ def draw_input(app, stdscr):
except curses.error:
pass
# Cursor position — only on the visual chunk that contains the cursor
if idx == cur_visual and not app.processing:
# Cursor position — always on the visual chunk that contains the cursor
if idx == cur_visual:
col_on_visual = app.input_col - start
cursor_yx = (y, 4 + min(col_on_visual, len(chunk)))