feat(tui): non-blocking agent loop — cursor visible, user can type during processing
This commit is contained in:
parent
f4fe53b8c0
commit
f3c3d8d1ad
46
tui/agent.py
46
tui/agent.py
@ -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"],
|
||||
|
||||
48
tui/app.py
48
tui/app.py
@ -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
|
||||
|
||||
@ -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)))
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user