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 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"],
|
||||||
|
|||||||
48
tui/app.py
48
tui/app.py
@ -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
|
||||||
|
|||||||
@ -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)))
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user