Merge feat/tui: Add TUI interface with word wrap, timing, and improved rendering
This commit is contained in:
commit
58b9eda7f7
12
config.py
12
config.py
@ -4,11 +4,11 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# LLM Configuration
|
# LLM Configuration
|
||||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1")
|
llm_baseurl = os.getenv("LLM_BASE_URL", default="http://localhost:11434/v1" )
|
||||||
LLM_MODEL = os.getenv("LLM_MODEL", default="granite4.1:8b")
|
llm_model = os.getenv("LLM_MODEL", default="granite4.1:8b" )
|
||||||
LLM_API_KEY = os.getenv("LLM_API_KEY", default="ollama")
|
llm_api_key = os.getenv("LLM_API_KEY", default="ollama" )
|
||||||
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", default="600"))
|
llm_timeout = int( os.getenv("LLM_TIMEOUT", default="600" ) )
|
||||||
# Agent Configuration
|
# Agent Configuration
|
||||||
AGENT_MAX_ITERATIONS = int(os.getenv("AGENT_MAX_ITERATIONS", default="10"))
|
AGENT_MAX_ITERATIONS = int( os.getenv("AGENT_MAX_ITERATIONS", default="10" ) )
|
||||||
# Tool Configuration (for future use)
|
# Tool Configuration (for future use)
|
||||||
MAX_TOOL_OUTPUT = int(os.getenv("MAX_TOOL_OUTPUT", default="4000"))
|
MAX_TOOL_OUTPUT = int( os.getenv("MAX_TOOL_OUTPUT", default="4000" ) )
|
||||||
|
|||||||
13
hendrik
Normal file
13
hendrik
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# hendrik — wrapper to run the TUI agent from anywhere
|
||||||
|
|
||||||
|
# Set HENDRIK_DIR env var to override, or update the default below
|
||||||
|
DEFAULT_DIR="/home/ambadar-aji/experiment/hendrik"
|
||||||
|
PROJECT_DIR="${HENDRIK_DIR:-$DEFAULT_DIR}"
|
||||||
|
|
||||||
|
if [ ! -d "$PROJECT_DIR/.venv" ]; then
|
||||||
|
echo "Error: .venv not found at $PROJECT_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PROJECT_DIR/.venv/bin/python" "$PROJECT_DIR/hendrik.py" "$@"
|
||||||
201
hendrik.py
201
hendrik.py
@ -1,157 +1,39 @@
|
|||||||
import os, sys, json, tty, termios
|
import os, sys
|
||||||
import config
|
import config
|
||||||
from llm_client import LLMClient
|
|
||||||
|
from scripts.llm_client import LLMClient
|
||||||
from tools import coder
|
from tools import coder
|
||||||
from scripts import gadget
|
from scripts import gadget
|
||||||
from scripts.ansicolor import TEXT_COLOR_YELLOW, TEXT_COLOR_GREEN, TEXT_COLOR_RESET
|
from tui import HendrikTUI
|
||||||
from scripts.separator import separator
|
|
||||||
|
|
||||||
|
# Daftar tools yang tersedia
|
||||||
tools_definition = [
|
tools_definition = [
|
||||||
gadget.tools_mapping( coder.schema_read_file, coder.read_file ),
|
gadget.tools_mapping( schema = coder.schema_read_file, handler = coder.read_file ),
|
||||||
gadget.tools_mapping( coder.schema_write_file, coder.write_file ),
|
gadget.tools_mapping( schema = coder.schema_write_file, handler = coder.write_file ),
|
||||||
gadget.tools_mapping( coder.schema_edit_file, coder.edit_file ),
|
gadget.tools_mapping( schema = coder.schema_edit_file, handler = coder.edit_file ),
|
||||||
gadget.tools_mapping( coder.schema_run_bash, coder.run_bash ),
|
gadget.tools_mapping( schema = coder.schema_run_bash, handler = coder.run_bash ),
|
||||||
gadget.tools_mapping( coder.schema_search_code, coder.search_code ),
|
gadget.tools_mapping( schema = coder.schema_search_code, handler = coder.search_code ),
|
||||||
gadget.tools_mapping( coder.schema_git_operation, coder.git_operation ),
|
gadget.tools_mapping( schema = coder.schema_git_operation, handler = coder.git_operation ),
|
||||||
]
|
]
|
||||||
|
|
||||||
TOOLS = gadget.tool_schemas(tools_definition)
|
# Ekstrak dari tools_definition ke dua format berbeda
|
||||||
TOOL_HANDLERS = gadget.tool_handlers(tools_definition)
|
TOOLS = gadget.tool_schemas (tools_definition)
|
||||||
|
TOOL_HANDLERS = gadget.tool_handlers (tools_definition)
|
||||||
|
|
||||||
def interactive_input(header_mode='full'):
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old = termios.tcgetattr(fd)
|
|
||||||
|
|
||||||
print()
|
|
||||||
if header_mode == 'full':
|
|
||||||
print(separator())
|
|
||||||
print("Hendrik AI Agent - Interactive Mode")
|
|
||||||
print(separator())
|
|
||||||
print(f"Workspace: {os.getcwd()}")
|
|
||||||
print(separator())
|
|
||||||
print("[Ctrl+W] Change workspace | :workspace <dir> | [Ctrl+D] Submit")
|
|
||||||
print(separator())
|
|
||||||
elif header_mode == 'workspace':
|
|
||||||
print(separator())
|
|
||||||
print(f"Workspace: {os.getcwd()}")
|
|
||||||
print(separator())
|
|
||||||
else:
|
|
||||||
print("[Ctrl+W] Change workspace | :workspace <dir> | [Ctrl+D] Submit")
|
|
||||||
print(separator())
|
|
||||||
|
|
||||||
buffer = bytearray()
|
|
||||||
try:
|
|
||||||
tty.setraw(fd)
|
|
||||||
while True:
|
|
||||||
ch = os.read(fd, 1)
|
|
||||||
if ch == b'\x03': # Ctrl+C → exit
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
||||||
print("\r\nExiting.")
|
|
||||||
sys.exit(0)
|
|
||||||
elif ch == b'\x04': # Ctrl+D → submit
|
|
||||||
break
|
|
||||||
elif ch == b'\x17': # Ctrl+W → change workspace
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
||||||
print("\r\n", end="")
|
|
||||||
ws = input("Workspace directory: ").strip()
|
|
||||||
if ws:
|
|
||||||
resolved = os.path.abspath(ws)
|
|
||||||
if not os.path.isdir(resolved):
|
|
||||||
print(f"Error: '{resolved}' is not a valid directory")
|
|
||||||
else:
|
|
||||||
os.chdir(resolved)
|
|
||||||
print(f"\u2192 Workspace changed to {os.getcwd()}")
|
|
||||||
return interactive_input(header_mode='workspace')
|
|
||||||
elif ch in (b'\r', b'\n'): # Enter
|
|
||||||
buffer.extend(b'\n')
|
|
||||||
sys.stdout.buffer.write(b'\r\n')
|
|
||||||
sys.stdout.flush()
|
|
||||||
elif ch == b'\x7f': # Backspace
|
|
||||||
if buffer:
|
|
||||||
buffer.pop()
|
|
||||||
sys.stdout.buffer.write(b'\b \b')
|
|
||||||
sys.stdout.flush()
|
|
||||||
elif ch >= b' ': # Printable characters
|
|
||||||
buffer.extend(ch)
|
|
||||||
sys.stdout.buffer.write(ch)
|
|
||||||
sys.stdout.flush()
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
||||||
|
|
||||||
full_query = buffer.decode('utf-8', errors='replace').strip()
|
|
||||||
|
|
||||||
if full_query.startswith(':workspace '):
|
|
||||||
ws = full_query[11:].strip()
|
|
||||||
resolved = os.path.abspath(ws)
|
|
||||||
if not os.path.isdir(resolved):
|
|
||||||
print(f"Error: '{resolved}' is not a valid directory")
|
|
||||||
return interactive_input(header_mode=header_mode)
|
|
||||||
os.chdir(resolved)
|
|
||||||
print(f"\u2192 Workspace changed to {os.getcwd()}")
|
|
||||||
return interactive_input(header_mode='workspace')
|
|
||||||
|
|
||||||
return full_query
|
|
||||||
|
|
||||||
|
|
||||||
def agent_loop(user_query, messages, llm_client):
|
|
||||||
messages.append({"role": "user", "content": user_query})
|
|
||||||
for _ in range(config.AGENT_MAX_ITERATIONS):
|
|
||||||
response = llm_client.chat(messages, tools=TOOLS)
|
|
||||||
if response.tool_calls:
|
|
||||||
assistant_msg = {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": response.content,
|
|
||||||
"tool_calls": response.tool_calls
|
|
||||||
}
|
|
||||||
messages.append(assistant_msg)
|
|
||||||
for tool_call in response.tool_calls:
|
|
||||||
tool_name = tool_call['function']['name']
|
|
||||||
tool_args = json.loads(tool_call['function']['arguments'])
|
|
||||||
handler = TOOL_HANDLERS.get(tool_name)
|
|
||||||
if not handler:
|
|
||||||
result = f"Tool {tool_name} not found"
|
|
||||||
else:
|
|
||||||
args_display = ", ".join(f"{k}={v!r}" for k, v in tool_args.items())
|
|
||||||
print(f" \u2192 {tool_name}({args_display})")
|
|
||||||
try:
|
|
||||||
if tool_name == "search_code":
|
|
||||||
result = handler(
|
|
||||||
pattern=tool_args["pattern"],
|
|
||||||
search_type=tool_args["search_type"],
|
|
||||||
path=tool_args.get("path", ".")
|
|
||||||
)
|
|
||||||
elif tool_name == "git_operation":
|
|
||||||
result = handler(args=tool_args["args"])
|
|
||||||
else:
|
|
||||||
result = handler(**tool_args)
|
|
||||||
except Exception as e:
|
|
||||||
result = f"Error executing tool: {str(e)}"
|
|
||||||
messages.append({
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_call['id'],
|
|
||||||
"content": str(result)
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
messages.append({"role": "assistant", "content": response.content})
|
|
||||||
return response.content, messages
|
|
||||||
msg = "Max iterations reached without final answer."
|
|
||||||
messages.append({"role": "assistant", "content": msg})
|
|
||||||
return msg, messages
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
llm_client = LLMClient(config.llm_baseurl, config.llm_model, config.llm_api_key, config.llm_timeout)
|
||||||
|
|
||||||
|
# Parsing arguments `-w <dir>` atau `--workspace <dir>`
|
||||||
workspace = None
|
workspace = None
|
||||||
query_parts = []
|
|
||||||
i = 1
|
i = 1
|
||||||
while i < len(sys.argv):
|
while i < len(sys.argv):
|
||||||
if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv):
|
if sys.argv[i] in ('-w', '--workspace') and i + 1 < len(sys.argv):
|
||||||
workspace = sys.argv[i + 1]
|
workspace = sys.argv[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
query_parts.append(sys.argv[i])
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
|
# Apply workspace jika ada
|
||||||
if workspace:
|
if workspace:
|
||||||
resolved = os.path.abspath(workspace)
|
resolved = os.path.abspath(workspace)
|
||||||
if not os.path.isdir(resolved):
|
if not os.path.isdir(resolved):
|
||||||
@ -159,45 +41,14 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
os.chdir(resolved)
|
os.chdir(resolved)
|
||||||
|
|
||||||
llm_client = LLMClient(
|
HendrikTUI(
|
||||||
base_url=config.LLM_BASE_URL,
|
llm_client = llm_client,
|
||||||
model=config.LLM_MODEL,
|
tools_definition = tools_definition,
|
||||||
api_key=config.LLM_API_KEY
|
TOOLS = TOOLS,
|
||||||
)
|
TOOL_HANDLERS = TOOL_HANDLERS,
|
||||||
|
build_system_prompt = gadget.build_system_prompt,
|
||||||
messages = None
|
agent_max_iterations = config.AGENT_MAX_ITERATIONS,
|
||||||
|
).run() # Luncurkan TUI
|
||||||
if query_parts:
|
|
||||||
user_query = ' '.join(query_parts)
|
|
||||||
if not user_query:
|
|
||||||
print("No query provided.")
|
|
||||||
return
|
|
||||||
messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}]
|
|
||||||
print(f"\n{TEXT_COLOR_YELLOW}Thinking...{TEXT_COLOR_RESET}")
|
|
||||||
final_answer, messages = agent_loop(user_query, messages, llm_client)
|
|
||||||
print(f"\n{TEXT_COLOR_GREEN}Final Answer:{TEXT_COLOR_RESET}")
|
|
||||||
print(final_answer)
|
|
||||||
return
|
|
||||||
|
|
||||||
first_interaction = True
|
|
||||||
while True:
|
|
||||||
user_query = interactive_input(
|
|
||||||
header_mode='full' if first_interaction else 'compact'
|
|
||||||
)
|
|
||||||
first_interaction = False
|
|
||||||
if not user_query:
|
|
||||||
break
|
|
||||||
if user_query.lower() in ('/exit', '/quit'):
|
|
||||||
break
|
|
||||||
|
|
||||||
if messages is None:
|
|
||||||
messages = [{"role": "system", "content": gadget.build_system_prompt(tools_definition)}]
|
|
||||||
|
|
||||||
print(f"{TEXT_COLOR_YELLOW}Thinking...{TEXT_COLOR_RESET}")
|
|
||||||
final_answer, messages = agent_loop(user_query, messages, llm_client)
|
|
||||||
print(f"\n{TEXT_COLOR_GREEN}Final Answer:{TEXT_COLOR_RESET}")
|
|
||||||
print(final_answer)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
TEXT_COLOR_YELLOW = '\033[93m'
|
|
||||||
TEXT_COLOR_GREEN = '\033[92m'
|
|
||||||
TEXT_COLOR_RESET = '\033[0m'
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from config import LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_TIMEOUT
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
class Message:
|
class Message:
|
||||||
@ -9,10 +8,11 @@ class LLMClient:
|
|||||||
self.content = msg.get('content', '')
|
self.content = msg.get('content', '')
|
||||||
self.tool_calls = msg.get('tool_calls', None)
|
self.tool_calls = msg.get('tool_calls', None)
|
||||||
|
|
||||||
def __init__(self, base_url=LLM_BASE_URL, model=LLM_MODEL, api_key=LLM_API_KEY):
|
def __init__(self, base_url, model, api_key, timeout=600):
|
||||||
self.base_url = base_url.rstrip('/')
|
self.base_url = base_url.rstrip('/')
|
||||||
self.model = model
|
self.model = model
|
||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
def chat(self, messages, tools=None):
|
def chat(self, messages, tools=None):
|
||||||
url = f"{self.base_url}/chat/completions"
|
url = f"{self.base_url}/chat/completions"
|
||||||
@ -30,7 +30,7 @@ class LLMClient:
|
|||||||
req.add_header('Authorization', f'Bearer {self.api_key}')
|
req.add_header('Authorization', f'Bearer {self.api_key}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as resp:
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
response = json.loads(resp.read().decode('utf-8'))
|
response = json.loads(resp.read().decode('utf-8'))
|
||||||
message = response['choices'][0]['message']
|
message = response['choices'][0]['message']
|
||||||
return self.Message(message)
|
return self.Message(message)
|
||||||
12
scripts/ntro.py
Normal file
12
scripts/ntro.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
def start():
|
||||||
|
return {"start": time.perf_counter_ns()}
|
||||||
|
|
||||||
|
def end(stamp):
|
||||||
|
elapsed_ns = time.perf_counter_ns() - stamp["start"]
|
||||||
|
elapsed_ms = elapsed_ns / 1_000_000
|
||||||
|
stamp["start"] = time.perf_counter_ns() # For start with multi end
|
||||||
|
# print(f"[ntropy] {elapsed_ms:.2f}ms")
|
||||||
|
return elapsed_ms
|
||||||
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import shutil
|
|
||||||
|
|
||||||
|
|
||||||
def separator():
|
|
||||||
return "\u2500" * shutil.get_terminal_size().columns
|
|
||||||
2
tui/__init__.py
Normal file
2
tui/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Re-export HendrikTUI agar import dari luar cukup: from tui import HendrikTUI
|
||||||
|
from .app import HendrikTUI
|
||||||
129
tui/agent.py
Normal file
129
tui/agent.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# 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
|
||||||
|
from datetime import datetime
|
||||||
|
from .render import draw
|
||||||
|
from scripts import ntro
|
||||||
|
|
||||||
|
|
||||||
|
def log(app, role, text):
|
||||||
|
# Simpan item ke app.log untuk di-render oleh render.py
|
||||||
|
app.log.append({
|
||||||
|
"role": role,
|
||||||
|
"text": text,
|
||||||
|
"time": datetime.now().strftime("%H:%M"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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.processing = True
|
||||||
|
draw(app, stdscr)
|
||||||
|
stdscr.refresh()
|
||||||
|
|
||||||
|
app.messages.append({"role": "user", "content": query})
|
||||||
|
|
||||||
|
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,
|
||||||
|
"tool_calls": response.tool_calls,
|
||||||
|
}
|
||||||
|
app.messages.append(amsg)
|
||||||
|
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",
|
||||||
|
"content": response.content,
|
||||||
|
})
|
||||||
|
log(app, "ai", response.content)
|
||||||
|
log(app, "sep", "")
|
||||||
|
app.processing = False
|
||||||
|
app.scroll = 999999
|
||||||
|
draw(app, stdscr)
|
||||||
|
stdscr.refresh()
|
||||||
|
ntro.end(stamp)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not handler:
|
||||||
|
result = f"Tool {tname} not found"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
if tname == "search_code":
|
||||||
|
result = handler(
|
||||||
|
pattern=targs["pattern"],
|
||||||
|
search_type=targs["search_type"],
|
||||||
|
path=targs.get("path", "."),
|
||||||
|
)
|
||||||
|
elif tname == "git_operation":
|
||||||
|
result = handler(args=targs["args"])
|
||||||
|
else:
|
||||||
|
result = handler(**targs)
|
||||||
|
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"],
|
||||||
|
"content": str(result),
|
||||||
|
})
|
||||||
68
tui/app.py
Normal file
68
tui/app.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import curses
|
||||||
|
from .render import init_colors, draw
|
||||||
|
from .input import handle_key
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
self.TOOL_HANDLERS = TOOL_HANDLERS
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
print("Exiting.")
|
||||||
|
|
||||||
|
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.refresh()
|
||||||
|
|
||||||
|
# Init system prompt — sekali di awal
|
||||||
|
self.messages = [{"role": "system",
|
||||||
|
"content": self.build_system_prompt(self.tools_def)}]
|
||||||
|
|
||||||
|
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)")
|
||||||
|
stdscr.refresh()
|
||||||
|
stdscr.getch()
|
||||||
|
continue
|
||||||
|
|
||||||
|
draw(self, stdscr)
|
||||||
|
# Sembunyikan kursor saat processing, tampilkan high visibility saat idle
|
||||||
|
curses.curs_set(0 if self.processing else 2)
|
||||||
|
try:
|
||||||
|
key = stdscr.getch()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
break
|
||||||
|
handle_key(self, stdscr, key)
|
||||||
159
tui/input.py
Normal file
159
tui/input.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
# input.py — Keyboard handling dan workspace popup.
|
||||||
|
# handle_key() adalah dispatch besar yang menerjemahkan
|
||||||
|
# key code curses menjadi aksi pada state app.
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
from .agent import submit, log
|
||||||
|
|
||||||
|
|
||||||
|
def _build_visual(buffer, max_chars):
|
||||||
|
# Build list of (logical_line_idx, start_col) for each visual line.
|
||||||
|
visual = []
|
||||||
|
for i, line in enumerate(buffer):
|
||||||
|
if not line:
|
||||||
|
visual.append((i, 0))
|
||||||
|
else:
|
||||||
|
for start in range(0, max(len(line), 1), max_chars):
|
||||||
|
visual.append((i, start))
|
||||||
|
return visual
|
||||||
|
|
||||||
|
|
||||||
|
def _find_visual(visual, logical_line, col):
|
||||||
|
# Find visual line index for given logical line and column.
|
||||||
|
best = 0
|
||||||
|
for idx, (li, start) in enumerate(visual):
|
||||||
|
if li == logical_line:
|
||||||
|
best = idx
|
||||||
|
if start <= col:
|
||||||
|
break
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def handle_key(app, stdscr, key):
|
||||||
|
max_chars = app.w - 6 # usable width in input box
|
||||||
|
visual = _build_visual(app.input_buffer, max_chars)
|
||||||
|
cur_visual = _find_visual(visual, app.input_line, app.input_col)
|
||||||
|
|
||||||
|
processing = app.processing
|
||||||
|
|
||||||
|
# -- Always allowed (even during processing) --
|
||||||
|
if key == 3: # Ctrl+C → exit
|
||||||
|
app.running = False
|
||||||
|
elif key == curses.KEY_PPAGE:
|
||||||
|
app.scroll = max(0, app.scroll - (app.h - 10) // 2)
|
||||||
|
elif key == curses.KEY_NPAGE:
|
||||||
|
app.scroll += (app.h - 10) // 2
|
||||||
|
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)
|
||||||
|
elif key == 23: # Ctrl+W → popup ganti workspace
|
||||||
|
workspace_popup(app, stdscr)
|
||||||
|
elif key == 12: # Ctrl+L → clear chat log
|
||||||
|
app.log.clear()
|
||||||
|
|
||||||
|
# -- Enter: split logical line at cursor position --
|
||||||
|
elif key in (curses.KEY_ENTER, 10, 13):
|
||||||
|
line = app.input_buffer[app.input_line]
|
||||||
|
left = line[:app.input_col]
|
||||||
|
right = line[app.input_col:]
|
||||||
|
app.input_buffer[app.input_line] = left
|
||||||
|
app.input_buffer.insert(app.input_line + 1, right)
|
||||||
|
app.input_line += 1
|
||||||
|
app.input_col = 0
|
||||||
|
|
||||||
|
# -- Backspace: hapus karakter sebelumnya atau gabung baris --
|
||||||
|
elif key in (curses.KEY_BACKSPACE, 127):
|
||||||
|
if app.input_col > 0:
|
||||||
|
line = app.input_buffer[app.input_line]
|
||||||
|
app.input_buffer[app.input_line] = (
|
||||||
|
line[: app.input_col - 1] + line[app.input_col :]
|
||||||
|
)
|
||||||
|
app.input_col -= 1
|
||||||
|
elif app.input_line > 0:
|
||||||
|
carry = app.input_buffer.pop(app.input_line)
|
||||||
|
app.input_line -= 1
|
||||||
|
app.input_col = len(app.input_buffer[app.input_line])
|
||||||
|
app.input_buffer[app.input_line] += carry
|
||||||
|
|
||||||
|
# -- Navigation arrows --
|
||||||
|
elif key == curses.KEY_UP:
|
||||||
|
if cur_visual > 0:
|
||||||
|
prev_li, prev_start = visual[cur_visual - 1]
|
||||||
|
app.input_line = prev_li
|
||||||
|
app.input_col = min(app.input_col, len(app.input_buffer[prev_li]))
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
if cur_visual < len(visual) - 1:
|
||||||
|
next_li, next_start = visual[cur_visual + 1]
|
||||||
|
app.input_line = next_li
|
||||||
|
app.input_col = min(app.input_col, len(app.input_buffer[next_li]))
|
||||||
|
elif key == curses.KEY_LEFT:
|
||||||
|
if app.input_col > 0:
|
||||||
|
app.input_col -= 1
|
||||||
|
elif app.input_line > 0:
|
||||||
|
app.input_line -= 1
|
||||||
|
app.input_col = len(app.input_buffer[app.input_line])
|
||||||
|
elif key == curses.KEY_RIGHT:
|
||||||
|
if app.input_col < len(app.input_buffer[app.input_line]):
|
||||||
|
app.input_col += 1
|
||||||
|
elif app.input_line < len(app.input_buffer) - 1:
|
||||||
|
app.input_line += 1
|
||||||
|
app.input_col = 0
|
||||||
|
elif key == curses.KEY_HOME:
|
||||||
|
app.input_col = 0
|
||||||
|
elif key == curses.KEY_END:
|
||||||
|
app.input_col = len(app.input_buffer[app.input_line])
|
||||||
|
|
||||||
|
# -- Tab: insert 4 spasi --
|
||||||
|
elif key == 9:
|
||||||
|
line = app.input_buffer[app.input_line]
|
||||||
|
app.input_buffer[app.input_line] = (
|
||||||
|
line[: app.input_col] + " " + line[app.input_col :]
|
||||||
|
)
|
||||||
|
app.input_col += 4
|
||||||
|
|
||||||
|
# -- Printable characters --
|
||||||
|
elif 32 <= key <= 255:
|
||||||
|
ch = chr(key)
|
||||||
|
line = app.input_buffer[app.input_line]
|
||||||
|
app.input_buffer[app.input_line] = (
|
||||||
|
line[: app.input_col] + ch + line[app.input_col :]
|
||||||
|
)
|
||||||
|
app.input_col += 1
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_popup(app, stdscr):
|
||||||
|
# Overlay window kecil di tengah layar untuk input path workspace
|
||||||
|
pw = min(60, app.w - 4)
|
||||||
|
ph = 3
|
||||||
|
px = (app.w - pw) // 2
|
||||||
|
py = app.h // 2 - 1
|
||||||
|
|
||||||
|
win = curses.newwin(ph, pw, py, px)
|
||||||
|
win.box()
|
||||||
|
win.addstr(0, 2, " Workspace path: ")
|
||||||
|
win.addstr(1, 2, " " * (pw - 4))
|
||||||
|
|
||||||
|
curses.echo() # tampilkan input user
|
||||||
|
ws = win.getstr(1, 2, pw - 5).decode("utf-8")
|
||||||
|
curses.noecho()
|
||||||
|
del win
|
||||||
|
|
||||||
|
ws = ws.strip()
|
||||||
|
if ws:
|
||||||
|
resolved = os.path.abspath(ws)
|
||||||
|
if os.path.isdir(resolved):
|
||||||
|
os.chdir(resolved)
|
||||||
|
log(app, "system", f"Workspace \u2192 {resolved}")
|
||||||
|
else:
|
||||||
|
log(app, "error", f"Invalid directory: {resolved}")
|
||||||
|
|
||||||
|
stdscr.touchwin()
|
||||||
|
stdscr.refresh()
|
||||||
272
tui/render.py
Normal file
272
tui/render.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
# render.py — Semua fungsi drawing / tampilan curses.
|
||||||
|
# Setiap fungsi menerima `app` (instance HendrikTUI) dan `stdscr`
|
||||||
|
# lalu membaca state dari `app` untuk menggambar di layar.
|
||||||
|
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
# -- Color pair IDs (id 1-9, id 0 = default curses) --
|
||||||
|
C_HEADER = 1 # header bar: biru
|
||||||
|
C_USER = 2 # user message: cyan
|
||||||
|
C_AI = 3 # AI response: hijau
|
||||||
|
C_SYSTEM = 4 # system log: kuning
|
||||||
|
C_INPUT = 5 # text input: putih
|
||||||
|
C_STATUS = 6 # status bar: hitam di atas kuning
|
||||||
|
C_STATUS_READY = 10 # status READY: hijau
|
||||||
|
C_STATUS_PROC = 11 # status PROCESSING: kuning
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def init_colors():
|
||||||
|
# Daftarkan semua color pair sekali di awal.
|
||||||
|
# -1 = foreground/background default terminal.
|
||||||
|
curses.init_pair(C_HEADER, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||||
|
curses.init_pair(C_USER, curses.COLOR_CYAN, -1)
|
||||||
|
curses.init_pair(C_AI, curses.COLOR_GREEN, -1)
|
||||||
|
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_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)
|
||||||
|
|
||||||
|
|
||||||
|
def draw(app, stdscr):
|
||||||
|
# Panggil keempat fungsi gambar secara berurutan.
|
||||||
|
# Urutan penting: input digambar paling akhir supaya kursor
|
||||||
|
# bisa dipindah di atas layer paling atas.
|
||||||
|
draw_header(app, stdscr)
|
||||||
|
draw_chat(app, stdscr)
|
||||||
|
draw_status(app, stdscr)
|
||||||
|
draw_input(app, stdscr)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_header(app, stdscr):
|
||||||
|
# Baris paling atas: " Hendrik AI Agent ─────── <model> "
|
||||||
|
w = app.w
|
||||||
|
name = " Hendrik AI Agent "
|
||||||
|
model = f" {app.llm.model} "
|
||||||
|
mid = w - len(model) - 1
|
||||||
|
pad = max(1, mid - len(name) - 1)
|
||||||
|
line = name + "\u2500" * pad + " " + model
|
||||||
|
attr = curses.color_pair(C_HEADER) | curses.A_BOLD
|
||||||
|
stdscr.addstr(0, 0, line[:w], attr)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_chat(app, stdscr):
|
||||||
|
# Area chat — dari baris 1 sampai baris (h - 10).
|
||||||
|
# Bisa di-scroll dengan Page Up / Page Down.
|
||||||
|
# app.log berisi daftar item (role, text, time) untuk display.
|
||||||
|
h, w = app.h, app.w
|
||||||
|
chat_top = 1
|
||||||
|
chat_h = h - 10
|
||||||
|
if chat_h <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Render log ke list of (color, text) agar scroll calculation akurat
|
||||||
|
# Setiap baris di-wrap sesuai lebar terminal
|
||||||
|
rendered = []
|
||||||
|
|
||||||
|
def _wrap_render(text, indent=0, color=C_INPUT):
|
||||||
|
available = w - indent
|
||||||
|
if available <= 0:
|
||||||
|
rendered.append((color, " " * indent))
|
||||||
|
return
|
||||||
|
for line in text.split("\n"):
|
||||||
|
if not line:
|
||||||
|
rendered.append((color, " " * indent))
|
||||||
|
continue
|
||||||
|
start = 0
|
||||||
|
while start < len(line):
|
||||||
|
chunk = line[start:start + available]
|
||||||
|
rendered.append((color, " " * indent + chunk))
|
||||||
|
start += available
|
||||||
|
|
||||||
|
for idx, item in enumerate(app.log):
|
||||||
|
role, text = item["role"], item["text"]
|
||||||
|
if role == "sep":
|
||||||
|
rendered.append((None, ""))
|
||||||
|
rendered.append((None, ""))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Tambah blank line sebelum system log setelah user/ai response
|
||||||
|
if role == "system" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
||||||
|
rendered.append((None, ""))
|
||||||
|
|
||||||
|
# Tambah blank line sebelum ai response setelah user (langsung, tanpa tools)
|
||||||
|
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] in ("user", "ai"):
|
||||||
|
rendered.append((None, ""))
|
||||||
|
|
||||||
|
# Tambah blank line sebelum ai response setelah system log
|
||||||
|
if role == "ai" and idx > 0 and app.log[idx - 1]["role"] == "system":
|
||||||
|
rendered.append((None, ""))
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
label = f" You ({item['time']}) "
|
||||||
|
rendered.append((C_USER, label))
|
||||||
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
|
elif role == "ai":
|
||||||
|
label = f" Hendrik ({item['time']}) "
|
||||||
|
rendered.append((C_AI, label))
|
||||||
|
_wrap_render(text, indent=1, color=C_INPUT)
|
||||||
|
elif role == "system":
|
||||||
|
lines = text.split("\n")
|
||||||
|
rendered.append((C_SYSTEM, lines[0]))
|
||||||
|
for line in lines[1:]:
|
||||||
|
rendered.append((C_SYSTEM, " " + line))
|
||||||
|
elif role == "error":
|
||||||
|
label = " \u2717 "
|
||||||
|
lines = text.split("\n")
|
||||||
|
rendered.append((C_ERROR, label + (lines[0] if lines else "")))
|
||||||
|
for line in lines[1:]:
|
||||||
|
rendered.append((C_ERROR, " " + line))
|
||||||
|
|
||||||
|
# Clamp scroll agar tidak melebihi total baris
|
||||||
|
total = len(rendered)
|
||||||
|
max_scroll = max(0, total - chat_h)
|
||||||
|
if app.scroll > max_scroll:
|
||||||
|
app.scroll = max_scroll
|
||||||
|
app.scroll = max(0, app.scroll)
|
||||||
|
|
||||||
|
# Gambar baris yang terlihat (scroll sampai scroll + chat_h)
|
||||||
|
# Clear area chat dulu biar nggak ada text lama yang nyisa
|
||||||
|
for clear_y in range(chat_top, chat_top + chat_h):
|
||||||
|
try:
|
||||||
|
stdscr.addstr(clear_y, 0, " " * w, curses.A_NORMAL)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
y = chat_top
|
||||||
|
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]
|
||||||
|
try:
|
||||||
|
stdscr.addstr(y, 0, text, attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
y += 1
|
||||||
|
|
||||||
|
|
||||||
|
def draw_input(app, stdscr):
|
||||||
|
# Kotak input multi-line (maks 6 baris visual).
|
||||||
|
# Ada border atas dan bawah.
|
||||||
|
# Baris aktif ditandai "> " di depannya.
|
||||||
|
# Soft word wrap: logical line panjang dipecah jadi beberapa visual line.
|
||||||
|
h, w = app.h, app.w
|
||||||
|
iy = h - 8
|
||||||
|
border_attr = curses.color_pair(C_INPUT_BORDER) | curses.A_BOLD
|
||||||
|
text_attr = curses.color_pair(C_INPUT)
|
||||||
|
max_chars = w - 6 # space for text (border + "> " = 4 chars, border = 2 chars)
|
||||||
|
|
||||||
|
# Border atas ┌───┐ dan bawah └───┘
|
||||||
|
for off, text in [
|
||||||
|
(0, "\u250c" + "\u2500" * (w - 2) + "\u2510"),
|
||||||
|
(7, "\u2514" + "\u2500" * (w - 2) + "\u2518"),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
stdscr.addstr(iy + off, 0, text[:w], border_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build visual lines dari buffer dengan soft wrap
|
||||||
|
visual = [] # list of (logical_line_idx, start_col, text_chunk)
|
||||||
|
for i, line in enumerate(app.input_buffer):
|
||||||
|
if not line:
|
||||||
|
visual.append((i, 0, ""))
|
||||||
|
else:
|
||||||
|
for start in range(0, max(len(line), 1), max_chars):
|
||||||
|
chunk = line[start:start + max_chars]
|
||||||
|
visual.append((i, start, chunk))
|
||||||
|
|
||||||
|
total_visual = len(visual)
|
||||||
|
|
||||||
|
# Find cursor visual line
|
||||||
|
cur_visual = 0
|
||||||
|
for idx, (li, start, text) in enumerate(visual):
|
||||||
|
if li == app.input_line and start <= app.input_col <= start + len(text):
|
||||||
|
cur_visual = idx
|
||||||
|
break
|
||||||
|
if li == app.input_line and app.input_col > start + len(text):
|
||||||
|
cur_visual = idx # edge: cursor at end of last chunk
|
||||||
|
|
||||||
|
# Scroll input visual lines supaya cursor terlihat
|
||||||
|
if total_visual <= 6:
|
||||||
|
show = 0
|
||||||
|
else:
|
||||||
|
show = max(0, min(cur_visual - 3, total_visual - 6))
|
||||||
|
|
||||||
|
cursor_yx = None
|
||||||
|
for i in range(6):
|
||||||
|
idx = show + i
|
||||||
|
y = iy + 1 + i
|
||||||
|
if idx < total_visual:
|
||||||
|
li, start, chunk = visual[idx]
|
||||||
|
line_is_active = (li == app.input_line)
|
||||||
|
|
||||||
|
# Border kiri
|
||||||
|
try:
|
||||||
|
stdscr.addstr(y, 0, "\u2502 ", border_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Isi baris
|
||||||
|
if line_is_active:
|
||||||
|
prefix = "> "
|
||||||
|
else:
|
||||||
|
prefix = " "
|
||||||
|
|
||||||
|
content = prefix + chunk + " " * (w - 4 - len(prefix) - len(chunk))
|
||||||
|
try:
|
||||||
|
stdscr.addstr(y, 2, content, text_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cursor position
|
||||||
|
if line_is_active and not app.processing:
|
||||||
|
col_on_visual = app.input_col - start
|
||||||
|
cursor_yx = (y, 4 + min(col_on_visual, len(chunk)))
|
||||||
|
|
||||||
|
# Border kanan
|
||||||
|
try:
|
||||||
|
stdscr.addstr(y, w - 2, " \u2502", border_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Empty row
|
||||||
|
try:
|
||||||
|
stdscr.addstr(y, 0, "\u2502" + " " * (w - 2) + "\u2502", border_attr)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if cursor_yx:
|
||||||
|
stdscr.move(*cursor_yx)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_status(app, stdscr):
|
||||||
|
# Status bar di baris h-9: workspace, mode (READY/PROCESSING), shortcut hints
|
||||||
|
h, w = app.h, app.w
|
||||||
|
y = h - 9
|
||||||
|
ws = os.getcwd()
|
||||||
|
mode = " PROCESSING " if app.processing else " READY "
|
||||||
|
hints = " ^D:send ^W:workspace ^C:exit "
|
||||||
|
max_ws = w - len(mode) - len(hints) - 4
|
||||||
|
if len(ws) > max_ws:
|
||||||
|
ws = ".." + ws[-(max_ws - 2):]
|
||||||
|
|
||||||
|
status = f" {ws} \u2502{mode}\u2502{hints}"
|
||||||
|
stdscr.addstr(y, 0, status[:w], curses.color_pair(C_STATUS_INFO))
|
||||||
|
|
||||||
|
# Highlight mode dengan warna berbeda
|
||||||
|
mode_start = len(f" {ws} \u2502")
|
||||||
|
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)
|
||||||
Loading…
Reference in New Issue
Block a user