Compare commits

...

4 Commits

4 changed files with 78 additions and 65 deletions

View File

@ -1,15 +1,22 @@
# 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
WELCOME_ART = """\
\n\
/\\_/\\ H E N D R I K
( o.o ) AI Agent
> ^ < siap membantu!
( )
(___)
"""
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 +25,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 +69,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 +82,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 +116,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,13 @@
import curses
import threading
from .render import init_colors, draw
from .input import handle_key
from .agent import log, WELCOME_ART
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 +15,37 @@ 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)}]
log(self, "welcome", WELCOME_ART)
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 +54,22 @@ class HendrikTUI:
continue
draw(self, stdscr)
# Sembunyikan kursor saat processing, tampilkan high visibility saat idle
curses.curs_set(0 if self.processing else 2)
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

@ -47,13 +47,10 @@ def handle_key(app, stdscr, key):
elif key == curses.KEY_RESIZE:
pass
# -- Blocked during processing --
elif processing:
pass # ignore all other keys while processing
# -- Ctrl shortcuts --
elif key == 4: # Ctrl+D → submit query ke LLM
submit(app, stdscr)
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

View File

@ -19,6 +19,8 @@ C_SEP = 7 # separator line: magenta
C_ERROR = 8 # error message: merah
C_INPUT_BORDER = 9 # border input box: biru
C_STATUS_INFO = 12 # status info (workspace/hints): putih
C_HINT_DISABLED = 13 # hint disabled (abu-abu)
C_WELCOME = 14 # welcome art: light blue
def init_colors():
@ -30,12 +32,14 @@ def init_colors():
curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1)
curses.init_pair(C_INPUT, curses.COLOR_WHITE, -1)
curses.init_pair(C_STATUS, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(C_STATUS_READY, curses.COLOR_WHITE, curses.COLOR_GREEN)
curses.init_pair(C_STATUS_READY, curses.COLOR_BLACK, curses.COLOR_GREEN + 8)
curses.init_pair(C_STATUS_PROC, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(C_STATUS_INFO, curses.COLOR_WHITE, -1)
curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1)
curses.init_pair(C_ERROR, curses.COLOR_RED, -1)
curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -1)
curses.init_pair(C_HINT_DISABLED, 8, -1) # abu-abu di atas bg default
curses.init_pair(C_WELCOME, curses.COLOR_BLUE + 8, -1) # light blue
def draw(app, stdscr):
@ -75,7 +79,7 @@ def draw_chat(app, stdscr):
rendered = []
def _wrap_render(text, indent=0, color=C_INPUT):
available = w - indent
available = w - indent - 1 # sisakan 1 kolom margin kanan
if available <= 0:
rendered.append((color, " " * indent))
return
@ -121,6 +125,10 @@ def draw_chat(app, stdscr):
rendered.append((C_SYSTEM, lines[0]))
for line in lines[1:]:
rendered.append((C_SYSTEM, " " + line))
elif role == "welcome":
lines = text.split("\n")
for line in lines:
rendered.append((C_WELCOME, " " + line))
elif role == "error":
label = " \u2717 "
lines = text.split("\n")
@ -147,8 +155,8 @@ def draw_chat(app, stdscr):
for i in range(app.scroll, min(app.scroll + chat_h, total)):
color, text = rendered[i]
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
if len(text) >= w:
text = text[: w - 1]
if len(text) > w:
text = text[:w]
try:
stdscr.addstr(y, 0, text, attr)
except curses.error:
@ -230,8 +238,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)))
@ -273,3 +281,9 @@ def draw_status(app, stdscr):
mode_end = mode_start + len(mode)
mode_attr = curses.color_pair(C_STATUS_READY) if not app.processing else curses.color_pair(C_STATUS_PROC)
stdscr.addstr(y, mode_start, mode, mode_attr | curses.A_BOLD)
# ^D:send — abu-abu saat processing, bold putih saat idle
if app.processing:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_HINT_DISABLED))
else:
stdscr.addstr(y, mode_end + 2, "^D:send", curses.color_pair(C_STATUS_INFO) | curses.A_BOLD)