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.
Files changed (74) hide show
  1. agentkernel/__init__.py +7 -0
  2. agentkernel/__main__.py +5 -0
  3. agentkernel/agent.py +311 -0
  4. agentkernel/approval/__init__.py +23 -0
  5. agentkernel/approval/base.py +34 -0
  6. agentkernel/approval/cli.py +129 -0
  7. agentkernel/approval/policy.py +58 -0
  8. agentkernel/approval/risk.py +91 -0
  9. agentkernel/approval/sandbox.py +201 -0
  10. agentkernel/budget.py +64 -0
  11. agentkernel/checkpoint.py +50 -0
  12. agentkernel/cli.py +1482 -0
  13. agentkernel/config.py +224 -0
  14. agentkernel/context/__init__.py +17 -0
  15. agentkernel/context/manager.py +216 -0
  16. agentkernel/context/truncate.py +35 -0
  17. agentkernel/cron.py +146 -0
  18. agentkernel/curation.py +183 -0
  19. agentkernel/doctor.py +141 -0
  20. agentkernel/embeddings.py +132 -0
  21. agentkernel/evaluation.py +186 -0
  22. agentkernel/improvement.py +133 -0
  23. agentkernel/insights.py +141 -0
  24. agentkernel/kanban.py +114 -0
  25. agentkernel/knowledge.py +383 -0
  26. agentkernel/loops.py +145 -0
  27. agentkernel/mcp/__init__.py +23 -0
  28. agentkernel/mcp/client.py +181 -0
  29. agentkernel/mcp/config.py +59 -0
  30. agentkernel/mcp/tools.py +96 -0
  31. agentkernel/memory.py +1208 -0
  32. agentkernel/paths.py +73 -0
  33. agentkernel/plugins.py +76 -0
  34. agentkernel/profiles.py +70 -0
  35. agentkernel/progress.py +89 -0
  36. agentkernel/providers/__init__.py +35 -0
  37. agentkernel/providers/_http.py +157 -0
  38. agentkernel/providers/anthropic.py +282 -0
  39. agentkernel/providers/base.py +38 -0
  40. agentkernel/providers/credentials.py +65 -0
  41. agentkernel/providers/local.py +34 -0
  42. agentkernel/providers/openai.py +260 -0
  43. agentkernel/redaction.py +77 -0
  44. agentkernel/semantic_index.py +139 -0
  45. agentkernel/semantic_memory.py +253 -0
  46. agentkernel/skills.py +268 -0
  47. agentkernel/subagent.py +161 -0
  48. agentkernel/telemetry.py +199 -0
  49. agentkernel/templates/README.md +35 -0
  50. agentkernel/templates/SKILL.md +28 -0
  51. agentkernel/templates/eval-suite.toml +22 -0
  52. agentkernel/templates/loop.toml +29 -0
  53. agentkernel/templates/mcp-servers.toml +22 -0
  54. agentkernel/templates/profile.toml +29 -0
  55. agentkernel/templates/tool_module.py +64 -0
  56. agentkernel/tools/__init__.py +5 -0
  57. agentkernel/tools/base.py +100 -0
  58. agentkernel/tools/builtin/__init__.py +37 -0
  59. agentkernel/tools/builtin/checkpoint_tool.py +33 -0
  60. agentkernel/tools/builtin/clarify.py +60 -0
  61. agentkernel/tools/builtin/files.py +221 -0
  62. agentkernel/tools/builtin/kanban_tool.py +100 -0
  63. agentkernel/tools/builtin/search.py +225 -0
  64. agentkernel/tools/builtin/shell.py +67 -0
  65. agentkernel/tools/builtin/todo.py +106 -0
  66. agentkernel/tui/__init__.py +50 -0
  67. agentkernel/tui/app.py +594 -0
  68. agentkernel/types.py +127 -0
  69. agentkernel/worktree.py +64 -0
  70. agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
  71. agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
  72. agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
  73. agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. 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()