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 json
|
||||||
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .render import draw
|
|
||||||
from scripts import ntro
|
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):
|
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 +25,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 +69,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 +82,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 +116,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"],
|
||||||
|
|||||||
52
tui/app.py
52
tui/app.py
@ -1,15 +1,13 @@
|
|||||||
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
|
||||||
@ -17,39 +15,37 @@ 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)}]
|
||||||
|
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)")
|
||||||
@ -58,10 +54,22 @@ class HendrikTUI:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
draw(self, stdscr)
|
draw(self, stdscr)
|
||||||
# Sembunyikan kursor saat processing, tampilkan high visibility saat idle
|
curses.curs_set(2)
|
||||||
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
|
||||||
|
|||||||
@ -47,13 +47,10 @@ 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
|
||||||
submit(app, stdscr)
|
if not processing:
|
||||||
|
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)
|
||||||
elif key == 12: # Ctrl+L → clear chat log
|
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_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():
|
||||||
@ -30,12 +32,14 @@ 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_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_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):
|
||||||
@ -75,7 +79,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
|
available = w - indent - 1 # sisakan 1 kolom margin kanan
|
||||||
if available <= 0:
|
if available <= 0:
|
||||||
rendered.append((color, " " * indent))
|
rendered.append((color, " " * indent))
|
||||||
return
|
return
|
||||||
@ -121,6 +125,10 @@ 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")
|
||||||
@ -147,8 +155,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 - 1]
|
text = text[:w]
|
||||||
try:
|
try:
|
||||||
stdscr.addstr(y, 0, text, attr)
|
stdscr.addstr(y, 0, text, attr)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
@ -230,8 +238,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)))
|
||||||
|
|
||||||
@ -273,3 +281,9 @@ 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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user