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 json
import threading
from datetime import datetime from datetime import datetime
from .render import draw
from scripts import ntro from scripts import ntro
def log(app, role, text): def log(app, role, text):
# Simpan item ke app.log untuk di-render oleh render.py
app.log.append({ app.log.append({
"role": role, "role": role,
"text": text, "text": text,
@ -18,42 +13,41 @@ def log(app, role, text):
def submit(app, stdscr): 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() query = "\n".join(app.input_buffer).strip()
if not query: if not query:
return return
log(app, "user", query) log(app, "user", query)
# Reset input buffer
app.input_buffer = [""] app.input_buffer = [""]
app.input_line = 0 app.input_line = 0
app.input_col = 0 app.input_col = 0
app.scroll = 999999 # scroll ke paling bawah app.scroll = 999999
app.processing = True app.processing = True
draw(app, stdscr)
stdscr.refresh()
app.messages.append({"role": "user", "content": query}) 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() stamp = ntro.start()
for step in range(app.agent_max_iterations): for step in range(app.agent_max_iterations):
stamp_step = ntro.start() stamp_step = ntro.start()
log(app, "system", f" step {step + 1} \u2014 Thinking...") log(app, "system", f" step {step + 1} \u2014 Thinking...")
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
response = app.llm.chat(app.messages, tools=app.TOOLS) response = app.llm.chat(app.messages, tools=app.TOOLS)
# Hapus "step N — LLM..." log, ganti dengan hasil aktual
app.log.pop() app.log.pop()
if response.tool_calls: if response.tool_calls:
# LLM meminta menjalankan tool(s)
amsg = { amsg = {
"role": "assistant", "role": "assistant",
"content": response.content, "content": response.content,
@ -63,17 +57,12 @@ def submit(app, stdscr):
if response.content and response.content.strip(): if response.content and response.content.strip():
log(app, "ai", response.content) log(app, "ai", response.content)
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
for tc in response.tool_calls: for tc in response.tool_calls:
tname = tc["function"]["name"] tname = tc["function"]["name"]
log(app, "system", f" \u2192 {tname}") log(app, "system", f" \u2192 {tname}")
app.scroll = 999999 app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
execute_tool(app, tc) execute_tool(app, tc)
else: else:
# Final answer — tidak ada tool_calls
if response.content: if response.content:
app.messages.append({ app.messages.append({
"role": "assistant", "role": "assistant",
@ -81,25 +70,19 @@ def submit(app, stdscr):
}) })
log(app, "ai", response.content) log(app, "ai", response.content)
log(app, "sep", "") log(app, "sep", "")
app.processing = False
app.scroll = 999999
draw(app, stdscr)
stdscr.refresh()
ntro.end(stamp) ntro.end(stamp)
app.agent_done.set()
return return
ntro.end(stamp_step) ntro.end(stamp_step)
# Timeout — max iterations tercapai tanpa final answer
log(app, "error", "Max iterations reached without final answer.") log(app, "error", "Max iterations reached without final answer.")
app.messages.append({"role": "assistant", app.messages.append({"role": "assistant",
"content": "Max iterations reached without final answer."}) "content": "Max iterations reached without final answer."})
app.processing = False
ntro.end(stamp) ntro.end(stamp)
app.agent_done.set()
def execute_tool(app, tool_call): 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"] tname = tool_call["function"]["name"]
targs = json.loads(tool_call["function"]["arguments"]) targs = json.loads(tool_call["function"]["arguments"])
handler = app.TOOL_HANDLERS.get(tname) handler = app.TOOL_HANDLERS.get(tname)
@ -121,7 +104,6 @@ def execute_tool(app, tool_call):
except Exception as e: except Exception as e:
result = f"Error executing tool: {str(e)}" result = f"Error executing tool: {str(e)}"
# Hasil tool disimpan ke messages agar bisa dikirim balik ke LLM
app.messages.append({ app.messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tool_call["id"], "tool_call_id": tool_call["id"],

View File

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