agentkernel-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentkernel/__init__.py +7 -0
- agentkernel/__main__.py +5 -0
- agentkernel/agent.py +311 -0
- agentkernel/approval/__init__.py +23 -0
- agentkernel/approval/base.py +34 -0
- agentkernel/approval/cli.py +129 -0
- agentkernel/approval/policy.py +58 -0
- agentkernel/approval/risk.py +91 -0
- agentkernel/approval/sandbox.py +201 -0
- agentkernel/budget.py +64 -0
- agentkernel/checkpoint.py +50 -0
- agentkernel/cli.py +1482 -0
- agentkernel/config.py +224 -0
- agentkernel/context/__init__.py +17 -0
- agentkernel/context/manager.py +216 -0
- agentkernel/context/truncate.py +35 -0
- agentkernel/cron.py +146 -0
- agentkernel/curation.py +183 -0
- agentkernel/doctor.py +141 -0
- agentkernel/embeddings.py +132 -0
- agentkernel/evaluation.py +186 -0
- agentkernel/improvement.py +133 -0
- agentkernel/insights.py +141 -0
- agentkernel/kanban.py +114 -0
- agentkernel/knowledge.py +383 -0
- agentkernel/loops.py +145 -0
- agentkernel/mcp/__init__.py +23 -0
- agentkernel/mcp/client.py +181 -0
- agentkernel/mcp/config.py +59 -0
- agentkernel/mcp/tools.py +96 -0
- agentkernel/memory.py +1208 -0
- agentkernel/paths.py +73 -0
- agentkernel/plugins.py +76 -0
- agentkernel/profiles.py +70 -0
- agentkernel/progress.py +89 -0
- agentkernel/providers/__init__.py +35 -0
- agentkernel/providers/_http.py +157 -0
- agentkernel/providers/anthropic.py +282 -0
- agentkernel/providers/base.py +38 -0
- agentkernel/providers/credentials.py +65 -0
- agentkernel/providers/local.py +34 -0
- agentkernel/providers/openai.py +260 -0
- agentkernel/redaction.py +77 -0
- agentkernel/semantic_index.py +139 -0
- agentkernel/semantic_memory.py +253 -0
- agentkernel/skills.py +268 -0
- agentkernel/subagent.py +161 -0
- agentkernel/telemetry.py +199 -0
- agentkernel/templates/README.md +35 -0
- agentkernel/templates/SKILL.md +28 -0
- agentkernel/templates/eval-suite.toml +22 -0
- agentkernel/templates/loop.toml +29 -0
- agentkernel/templates/mcp-servers.toml +22 -0
- agentkernel/templates/profile.toml +29 -0
- agentkernel/templates/tool_module.py +64 -0
- agentkernel/tools/__init__.py +5 -0
- agentkernel/tools/base.py +100 -0
- agentkernel/tools/builtin/__init__.py +37 -0
- agentkernel/tools/builtin/checkpoint_tool.py +33 -0
- agentkernel/tools/builtin/clarify.py +60 -0
- agentkernel/tools/builtin/files.py +221 -0
- agentkernel/tools/builtin/kanban_tool.py +100 -0
- agentkernel/tools/builtin/search.py +225 -0
- agentkernel/tools/builtin/shell.py +67 -0
- agentkernel/tools/builtin/todo.py +106 -0
- agentkernel/tui/__init__.py +50 -0
- agentkernel/tui/app.py +594 -0
- agentkernel/types.py +127 -0
- agentkernel/worktree.py +64 -0
- agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
- agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
- agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
- agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agentkernel/tui/app.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Curses TUI application — chat panes, input, status bar, and background agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
# curses is imported lazily inside TuiApp.run() so the module can be imported
|
|
11
|
+
# on platforms where curses is unavailable (e.g., Windows without windows-curses).
|
|
12
|
+
# Helper functions like _wrap_text do not depend on self._c.
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from agentkernel.config import Config
|
|
16
|
+
|
|
17
|
+
# ── constants ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
_SPINNER = "|/-\\"
|
|
20
|
+
_SEND_KEY = ord("\n") # Enter sends the current input
|
|
21
|
+
_QUIT_KEYS = {27, ord("q")} # Esc or 'q' to quit (Esc is 27)
|
|
22
|
+
_SCROLL_UP = {339, 259} # KEY_PPAGE, KEY_UP
|
|
23
|
+
_SCROLL_DOWN = {338, 258} # KEY_NPAGE, KEY_DOWN
|
|
24
|
+
|
|
25
|
+
_COLOR_USER = 1
|
|
26
|
+
_COLOR_ASSISTANT = 2
|
|
27
|
+
_COLOR_TOOL = 3
|
|
28
|
+
_COLOR_SYSTEM = 4
|
|
29
|
+
_COLOR_STATUS = 5
|
|
30
|
+
_COLOR_INPUT_BORDER = 6
|
|
31
|
+
_COLOR_SPINNER = 7
|
|
32
|
+
_COLOR_TITLE = 8
|
|
33
|
+
|
|
34
|
+
_APP_NAME = "agentkernel"
|
|
35
|
+
_TAGLINE = "a minimal kernel for a general-purpose AI agent"
|
|
36
|
+
|
|
37
|
+
# Shown once at the top of the chat on startup.
|
|
38
|
+
_WELCOME = (
|
|
39
|
+
f"Welcome to {_APP_NAME} — {_TAGLINE}.\n"
|
|
40
|
+
"\n"
|
|
41
|
+
"• Type a message and press Enter to chat with the agent.\n"
|
|
42
|
+
"• Slash commands: /help /tools /clear /model /cost /trace /exit\n"
|
|
43
|
+
"• Scroll: PgUp/PgDn or ↑/↓ Quit: Esc (or /exit)\n"
|
|
44
|
+
"\n"
|
|
45
|
+
"Type /help any time for the full list."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Lines rendered by the /help command.
|
|
49
|
+
_HELP = (
|
|
50
|
+
"Commands\n"
|
|
51
|
+
" /help show this help\n"
|
|
52
|
+
" /tools list the tools available to the agent\n"
|
|
53
|
+
" /model show the active provider, model, and working directory\n"
|
|
54
|
+
" /clear clear the conversation from the screen\n"
|
|
55
|
+
" /cost cost + token usage of the last run\n"
|
|
56
|
+
" /trace path to the last session's JSONL trace\n"
|
|
57
|
+
" /exit, /quit leave the TUI\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"Keys\n"
|
|
60
|
+
" Enter send the current message\n"
|
|
61
|
+
" PgUp / PgDn scroll the conversation (↑ / ↓ also work)\n"
|
|
62
|
+
" Esc or q quit (press twice while the agent is working)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── message model ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class _DisplayMessage:
|
|
70
|
+
role: str # "user", "assistant", "tool"
|
|
71
|
+
content: str
|
|
72
|
+
iteration: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── application ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
class TuiApp:
|
|
78
|
+
"""Manages the curses screen, input, and background agent execution."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: Config) -> None:
|
|
81
|
+
self._config = config
|
|
82
|
+
self._messages: list[_DisplayMessage] = [
|
|
83
|
+
_DisplayMessage(role="system", content=_WELCOME)
|
|
84
|
+
]
|
|
85
|
+
self._scroll_offset = 0 # lines scrolled back (0 = bottom)
|
|
86
|
+
self._input_text = ""
|
|
87
|
+
self._cursor_pos = 0
|
|
88
|
+
self._status = "Ready · type a message, or /help for commands"
|
|
89
|
+
self._running = True
|
|
90
|
+
|
|
91
|
+
# Surfaced by the /tools, /cost, /trace commands; populated after a run.
|
|
92
|
+
self._tool_lines: list[str] = []
|
|
93
|
+
self._last_trace: str | None = None
|
|
94
|
+
self._last_cost: float | None = None
|
|
95
|
+
|
|
96
|
+
# Background agent state
|
|
97
|
+
self._agent_thread: threading.Thread | None = None
|
|
98
|
+
self._agent_result: str | None = None
|
|
99
|
+
self._agent_error: str | None = None
|
|
100
|
+
self._agent_done = threading.Event()
|
|
101
|
+
self._agent_done.set() # no agent in flight initially → ready to submit
|
|
102
|
+
self._spinner_idx = 0
|
|
103
|
+
|
|
104
|
+
# Agent components — built lazily on first message
|
|
105
|
+
self._agent = None
|
|
106
|
+
self._telemetry = None
|
|
107
|
+
self._mcp_clients: list = []
|
|
108
|
+
|
|
109
|
+
# curses objects set in run()
|
|
110
|
+
self._stdscr: object | None = None
|
|
111
|
+
self._title_win: object | None = None
|
|
112
|
+
self._chat_pad: object | None = None
|
|
113
|
+
self._input_win: object | None = None
|
|
114
|
+
self._status_win: object | None = None
|
|
115
|
+
self._max_y = 0
|
|
116
|
+
self._max_x = 0
|
|
117
|
+
|
|
118
|
+
# ── public entry point ────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def run(self, stdscr) -> int:
|
|
121
|
+
import curses as _c
|
|
122
|
+
self._c = _c # lazy curses import for cross-platform compat
|
|
123
|
+
"""Main event loop. ``self._c.wrapper`` passes the stdscr."""
|
|
124
|
+
self._stdscr = stdscr
|
|
125
|
+
self._init_colors()
|
|
126
|
+
self._c.curs_set(1) # show cursor in input area
|
|
127
|
+
stdscr.timeout(80) # 80 ms getch timeout → polls ~12 fps, no spin
|
|
128
|
+
|
|
129
|
+
self._resize()
|
|
130
|
+
self._dirty = True # force initial draw
|
|
131
|
+
|
|
132
|
+
while self._running:
|
|
133
|
+
dirty = self._dirty
|
|
134
|
+
self._dirty = False
|
|
135
|
+
self._poll_agent()
|
|
136
|
+
if dirty or self._dirty:
|
|
137
|
+
self._draw()
|
|
138
|
+
self._handle_input()
|
|
139
|
+
|
|
140
|
+
self._cleanup()
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
# ── drawing ────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
def _init_colors(self) -> None:
|
|
146
|
+
self._c.start_color()
|
|
147
|
+
self._c.use_default_colors()
|
|
148
|
+
self._c.init_pair(_COLOR_USER, self._c.COLOR_CYAN, -1)
|
|
149
|
+
self._c.init_pair(_COLOR_ASSISTANT, self._c.COLOR_GREEN, -1)
|
|
150
|
+
self._c.init_pair(_COLOR_TOOL, self._c.COLOR_YELLOW, -1)
|
|
151
|
+
self._c.init_pair(_COLOR_SYSTEM, self._c.COLOR_MAGENTA, -1)
|
|
152
|
+
self._c.init_pair(_COLOR_STATUS, self._c.COLOR_BLACK, self._c.COLOR_WHITE)
|
|
153
|
+
self._c.init_pair(_COLOR_INPUT_BORDER, self._c.COLOR_BLUE, -1)
|
|
154
|
+
self._c.init_pair(_COLOR_SPINNER, self._c.COLOR_YELLOW, -1)
|
|
155
|
+
self._c.init_pair(_COLOR_TITLE, self._c.COLOR_WHITE, self._c.COLOR_BLUE)
|
|
156
|
+
|
|
157
|
+
def _resize(self) -> None:
|
|
158
|
+
"""Recompute sub-window geometries after a terminal resize."""
|
|
159
|
+
self._max_y, self._max_x = self._stdscr.getmaxyx()
|
|
160
|
+
|
|
161
|
+
title_height = 1
|
|
162
|
+
input_height = 4 # border + 2 content lines
|
|
163
|
+
status_height = 1
|
|
164
|
+
chat_top = title_height
|
|
165
|
+
chat_height = self._max_y - title_height - input_height - status_height
|
|
166
|
+
|
|
167
|
+
# Title / brand bar (very top)
|
|
168
|
+
self._title_win = self._c.newwin(title_height, self._max_x, 0, 0)
|
|
169
|
+
|
|
170
|
+
# Chat pad — virtual scrollable area, viewport into the main screen
|
|
171
|
+
self._chat_pad = self._c.newpad(max(chat_height * 2, 1024), self._max_x)
|
|
172
|
+
self._chat_pad.scrollok(True)
|
|
173
|
+
self._chat_vp_height = chat_height
|
|
174
|
+
self._chat_vp_width = self._max_x
|
|
175
|
+
self._chat_vp_y = chat_top
|
|
176
|
+
self._chat_vp_x = 0
|
|
177
|
+
|
|
178
|
+
# Input window (above status)
|
|
179
|
+
self._input_win = self._c.newwin(
|
|
180
|
+
input_height, self._max_x,
|
|
181
|
+
chat_top + chat_height, 0,
|
|
182
|
+
)
|
|
183
|
+
self._input_win.keypad(True)
|
|
184
|
+
# getch() reads from this window, so the poll timeout must live here (not
|
|
185
|
+
# only on stdscr) or it would block and freeze the spinner / background
|
|
186
|
+
# result handling. ~80 ms ≈ 12 fps when something is actually changing.
|
|
187
|
+
self._input_win.timeout(80)
|
|
188
|
+
|
|
189
|
+
# Status bar (very bottom)
|
|
190
|
+
self._status_win = self._c.newwin(
|
|
191
|
+
status_height, self._max_x,
|
|
192
|
+
self._max_y - 1, 0,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _draw(self) -> None:
|
|
196
|
+
"""Full redraw of all panes.
|
|
197
|
+
|
|
198
|
+
Uses the curses double-buffer pattern: clear the background and stage
|
|
199
|
+
every pane with ``noutrefresh`` (which only updates the virtual screen),
|
|
200
|
+
then push one ``doupdate``. Calling ``refresh`` per pane — or refreshing
|
|
201
|
+
stdscr last, on top of the panes — blanks what was just drawn and makes
|
|
202
|
+
the screen flicker. Input is staged last so the hardware cursor lands in
|
|
203
|
+
the message box.
|
|
204
|
+
"""
|
|
205
|
+
if self._stdscr is None:
|
|
206
|
+
return
|
|
207
|
+
self._stdscr.erase()
|
|
208
|
+
self._stdscr.noutrefresh() # clear the background first, virtually
|
|
209
|
+
|
|
210
|
+
self._draw_title()
|
|
211
|
+
self._draw_chat()
|
|
212
|
+
self._draw_status()
|
|
213
|
+
self._draw_input()
|
|
214
|
+
|
|
215
|
+
self._c.doupdate()
|
|
216
|
+
|
|
217
|
+
def _draw_title(self) -> None:
|
|
218
|
+
"""Brand bar: app name on the left, provider/model on the right."""
|
|
219
|
+
if self._title_win is None:
|
|
220
|
+
return
|
|
221
|
+
self._title_win.erase()
|
|
222
|
+
with contextlib.suppress(Exception):
|
|
223
|
+
self._title_win.bkgd(" ", self._c.color_pair(_COLOR_TITLE))
|
|
224
|
+
brand = f" {_APP_NAME} "
|
|
225
|
+
with contextlib.suppress(Exception):
|
|
226
|
+
self._title_win.addstr(
|
|
227
|
+
0, 0, brand, self._c.color_pair(_COLOR_TITLE) | self._c.A_BOLD
|
|
228
|
+
)
|
|
229
|
+
right = f"{self._config.provider} · {self._config.model} "
|
|
230
|
+
start = self._max_x - len(right)
|
|
231
|
+
if start > len(brand) + 1:
|
|
232
|
+
with contextlib.suppress(Exception):
|
|
233
|
+
self._title_win.addstr(0, start, right, self._c.color_pair(_COLOR_TITLE))
|
|
234
|
+
self._title_win.noutrefresh()
|
|
235
|
+
|
|
236
|
+
def _draw_chat(self) -> None:
|
|
237
|
+
if self._chat_pad is None:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
self._chat_pad.erase()
|
|
241
|
+
y = 0
|
|
242
|
+
color_role = {
|
|
243
|
+
"user": _COLOR_USER,
|
|
244
|
+
"assistant": _COLOR_ASSISTANT,
|
|
245
|
+
"tool": _COLOR_TOOL,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Render messages onto the virtual pad
|
|
249
|
+
for msg in self._messages:
|
|
250
|
+
prefix = {
|
|
251
|
+
"user": "▶ You",
|
|
252
|
+
"assistant": "■ Agent",
|
|
253
|
+
"tool": "⚙ Tool",
|
|
254
|
+
"system": f"✦ {_APP_NAME}",
|
|
255
|
+
}.get(msg.role, "?")
|
|
256
|
+
color = color_role.get(msg.role, _COLOR_SYSTEM)
|
|
257
|
+
|
|
258
|
+
# Header line
|
|
259
|
+
with contextlib.suppress(Exception):
|
|
260
|
+
self._chat_pad.addstr(
|
|
261
|
+
y, 0, f" {prefix} ", self._c.color_pair(color) | self._c.A_BOLD
|
|
262
|
+
)
|
|
263
|
+
y += 1
|
|
264
|
+
|
|
265
|
+
# Content lines (word-wrapped)
|
|
266
|
+
for line in self._wrap_text(msg.content, self._max_x - 4):
|
|
267
|
+
with contextlib.suppress(Exception):
|
|
268
|
+
self._chat_pad.addstr(y, 2, line[: self._max_x - 2])
|
|
269
|
+
y += 1
|
|
270
|
+
y += 1 # blank line between messages
|
|
271
|
+
|
|
272
|
+
# Compute scroll bounds
|
|
273
|
+
total_lines = y
|
|
274
|
+
viewport_lines = self._chat_vp_height
|
|
275
|
+
max_scroll = max(0, total_lines - viewport_lines)
|
|
276
|
+
self._scroll_offset = min(self._scroll_offset, max_scroll)
|
|
277
|
+
|
|
278
|
+
# Stage the viewport (composited by the single doupdate in _draw).
|
|
279
|
+
with contextlib.suppress(Exception):
|
|
280
|
+
self._chat_pad.noutrefresh(
|
|
281
|
+
self._scroll_offset, 0,
|
|
282
|
+
self._chat_vp_y, self._chat_vp_x,
|
|
283
|
+
self._chat_vp_y + viewport_lines - 1, self._chat_vp_x + self._chat_vp_width - 1,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def _draw_input(self) -> None:
|
|
287
|
+
if self._input_win is None:
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
self._input_win.erase()
|
|
291
|
+
h, w = self._input_win.getmaxyx()
|
|
292
|
+
|
|
293
|
+
# Border
|
|
294
|
+
self._input_win.border(0)
|
|
295
|
+
with contextlib.suppress(Exception):
|
|
296
|
+
self._input_win.addstr(
|
|
297
|
+
0, 2, " Message — Enter to send · /help · Esc to quit ",
|
|
298
|
+
self._c.color_pair(_COLOR_INPUT_BORDER),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
# Show input text with cursor
|
|
302
|
+
text = self._input_text
|
|
303
|
+
cursor = min(self._cursor_pos, len(text))
|
|
304
|
+
for row in range(min(2, h - 2)):
|
|
305
|
+
line_start = row * (w - 4)
|
|
306
|
+
line_text = text[line_start : line_start + w - 4]
|
|
307
|
+
with contextlib.suppress(Exception):
|
|
308
|
+
self._input_win.addstr(1 + row, 2, line_text)
|
|
309
|
+
|
|
310
|
+
# Position cursor
|
|
311
|
+
cursor_y = 1 + (cursor // (w - 4))
|
|
312
|
+
cursor_x = 2 + (cursor % (w - 4))
|
|
313
|
+
if cursor_y < h - 1:
|
|
314
|
+
with contextlib.suppress(Exception):
|
|
315
|
+
self._input_win.move(cursor_y, cursor_x)
|
|
316
|
+
self._input_win.noutrefresh()
|
|
317
|
+
|
|
318
|
+
def _draw_status(self) -> None:
|
|
319
|
+
if self._status_win is None:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
self._status_win.erase()
|
|
323
|
+
with contextlib.suppress(Exception):
|
|
324
|
+
self._status_win.bkgd(" ", self._c.color_pair(_COLOR_STATUS))
|
|
325
|
+
|
|
326
|
+
status = self._status[: self._max_x - 1]
|
|
327
|
+
with contextlib.suppress(Exception):
|
|
328
|
+
self._status_win.addstr(
|
|
329
|
+
0, 0, status.ljust(self._max_x - 1), self._c.color_pair(_COLOR_STATUS)
|
|
330
|
+
)
|
|
331
|
+
self._status_win.noutrefresh()
|
|
332
|
+
|
|
333
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _wrap_text(text: str, width: int) -> list[str]:
|
|
337
|
+
"""Word-wrap text to the given width."""
|
|
338
|
+
lines: list[str] = []
|
|
339
|
+
for paragraph in text.split("\n"):
|
|
340
|
+
if not paragraph:
|
|
341
|
+
lines.append("")
|
|
342
|
+
continue
|
|
343
|
+
words = paragraph.split()
|
|
344
|
+
current = ""
|
|
345
|
+
for word in words:
|
|
346
|
+
if len(current) + len(word) + 1 <= width:
|
|
347
|
+
current = f"{current} {word}".strip()
|
|
348
|
+
else:
|
|
349
|
+
if current:
|
|
350
|
+
lines.append(current)
|
|
351
|
+
# If a single word is too long, hard-break it
|
|
352
|
+
while len(word) > width:
|
|
353
|
+
lines.append(word[:width])
|
|
354
|
+
word = word[width:]
|
|
355
|
+
current = word
|
|
356
|
+
if current:
|
|
357
|
+
lines.append(current)
|
|
358
|
+
return lines
|
|
359
|
+
|
|
360
|
+
def _append_message(self, role: str, content: str) -> None:
|
|
361
|
+
self._messages.append(_DisplayMessage(role=role, content=content))
|
|
362
|
+
# Auto-scroll to bottom
|
|
363
|
+
self._scroll_offset = 999_999
|
|
364
|
+
|
|
365
|
+
# ── input handling ───────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
def _handle_input(self) -> None:
|
|
368
|
+
"""Read one key from the input window and dispatch."""
|
|
369
|
+
if self._input_win is None:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
key = self._input_win.getch()
|
|
374
|
+
except Exception:
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
if key == -1:
|
|
378
|
+
return # no input (timeout)
|
|
379
|
+
|
|
380
|
+
self._dirty = True # something changed
|
|
381
|
+
|
|
382
|
+
if key in _QUIT_KEYS:
|
|
383
|
+
# Warn once, then allow quit on next Esc.
|
|
384
|
+
if (
|
|
385
|
+
self._agent_thread
|
|
386
|
+
and self._agent_thread.is_alive()
|
|
387
|
+
and not getattr(self, "_quit_warned", False)
|
|
388
|
+
):
|
|
389
|
+
self._quit_warned = True
|
|
390
|
+
self._status = "Agent is running. Press Esc again to force quit."
|
|
391
|
+
self._dirty = True
|
|
392
|
+
return
|
|
393
|
+
self._running = False
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
if key == _SEND_KEY:
|
|
397
|
+
if not self._agent_done.is_set():
|
|
398
|
+
return # agent still running
|
|
399
|
+
self._submit()
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
# Scrolling when chat pad has focus (use Ctrl+Up/Down or Page keys)
|
|
403
|
+
if key in _SCROLL_UP:
|
|
404
|
+
self._scroll_offset = max(0, self._scroll_offset - 3)
|
|
405
|
+
return
|
|
406
|
+
if key in _SCROLL_DOWN:
|
|
407
|
+
self._scroll_offset += 3
|
|
408
|
+
return
|
|
409
|
+
|
|
410
|
+
# Text editing
|
|
411
|
+
if key in (self._c.KEY_BACKSPACE, 127, 8):
|
|
412
|
+
if self._cursor_pos > 0:
|
|
413
|
+
self._input_text = (
|
|
414
|
+
self._input_text[: self._cursor_pos - 1] + self._input_text[self._cursor_pos :]
|
|
415
|
+
)
|
|
416
|
+
self._cursor_pos -= 1
|
|
417
|
+
elif key == self._c.KEY_DC:
|
|
418
|
+
if self._cursor_pos < len(self._input_text):
|
|
419
|
+
self._input_text = (
|
|
420
|
+
self._input_text[: self._cursor_pos] + self._input_text[self._cursor_pos + 1 :]
|
|
421
|
+
)
|
|
422
|
+
elif key == self._c.KEY_LEFT:
|
|
423
|
+
self._cursor_pos = max(0, self._cursor_pos - 1)
|
|
424
|
+
elif key == self._c.KEY_RIGHT:
|
|
425
|
+
self._cursor_pos = min(len(self._input_text), self._cursor_pos + 1)
|
|
426
|
+
elif key == self._c.KEY_HOME:
|
|
427
|
+
self._cursor_pos = 0
|
|
428
|
+
elif key == self._c.KEY_END:
|
|
429
|
+
self._cursor_pos = len(self._input_text)
|
|
430
|
+
elif 32 <= key <= 126:
|
|
431
|
+
ch = chr(key)
|
|
432
|
+
self._input_text = (
|
|
433
|
+
self._input_text[: self._cursor_pos] + ch + self._input_text[self._cursor_pos :]
|
|
434
|
+
)
|
|
435
|
+
self._cursor_pos += 1
|
|
436
|
+
|
|
437
|
+
# ── agent dispatch ────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
def _submit(self) -> None:
|
|
440
|
+
text = self._input_text.strip()
|
|
441
|
+
if not text:
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
# Slash commands are handled locally and never reach the model.
|
|
445
|
+
if text.startswith("/"):
|
|
446
|
+
self._input_text = ""
|
|
447
|
+
self._cursor_pos = 0
|
|
448
|
+
self._handle_command(text)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
self._append_message("user", text)
|
|
452
|
+
self._input_text = ""
|
|
453
|
+
self._cursor_pos = 0
|
|
454
|
+
|
|
455
|
+
# Spawn background agent
|
|
456
|
+
self._agent_result = None
|
|
457
|
+
self._agent_error = None
|
|
458
|
+
self._agent_done.clear()
|
|
459
|
+
self._status = "Thinking..."
|
|
460
|
+
self._spinner_idx = 0
|
|
461
|
+
|
|
462
|
+
self._quit_warned = False
|
|
463
|
+
self._agent_thread = threading.Thread(target=self._run_agent, args=(text,), daemon=True)
|
|
464
|
+
self._agent_thread.start()
|
|
465
|
+
|
|
466
|
+
def _handle_command(self, text: str) -> None:
|
|
467
|
+
"""Run a TUI slash command. Output is shown as a system message."""
|
|
468
|
+
cmd, _, _ = text[1:].partition(" ")
|
|
469
|
+
cmd = cmd.lower()
|
|
470
|
+
|
|
471
|
+
if cmd in ("exit", "quit"):
|
|
472
|
+
self._running = False
|
|
473
|
+
return
|
|
474
|
+
if cmd == "clear":
|
|
475
|
+
self._messages = [_DisplayMessage(role="system", content=_WELCOME)]
|
|
476
|
+
self._scroll_offset = 0
|
|
477
|
+
self._status = "Conversation cleared"
|
|
478
|
+
self._dirty = True
|
|
479
|
+
return
|
|
480
|
+
if cmd in ("help", "?"):
|
|
481
|
+
self._append_message("system", _HELP)
|
|
482
|
+
self._status = "/help"
|
|
483
|
+
self._dirty = True
|
|
484
|
+
return
|
|
485
|
+
if cmd == "model":
|
|
486
|
+
wd = getattr(self._config, "working_dir", ".")
|
|
487
|
+
self._append_message(
|
|
488
|
+
"system",
|
|
489
|
+
f"provider: {self._config.provider}\n"
|
|
490
|
+
f"model: {self._config.model}\n"
|
|
491
|
+
f"working dir: {wd}",
|
|
492
|
+
)
|
|
493
|
+
self._dirty = True
|
|
494
|
+
return
|
|
495
|
+
if cmd == "tools":
|
|
496
|
+
if self._tool_lines:
|
|
497
|
+
body = "Tools available to the agent:\n" + "\n".join(self._tool_lines)
|
|
498
|
+
else:
|
|
499
|
+
body = "Send a message first — tools are listed after the agent starts."
|
|
500
|
+
self._append_message("system", body)
|
|
501
|
+
self._dirty = True
|
|
502
|
+
return
|
|
503
|
+
if cmd == "cost":
|
|
504
|
+
if self._last_cost is not None:
|
|
505
|
+
body = f"Last run cost: ${self._last_cost:.6f}"
|
|
506
|
+
else:
|
|
507
|
+
body = "No cost recorded yet (send a message first)."
|
|
508
|
+
self._append_message("system", body)
|
|
509
|
+
self._dirty = True
|
|
510
|
+
return
|
|
511
|
+
if cmd == "trace":
|
|
512
|
+
body = self._last_trace or "No trace yet (send a message first)."
|
|
513
|
+
self._append_message("system", f"Last session trace:\n{body}")
|
|
514
|
+
self._dirty = True
|
|
515
|
+
return
|
|
516
|
+
|
|
517
|
+
self._append_message("system", f"Unknown command: /{cmd} (try /help)")
|
|
518
|
+
self._dirty = True
|
|
519
|
+
|
|
520
|
+
def _run_agent(self, user_input: str) -> None:
|
|
521
|
+
try:
|
|
522
|
+
from agentkernel.cli import build_runtime
|
|
523
|
+
|
|
524
|
+
agent, telemetry, mcp_clients = build_runtime(self._config)
|
|
525
|
+
self._telemetry = telemetry
|
|
526
|
+
self._mcp_clients = mcp_clients
|
|
527
|
+
# Capture the tool catalog so /tools can show it without a rebuild.
|
|
528
|
+
self._tool_lines = [
|
|
529
|
+
f" {s.name}: {(s.description.splitlines()[0] if s.description else '')}"
|
|
530
|
+
for s in agent.registry.specs()
|
|
531
|
+
]
|
|
532
|
+
result = agent.run(user_input)
|
|
533
|
+
self._agent_result = result
|
|
534
|
+
except Exception as exc:
|
|
535
|
+
self._agent_error = str(exc)
|
|
536
|
+
finally:
|
|
537
|
+
self._agent_done.set()
|
|
538
|
+
|
|
539
|
+
def _poll_agent(self) -> None:
|
|
540
|
+
"""Check whether the background agent has finished and collect its result."""
|
|
541
|
+
if self._agent_done.is_set() and self._agent_thread is not None:
|
|
542
|
+
self._agent_thread.join(timeout=0.1)
|
|
543
|
+
|
|
544
|
+
if self._agent_error:
|
|
545
|
+
self._append_message("assistant", f"[Error] {self._agent_error}")
|
|
546
|
+
self._status = f"Error: {self._agent_error[:60]}"
|
|
547
|
+
elif self._agent_result is not None:
|
|
548
|
+
self._append_message("assistant", self._agent_result)
|
|
549
|
+
self._status = "Ready"
|
|
550
|
+
else:
|
|
551
|
+
self._status = "Ready"
|
|
552
|
+
|
|
553
|
+
# Leave _agent_done SET: it doubles as the "ready to submit" gate, and
|
|
554
|
+
# clearing _agent_thread already prevents re-collecting this result.
|
|
555
|
+
# (Clearing the event here is what blocked every message after the
|
|
556
|
+
# first.) The next _submit clears it when it starts a new run.
|
|
557
|
+
self._agent_thread = None
|
|
558
|
+
self._dirty = True
|
|
559
|
+
self._scroll_offset = 999_999 # auto-scroll
|
|
560
|
+
|
|
561
|
+
# Stash trace path + cost for /trace and /cost before closing.
|
|
562
|
+
if self._telemetry is not None:
|
|
563
|
+
path = str(getattr(self._telemetry, "path", "") or "")
|
|
564
|
+
self._last_trace = path or self._last_trace
|
|
565
|
+
self._last_cost = getattr(self._telemetry, "cumulative_cost", self._last_cost)
|
|
566
|
+
|
|
567
|
+
# Clean up MCP clients and telemetry
|
|
568
|
+
if self._telemetry is not None:
|
|
569
|
+
with contextlib.suppress(Exception):
|
|
570
|
+
self._telemetry.close()
|
|
571
|
+
for client in self._mcp_clients:
|
|
572
|
+
with contextlib.suppress(Exception):
|
|
573
|
+
client.close()
|
|
574
|
+
self._telemetry = None
|
|
575
|
+
self._mcp_clients = []
|
|
576
|
+
|
|
577
|
+
elif self._agent_thread is not None and self._agent_thread.is_alive():
|
|
578
|
+
# Advance the spinner and request a redraw so it animates. Idle (no
|
|
579
|
+
# live thread) leaves _dirty alone, so the screen stays static.
|
|
580
|
+
self._spinner_idx = (self._spinner_idx + 1) % len(_SPINNER)
|
|
581
|
+
self._status = f" {_SPINNER[self._spinner_idx]} Thinking..."
|
|
582
|
+
self._dirty = True
|
|
583
|
+
|
|
584
|
+
def _cleanup(self) -> None:
|
|
585
|
+
"""Join any running agent thread and close resources."""
|
|
586
|
+
if self._agent_thread and self._agent_thread.is_alive():
|
|
587
|
+
self._agent_done.set()
|
|
588
|
+
self._agent_thread.join(timeout=2.0)
|
|
589
|
+
if self._telemetry is not None:
|
|
590
|
+
with contextlib.suppress(Exception):
|
|
591
|
+
self._telemetry.close()
|
|
592
|
+
for client in self._mcp_clients:
|
|
593
|
+
with contextlib.suppress(Exception):
|
|
594
|
+
client.close()
|