Compare commits
4 Commits
25c6fa3bdb
...
d833774809
| Author | SHA1 | Date | |
|---|---|---|---|
| d833774809 | |||
| 02f5ce345b | |||
| f3c3d8d1ad | |||
| f4fe53b8c0 |
58
tui/agent.py
58
tui/agent.py
@ -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"],
|
||||
|
||||
52
tui/app.py
52
tui/app.py
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user