Compare commits

..

No commits in common. "d83377480934806c61cc4730d3f394555329b38b" and "25c6fa3bdba5fc810fdd85f44c10f4a0402a4ba7" have entirely different histories.

4 changed files with 67 additions and 80 deletions

View File

@ -1,22 +1,15 @@
import json # agent.py — Agent loop dan tool execution.
import threading # submit() adalah entry point: membaca input buffer, mengirim ke LLM,
from datetime import datetime # memproses tool calls, dan menampilkan hasil di log.
from scripts import ntro
WELCOME_ART = """\ import json
\n\ from datetime import datetime
from .render import draw
from scripts import ntro
/\\_/\\ H E N D R I K
( o.o ) AI Agent
> ^ < siap membantu!
( )
(___)
"""
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,
@ -25,41 +18,42 @@ 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 app.scroll = 999999 # scroll ke paling bawah
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,
@ -69,12 +63,17 @@ def _agent_loop(app):
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",
@ -82,19 +81,25 @@ def _agent_loop(app):
}) })
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)
@ -116,6 +121,7 @@ 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,13 +1,15 @@
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
from .agent import log, WELCOME_ART
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
@ -15,37 +17,39 @@ 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
self.messages = None # -- UI state --
self.log = [] self.messages = None # chat history yg dikirim ke LLM API
self.input_buffer = [""] self.log = [] # rendered log (display-only, ada timestamp)
self.input_line = 0 self.input_buffer = [""] # baris-baris input multi-line
self.input_col = 0 self.input_line = 0 # index baris aktif di buffer
self.scroll = 0 self.input_col = 0 # kolom kursor di baris aktif
self.processing = False self.scroll = 0 # scroll offset chat area
self.running = True self.processing = False # true saat agent sedang memproses
self.h, self.w = 0, 0 self.running = True # false → keluar dari main loop
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) stdscr.keypad(True) # enable key codes (KEY_UP, KEY_DOWN, dll)
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)}]
log(self, "welcome", WELCOME_ART)
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)")
@ -54,22 +58,10 @@ class HendrikTUI:
continue continue
draw(self, stdscr) draw(self, stdscr)
curses.curs_set(2) # Sembunyikan kursor saat processing, tampilkan high visibility saat idle
curses.curs_set(0 if self.processing else 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

@ -47,9 +47,12 @@ def handle_key(app, stdscr, key):
elif key == curses.KEY_RESIZE: elif key == curses.KEY_RESIZE:
pass pass
# -- Blocked during processing --
elif processing:
pass # ignore all other keys while processing
# -- Ctrl shortcuts -- # -- Ctrl shortcuts --
elif key == 4: # Ctrl+D → submit query ke LLM elif key == 4: # Ctrl+D → submit query ke LLM
if not processing:
submit(app, stdscr) submit(app, stdscr)
elif key == 23: # Ctrl+W → popup ganti workspace elif key == 23: # Ctrl+W → popup ganti workspace
workspace_popup(app, stdscr) workspace_popup(app, stdscr)

View File

@ -19,8 +19,6 @@ C_SEP = 7 # separator line: magenta
C_ERROR = 8 # error message: merah C_ERROR = 8 # error message: merah
C_INPUT_BORDER = 9 # border input box: biru C_INPUT_BORDER = 9 # border input box: biru
C_STATUS_INFO = 12 # status info (workspace/hints): putih 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(): def init_colors():
@ -32,14 +30,12 @@ def init_colors():
curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1) curses.init_pair(C_SYSTEM, curses.COLOR_YELLOW, -1)
curses.init_pair(C_INPUT, curses.COLOR_WHITE, -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, curses.COLOR_BLACK, curses.COLOR_YELLOW)
curses.init_pair(C_STATUS_READY, curses.COLOR_BLACK, curses.COLOR_GREEN + 8) curses.init_pair(C_STATUS_READY, curses.COLOR_WHITE, curses.COLOR_GREEN)
curses.init_pair(C_STATUS_PROC, curses.COLOR_BLACK, curses.COLOR_YELLOW) 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_STATUS_INFO, curses.COLOR_WHITE, -1)
curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1) curses.init_pair(C_SEP, curses.COLOR_MAGENTA, -1)
curses.init_pair(C_ERROR, curses.COLOR_RED, -1) curses.init_pair(C_ERROR, curses.COLOR_RED, -1)
curses.init_pair(C_INPUT_BORDER, curses.COLOR_BLUE, -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): def draw(app, stdscr):
@ -79,7 +75,7 @@ def draw_chat(app, stdscr):
rendered = [] rendered = []
def _wrap_render(text, indent=0, color=C_INPUT): def _wrap_render(text, indent=0, color=C_INPUT):
available = w - indent - 1 # sisakan 1 kolom margin kanan available = w - indent
if available <= 0: if available <= 0:
rendered.append((color, " " * indent)) rendered.append((color, " " * indent))
return return
@ -125,10 +121,6 @@ def draw_chat(app, stdscr):
rendered.append((C_SYSTEM, lines[0])) rendered.append((C_SYSTEM, lines[0]))
for line in lines[1:]: for line in lines[1:]:
rendered.append((C_SYSTEM, " " + line)) rendered.append((C_SYSTEM, " " + line))
elif role == "welcome":
lines = text.split("\n")
for line in lines:
rendered.append((C_WELCOME, " " + line))
elif role == "error": elif role == "error":
label = " \u2717 " label = " \u2717 "
lines = text.split("\n") lines = text.split("\n")
@ -155,8 +147,8 @@ def draw_chat(app, stdscr):
for i in range(app.scroll, min(app.scroll + chat_h, total)): for i in range(app.scroll, min(app.scroll + chat_h, total)):
color, text = rendered[i] color, text = rendered[i]
attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL attr = curses.color_pair(color) | curses.A_BOLD if color else curses.A_NORMAL
if len(text) > w: if len(text) >= w:
text = text[:w] text = text[: w - 1]
try: try:
stdscr.addstr(y, 0, text, attr) stdscr.addstr(y, 0, text, attr)
except curses.error: except curses.error:
@ -238,8 +230,8 @@ def draw_input(app, stdscr):
except curses.error: except curses.error:
pass pass
# Cursor position — always on the visual chunk that contains the cursor # Cursor position — only on the visual chunk that contains the cursor
if idx == cur_visual: if idx == cur_visual and not app.processing:
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)))
@ -281,9 +273,3 @@ def draw_status(app, stdscr):
mode_end = mode_start + len(mode) 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) 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) 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)