codex-agent-framework 0.1.3__tar.gz → 0.1.5__tar.gz

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 (51) hide show
  1. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/CHANGELOG.md +18 -0
  2. {codex_agent_framework-0.1.3/codex_agent_framework.egg-info → codex_agent_framework-0.1.5}/PKG-INFO +4 -2
  3. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/README.md +2 -0
  4. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/__init__.py +4 -0
  5. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/__main__.py +1 -1
  6. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/agent.py +20 -12
  7. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/builtin_commands.py +19 -0
  8. codex_agent_framework-0.1.5/codex_agent/chat.py +364 -0
  9. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/event.py +17 -0
  10. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5/codex_agent_framework.egg-info}/PKG-INFO +4 -2
  11. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent_framework.egg-info/SOURCES.txt +1 -0
  12. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent_framework.egg-info/requires.txt +1 -1
  13. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/pyproject.toml +2 -2
  14. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_agent.py +19 -1
  15. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_chat.py +4 -29
  16. codex_agent_framework-0.1.5/tests/test_cli.py +22 -0
  17. codex_agent_framework-0.1.3/codex_agent/chat.py +0 -146
  18. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/LICENSE +0 -0
  19. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/MANIFEST.in +0 -0
  20. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/ai.py +0 -0
  21. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/builtin_providers.py +0 -0
  22. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/builtin_tools.py +0 -0
  23. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/command.py +0 -0
  24. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/get_text/__init__.py +0 -0
  25. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/get_text/default_gitignore +0 -0
  26. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/get_text/get_text.py +0 -0
  27. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/get_text/simpler_get_text.py +0 -0
  28. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/get_webdriver.py +0 -0
  29. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/image.py +0 -0
  30. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/latex.py +0 -0
  31. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/memory.py +0 -0
  32. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/message.py +0 -0
  33. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/prompts/image_generation_system_prompt.txt +0 -0
  34. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/prompts/system_prompt.txt +0 -0
  35. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/provider.py +0 -0
  36. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/stream_utils.py +0 -0
  37. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/tool.py +0 -0
  38. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/utils.py +0 -0
  39. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/voice.py +0 -0
  40. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent/worker.py +0 -0
  41. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent_framework.egg-info/dependency_links.txt +0 -0
  42. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent_framework.egg-info/entry_points.txt +0 -0
  43. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/codex_agent_framework.egg-info/top_level.txt +0 -0
  44. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/setup.cfg +0 -0
  45. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_ai.py +0 -0
  46. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_events.py +0 -0
  47. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_image_message.py +0 -0
  48. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_memory.py +0 -0
  49. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_messages.py +0 -0
  50. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_utils.py +0 -0
  51. {codex_agent_framework-0.1.3 → codex_agent_framework-0.1.5}/tests/test_worker.py +0 -0
@@ -3,6 +3,24 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning where practical.
6
+ ## [0.1.5] - 2026-05-03
7
+ ### Changed
8
+ - Refine the default Textual chat UI with a pure black background, black multiline input area, subtler scrollbars, a smaller step-end marker, and English UI labels.
9
+
10
+
11
+ ## [0.1.4] - 2026-05-03
12
+ ### Added
13
+ - Make the Textual REPL UI the default chat interface, with multiline input, subdued colors, visual speaker separators, step-end markers, and cleaner tool-call rendering.
14
+ - Add assistant turn and assistant step lifecycle events for UI and runtime integrations.
15
+ - Add a `/voice` command to show, toggle, or select the configured voice mode.
16
+
17
+ ### Changed
18
+ - Remove the legacy prompt-toolkit chat UI and the temporary `--textual` CLI flag.
19
+
20
+ ### Tests
21
+ - Add CLI coverage for starting the default chat UI and update chat status formatting tests.
22
+
23
+
6
24
  ## [0.1.3] - 2026-05-03
7
25
  ### Added
8
26
  - Send the current session id as `prompt_cache_key` to the Codex backend, enabling server-side prompt cache reuse across calls from the same session.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-agent-framework
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A lightweight event-driven Codex agent runtime.
5
5
  Author: Baptiste
6
6
  License-Expression: MIT
@@ -26,7 +26,6 @@ Requires-Dist: openai
26
26
  Requires-Dist: openpyxl
27
27
  Requires-Dist: pathspec
28
28
  Requires-Dist: pillow
29
- Requires-Dist: prompt_toolkit
30
29
  Requires-Dist: pydub
31
30
  Requires-Dist: PyPDF2
32
31
  Requires-Dist: pynteract
@@ -36,6 +35,7 @@ Requires-Dist: regex
36
35
  Requires-Dist: requests
37
36
  Requires-Dist: rich
38
37
  Requires-Dist: selenium
38
+ Requires-Dist: textual
39
39
  Requires-Dist: tiktoken
40
40
  Requires-Dist: trafilatura
41
41
  Provides-Extra: dev
@@ -342,6 +342,8 @@ python -m build
342
342
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
343
343
 
344
344
  ## Recent changes
345
+ - `0.1.5`: refine the Textual chat UI with pure black backgrounds, subtler markers/scrollbars, and English UI labels.
346
+ - `0.1.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
345
347
  - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
346
348
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
347
349
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
@@ -295,6 +295,8 @@ python -m build
295
295
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
296
296
 
297
297
  ## Recent changes
298
+ - `0.1.5`: refine the Textual chat UI with pure black backgrounds, subtler markers/scrollbars, and English UI labels.
299
+ - `0.1.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
298
300
  - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
299
301
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
300
302
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
@@ -2,6 +2,10 @@ from .agent import Agent, AgentSession
2
2
  from .event import (
3
3
  AgentInterrupted,
4
4
  AgentInterruptedEvent,
5
+ AssistantStepEndEvent,
6
+ AssistantStepStartEvent,
7
+ AssistantTurnEndEvent,
8
+ AssistantTurnStartEvent,
5
9
  AudioPlaybackEvent,
6
10
  Event,
7
11
  EventBus,
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  from . import Agent
4
4
 
5
5
 
6
- def main():
6
+ def main(argv=None):
7
7
  package_dir = Path(__file__).parent
8
8
  agent = Agent(
9
9
  system=str(package_dir / 'prompts' / 'system_prompt.txt'),
@@ -19,7 +19,7 @@ from .ai import AIClient
19
19
  from .worker import TurnSummaryWorker
20
20
  from .voice import VoiceProcessor
21
21
  from .latex import LaTeXProcessor
22
- from .event import AgentInterrupted, AgentInterruptedEvent, AudioPlaybackEvent, EventBus, MessageAddedEvent, ToolCallDoneEvent, ToolCallStartEvent
22
+ from .event import AgentInterrupted, AgentInterruptedEvent, AssistantStepEndEvent, AssistantStepStartEvent, AssistantTurnEndEvent, AssistantTurnStartEvent, AudioPlaybackEvent, EventBus, MessageAddedEvent, ToolCallDoneEvent, ToolCallStartEvent
23
23
  from pynteract import Shell
24
24
  from datetime import datetime
25
25
  from threading import Event as ThreadEvent, Thread as BackgroundThread
@@ -1056,15 +1056,24 @@ class Agent:
1056
1056
 
1057
1057
  self.clear_interrupt()
1058
1058
  self.age_context_messages()
1059
+ turn_message = None
1060
+ self.emit(AssistantTurnStartEvent)
1059
1061
  try:
1060
1062
  while True:
1061
1063
  self.check_interrupt()
1062
1064
  self.dump_pending()
1063
- message=self.get_response()
1064
- ThreadPool.join_all()
1065
- self.check_interrupt()
1066
- called_tools=self.call_tools(message)
1067
- self.dump_pending()
1065
+ message = None
1066
+ called_tools = False
1067
+ self.emit(AssistantStepStartEvent)
1068
+ try:
1069
+ message=self.get_response()
1070
+ turn_message = message
1071
+ ThreadPool.join_all()
1072
+ self.check_interrupt()
1073
+ called_tools=self.call_tools(message)
1074
+ self.dump_pending()
1075
+ finally:
1076
+ self.emit(AssistantStepEndEvent, message=message, called_tools=called_tools)
1068
1077
 
1069
1078
  if called_tools:
1070
1079
  if self.config.get('auto_proceed',True):
@@ -1077,6 +1086,8 @@ class Agent:
1077
1086
  self.dump_pending()
1078
1087
  self.add_message(DeveloperMessage(content="The previous agent response was interrupted by the user."))
1079
1088
  return False
1089
+ finally:
1090
+ self.emit(AssistantTurnEndEvent, message=turn_message)
1080
1091
 
1081
1092
  def speak(self,text,**kwargs):
1082
1093
  from .voice import silent_play
@@ -1168,13 +1179,10 @@ class Agent:
1168
1179
  return self.process()
1169
1180
 
1170
1181
  def interact(self):
1171
- """Starts an interactive command-line loop for user prompts until 'exit' or 'quit' is typed.
1172
-
1173
- Uses Alt+Enter for multi-line input submission.
1174
- """
1182
+ """Starts the interactive Textual chat UI."""
1175
1183
  from .utils import set_root_path
1176
1184
  import os
1177
1185
  if os.environ.get('AGENT_ROOT_PATH') is None:
1178
1186
  set_root_path(file=__file__)
1179
- from .chat import TerminalChat
1180
- TerminalChat(self).run()
1187
+ from .chat import Chat
1188
+ Chat(self).run()
@@ -134,3 +134,22 @@ def verbosity(value=None):
134
134
  if value is None:
135
135
  return f"verbosity: {get_agent().config.verbosity!r}"
136
136
  return update_model_config(verbosity=value)
137
+
138
+
139
+ @command
140
+ def voice(value=None):
141
+ agent = get_agent()
142
+ if value is None:
143
+ state = "on" if agent.config.get("voice_enabled", False) else "off"
144
+ return f"voice: {state}; voice={agent.config.get('voice')!r}"
145
+
146
+ lowered = str(value).lower()
147
+ if lowered in ("on", "true", "yes", "1"):
148
+ agent.update_config(voice_enabled=True)
149
+ return f"Updated config: voice_enabled={agent.config.voice_enabled!r}"
150
+ if lowered in ("off", "false", "no", "0"):
151
+ agent.update_config(voice_enabled=False)
152
+ return f"Updated config: voice_enabled={agent.config.voice_enabled!r}"
153
+
154
+ agent.update_config(voice=str(value), voice_enabled=True)
155
+ return f"Updated config: voice={agent.config.voice!r}, voice_enabled={agent.config.voice_enabled!r}"
@@ -0,0 +1,364 @@
1
+ from rich.text import Text
2
+
3
+ from .event import (
4
+ AssistantStepEndEvent,
5
+ AssistantStepStartEvent,
6
+ AssistantTurnEndEvent,
7
+ AssistantTurnStartEvent,
8
+ ResponseContentDeltaEvent,
9
+ ServerToolCallEvent,
10
+ ToolCallStartEvent,
11
+ )
12
+
13
+ class Chat:
14
+ """Textual-based chat UI styled as a quiet modern REPL."""
15
+
16
+ CSS = """
17
+ Screen {
18
+ background: #000000;
19
+ color: #c6c8c4;
20
+ }
21
+
22
+ #main {
23
+ height: 100%;
24
+ padding: 0 1;
25
+ background: #000000;
26
+ }
27
+
28
+ #conversation {
29
+ height: 1fr;
30
+ padding: 1 1;
31
+ background: #000000;
32
+ scrollbar-background: #000000;
33
+ scrollbar-background-hover: #000000;
34
+ scrollbar-background-active: #000000;
35
+ scrollbar-color: #202020;
36
+ scrollbar-color-hover: #2a2a2a;
37
+ scrollbar-color-active: #343434;
38
+ }
39
+
40
+ #status {
41
+ height: 1;
42
+ color: #7d8590;
43
+ background: #000000;
44
+ }
45
+
46
+ #input {
47
+ height: 5;
48
+ margin-top: 0;
49
+ padding: 0 1;
50
+ background: #000000;
51
+ color: #c6c8c4;
52
+ border: blank #000000;
53
+ scrollbar-size-vertical: 0;
54
+ scrollbar-size-horizontal: 0;
55
+ }
56
+
57
+ TextArea > .text-area--cursor {
58
+ background: #6f98bd;
59
+ color: #0d1117;
60
+ }
61
+
62
+ Footer {
63
+ background: #000000;
64
+ color: #7d8590;
65
+ }
66
+ """
67
+
68
+ def __init__(self, agent):
69
+ self.agent = agent
70
+ self.app = None
71
+ self._seen_tool_calls = set()
72
+ self._agent_buffer = ""
73
+ self._pending_tools = []
74
+ self._transcript = []
75
+
76
+ def attach(self):
77
+ self.agent.on(AssistantTurnStartEvent, self._on_assistant_turn_start)
78
+ self.agent.on(AssistantStepStartEvent, self._on_assistant_step_start)
79
+ self.agent.on(AssistantStepEndEvent, self._on_assistant_step_end)
80
+ self.agent.on(AssistantTurnEndEvent, self._on_assistant_turn_end)
81
+ self.agent.on(ResponseContentDeltaEvent, self._on_content_delta)
82
+ self.agent.on(ToolCallStartEvent, self._on_tool_call_start)
83
+ self.agent.on(ServerToolCallEvent, self._on_server_tool_call)
84
+ return self
85
+
86
+ def run(self):
87
+ from textual.app import App, ComposeResult
88
+ from textual.containers import Vertical
89
+ from textual.widgets import Footer, RichLog, Static, TextArea
90
+
91
+ chat = self
92
+
93
+ class AgentTextualApp(App):
94
+ CSS = chat.CSS
95
+ TITLE = "Codex Agent"
96
+ BINDINGS = [
97
+ ("ctrl+c", "interrupt", "Interrupt"),
98
+ ("ctrl+l", "clear", "Clear"),
99
+ ("ctrl+q", "quit", "Quit"),
100
+ ("ctrl+s", "submit", "Submit"),
101
+ ("alt+enter", "submit", "Submit"),
102
+ ("ctrl+enter", "submit", "Submit"),
103
+ ("f2", "submit", "Submit"),
104
+ ]
105
+
106
+ def compose(self) -> ComposeResult:
107
+ with Vertical(id="main"):
108
+ yield RichLog(id="conversation", wrap=True, highlight=False, markup=True)
109
+ yield Static("", id="status")
110
+ yield TextArea(
111
+ "",
112
+ placeholder="› message · Enter = newline · Ctrl+S/F2 = send · Ctrl+Q = quit",
113
+ id="input",
114
+ show_line_numbers=False,
115
+ soft_wrap=True,
116
+ )
117
+ yield Footer()
118
+
119
+ def on_mount(self):
120
+ chat.app = self
121
+ chat.attach()
122
+ self.refresh_status()
123
+ self.query_one("#input", TextArea).focus()
124
+ self.write_info("Codex Agent ready.")
125
+
126
+ def action_quit(self):
127
+ self.exit()
128
+
129
+ def action_submit(self):
130
+ input_widget = self.query_one("#input", TextArea)
131
+ prompt = input_widget.text
132
+ input_widget.load_text("")
133
+ command = prompt.strip().lower()
134
+ if not command:
135
+ return
136
+ if command in ["exit", "quit", "/exit", "/quit"]:
137
+ self.exit()
138
+ return
139
+ if command == "/clear":
140
+ self.action_clear()
141
+ return
142
+
143
+ self.write_user(prompt)
144
+ self.refresh_status()
145
+ self.run_worker(lambda: self._run_agent(prompt), thread=True)
146
+
147
+ def _run_agent(self, prompt):
148
+ try:
149
+ result = chat.agent(prompt)
150
+ if isinstance(result, str) and result:
151
+ self.call_from_thread(self.write_info, result)
152
+ except Exception as exc:
153
+ self.call_from_thread(self.write_error, str(exc))
154
+ finally:
155
+ self.call_from_thread(self.refresh_status)
156
+
157
+ def action_clear(self):
158
+ chat._transcript.clear()
159
+ self.query_one("#conversation", RichLog).clear()
160
+ self.write_info("screen cleared · session history preserved")
161
+ self.refresh_status()
162
+
163
+ def action_interrupt(self):
164
+ chat.agent.interrupt("keyboard_interrupt")
165
+ self.write_error("interrupt requested")
166
+
167
+ def log_width(self):
168
+ # Keep visual separators a few columns narrower than the RichLog
169
+ # viewport: Textual reserves space for padding/scrollbar.
170
+ raw_width = self.query_one("#conversation", RichLog).size.width or 80
171
+ return max(32, raw_width - 6)
172
+
173
+ def speaker_rule(self, name, style):
174
+ width = self.log_width()
175
+ label = f" {name} "
176
+ available = max(2, width - len(label))
177
+ left = available // 2
178
+ right = available - left
179
+ line = "─" * left + label + "─" * right
180
+ return Text(line, style=style)
181
+
182
+ def qed_marker(self, style="#6f98bd"):
183
+ return Text(" " * max(0, self.log_width() - 1) + "▪", style=style)
184
+
185
+ def render_transcript(self):
186
+ log = self.query_one("#conversation", RichLog)
187
+ log.clear()
188
+ for entry in chat._transcript:
189
+ kind = entry[0]
190
+ if kind == "speaker":
191
+ _, name, style = entry
192
+ log.write(self.speaker_rule(name, style))
193
+ log.write("")
194
+ elif kind == "user":
195
+ log.write(Text(entry[1], style="#c6c8c4"))
196
+ elif kind == "agent":
197
+ log.write(Text(entry[1], style="#c6c8c4"))
198
+ elif kind == "tool":
199
+ _, name, call_id, status = entry
200
+ suffix = f" · {status}" if status else ""
201
+ call = f" · {call_id}" if call_id else ""
202
+ log.write(
203
+ Text("tool › ", style="bold #6f98bd")
204
+ + Text(name or "tool", style="#c6c8c4")
205
+ + Text(f"{call}{suffix}", style="#7d8590")
206
+ )
207
+ elif kind == "step_end":
208
+ log.write(self.qed_marker())
209
+ elif kind == "user_end":
210
+ log.write(self.qed_marker("#88b38a"))
211
+ elif kind == "info":
212
+ log.write(Text("info › ", style="bold #7d8590") + Text(str(entry[1]), style="#8b949e"))
213
+ elif kind == "error":
214
+ log.write(Text("error › ", style="bold #ff7b72") + Text(str(entry[1]), style="#ff7b72"))
215
+
216
+ def write_user(self, prompt):
217
+ username = chat.agent.config.get("username") or "You"
218
+ chat._transcript.append(("speaker", username, "#88b38a"))
219
+ chat._transcript.append(("user", prompt))
220
+ chat._transcript.append(("user_end",))
221
+ self.render_transcript()
222
+
223
+ def write_agent_start(self, name):
224
+ chat._agent_buffer = ""
225
+ chat._pending_tools.clear()
226
+ chat._seen_tool_calls.clear()
227
+ agent_name = name or chat.agent.config.get("name") or "Pandora"
228
+ chat._transcript.append(("speaker", agent_name, "#789fbe"))
229
+ self.render_transcript()
230
+
231
+ def start_agent_step(self):
232
+ self.render_transcript()
233
+
234
+ def write_agent_delta(self, delta):
235
+ chat._agent_buffer += delta
236
+ if chat._transcript and chat._transcript[-1][0] == "agent":
237
+ chat._transcript[-1] = ("agent", chat._agent_buffer)
238
+ else:
239
+ chat._transcript.append(("agent", chat._agent_buffer))
240
+ self.render_transcript()
241
+
242
+ def write_tool(self, name, call_id=None, status=None):
243
+ chat._pending_tools.append(("tool", name, call_id, status))
244
+
245
+ def end_agent_step(self):
246
+ had_content = bool(chat._agent_buffer or chat._pending_tools)
247
+ if chat._transcript and chat._transcript[-1][0] == "agent":
248
+ if chat._agent_buffer:
249
+ chat._transcript[-1] = ("agent", chat._agent_buffer)
250
+ else:
251
+ chat._transcript.pop()
252
+ if chat._pending_tools:
253
+ chat._transcript.extend(chat._pending_tools)
254
+ chat._pending_tools.clear()
255
+ if had_content and (not chat._transcript or chat._transcript[-1][0] != "step_end"):
256
+ chat._transcript.append(("step_end",))
257
+ self.render_transcript()
258
+
259
+ def write_info(self, text):
260
+ chat._transcript.append(("info", str(text)))
261
+ self.render_transcript()
262
+
263
+ def write_error(self, text):
264
+ chat._transcript.append(("error", str(text)))
265
+ self.render_transcript()
266
+
267
+ def refresh_status(self):
268
+ try:
269
+ status = chat.agent.context_status()
270
+ text = chat.format_status(status).plain
271
+ except Exception as exc:
272
+ text = f"status unavailable: {exc}"
273
+ self.query_one("#status", Static).update(text)
274
+
275
+ AgentTextualApp().run()
276
+
277
+
278
+ @classmethod
279
+ def format_status(cls, status):
280
+ percent = status.percent
281
+ style = "green"
282
+ if percent >= 90:
283
+ style = "bold red"
284
+ elif percent >= 75:
285
+ style = "yellow"
286
+ elif percent >= 50:
287
+ style = "cyan"
288
+
289
+ used = cls._format_int(status.used_tokens)
290
+ limit = cls._format_int(status.input_token_limit)
291
+ session = str(status.session_id or "-")[-15:]
292
+ compact_hint = " auto-compact:on" if status.auto_compact else " auto-compact:off"
293
+ if status.auto_compact and status.used_tokens >= status.auto_compact_threshold:
294
+ compact_hint = " auto-compact:due"
295
+
296
+ return Text.assemble(
297
+ ("ctx ", "dim"),
298
+ (f"{percent:.1f}%", style),
299
+ (f" {used}/{limit} tok", "dim"),
300
+ (f" · hist {status.visible_history_messages}/{status.total_history_messages}", "dim"),
301
+ (f" · msgs {status.total_session_messages}", "dim"),
302
+ (f" · img {status.image_messages}", "dim"),
303
+ (f" · mem {status.memory_entries}", "dim"),
304
+ (f" · pending {status.pending_messages}", "dim"),
305
+ (f" · {session}", "dim"),
306
+ (compact_hint, "dim"),
307
+ )
308
+
309
+ @staticmethod
310
+ def _format_int(value):
311
+ try:
312
+ return f"{int(value):,}".replace(",", " ")
313
+ except Exception:
314
+ return str(value)
315
+
316
+ def _call_app(self, method, *args):
317
+ if self.app is None:
318
+ return
319
+ callback = getattr(self.app, method)
320
+ try:
321
+ self.app.call_from_thread(callback, *args)
322
+ except RuntimeError:
323
+ callback(*args)
324
+
325
+ def _on_assistant_turn_start(self, event):
326
+ name = self.agent.config.get("name") or "Pandora"
327
+ self._call_app("write_agent_start", name)
328
+
329
+ def _on_assistant_step_start(self, event):
330
+ self._agent_buffer = ""
331
+ self._call_app("start_agent_step")
332
+
333
+ def _on_assistant_step_end(self, event):
334
+ self._call_app("end_agent_step")
335
+ self._call_app("refresh_status")
336
+
337
+ def _on_assistant_turn_end(self, event):
338
+ self._call_app("refresh_status")
339
+
340
+ def _on_content_delta(self, event):
341
+ self._call_app("write_agent_delta", event.delta)
342
+
343
+ def _on_tool_call_start(self, event):
344
+ tool_call = event.get("tool_call") or {}
345
+ name = tool_call.get("name") or "unknown"
346
+ call_id = tool_call.get("call_id") or name
347
+ self._print_tool_call(name, call_id)
348
+
349
+ def _on_server_tool_call(self, event):
350
+ name = event.get("name") or "unknown"
351
+ call_id = event.get("call_id") or name
352
+ status = event.get("status") or ""
353
+ self._print_tool_call(name, call_id, status=status)
354
+
355
+ def _print_tool_call(self, name, call_id, status=None):
356
+ if call_id in self._seen_tool_calls:
357
+ return
358
+ self._seen_tool_calls.add(call_id)
359
+ self._call_app("write_tool", name, call_id, status)
360
+
361
+ def _on_response_done(self, event):
362
+ # Tool call lines are flushed at assistant-turn end, not after each
363
+ # backend response, to keep agentic loops visually contiguous.
364
+ pass
@@ -16,6 +16,23 @@ class Event(modict):
16
16
  self['type'] = snake_case_type(type(self))
17
17
 
18
18
 
19
+ class AssistantTurnStartEvent(Event):
20
+ message = None
21
+
22
+
23
+ class AssistantTurnEndEvent(Event):
24
+ message = None
25
+
26
+
27
+ class AssistantStepStartEvent(Event):
28
+ message = None
29
+
30
+
31
+ class AssistantStepEndEvent(Event):
32
+ message = None
33
+ called_tools = False
34
+
35
+
19
36
  class ResponseStartEvent(Event):
20
37
  message = None
21
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-agent-framework
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A lightweight event-driven Codex agent runtime.
5
5
  Author: Baptiste
6
6
  License-Expression: MIT
@@ -26,7 +26,6 @@ Requires-Dist: openai
26
26
  Requires-Dist: openpyxl
27
27
  Requires-Dist: pathspec
28
28
  Requires-Dist: pillow
29
- Requires-Dist: prompt_toolkit
30
29
  Requires-Dist: pydub
31
30
  Requires-Dist: PyPDF2
32
31
  Requires-Dist: pynteract
@@ -36,6 +35,7 @@ Requires-Dist: regex
36
35
  Requires-Dist: requests
37
36
  Requires-Dist: rich
38
37
  Requires-Dist: selenium
38
+ Requires-Dist: textual
39
39
  Requires-Dist: tiktoken
40
40
  Requires-Dist: trafilatura
41
41
  Provides-Extra: dev
@@ -342,6 +342,8 @@ python -m build
342
342
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
343
343
 
344
344
  ## Recent changes
345
+ - `0.1.5`: refine the Textual chat UI with pure black backgrounds, subtler markers/scrollbars, and English UI labels.
346
+ - `0.1.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
345
347
  - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
346
348
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
347
349
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
@@ -39,6 +39,7 @@ codex_agent_framework.egg-info/top_level.txt
39
39
  tests/test_agent.py
40
40
  tests/test_ai.py
41
41
  tests/test_chat.py
42
+ tests/test_cli.py
42
43
  tests/test_events.py
43
44
  tests/test_image_message.py
44
45
  tests/test_memory.py
@@ -9,7 +9,6 @@ openai
9
9
  openpyxl
10
10
  pathspec
11
11
  pillow
12
- prompt_toolkit
13
12
  pydub
14
13
  PyPDF2
15
14
  pynteract
@@ -19,6 +18,7 @@ regex
19
18
  requests
20
19
  rich
21
20
  selenium
21
+ textual
22
22
  tiktoken
23
23
  trafilatura
24
24
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-agent-framework"
7
- version = "0.1.3"
7
+ version = "0.1.5"
8
8
  description = "A lightweight event-driven Codex agent runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -34,7 +34,6 @@ dependencies = [
34
34
  "openpyxl",
35
35
  "pathspec",
36
36
  "pillow",
37
- "prompt_toolkit",
38
37
  "pydub",
39
38
  "PyPDF2",
40
39
  "pynteract",
@@ -44,6 +43,7 @@ dependencies = [
44
43
  "requests",
45
44
  "rich",
46
45
  "selenium",
46
+ "textual",
47
47
  "tiktoken",
48
48
  "trafilatura",
49
49
  ]
@@ -1143,7 +1143,7 @@ def test_agent_command_supports_varargs_and_kwargs():
1143
1143
  def test_agent_loads_builtin_commands():
1144
1144
  agent = Agent(session="new", voice_enabled=False)
1145
1145
 
1146
- assert {"help", "compact", "config", "model", "reasoning", "verbosity"}.issubset(agent.commands)
1146
+ assert {"help", "compact", "config", "model", "reasoning", "verbosity", "voice"}.issubset(agent.commands)
1147
1147
  assert "/help" in agent("/help")
1148
1148
  assert "/compact" in agent("/help")
1149
1149
 
@@ -1162,6 +1162,24 @@ def test_builtin_model_config_commands_update_and_persist(tmp_path, monkeypatch)
1162
1162
  assert agent.config.verbosity == "high"
1163
1163
 
1164
1164
 
1165
+ def test_builtin_voice_command_shows_toggles_and_selects_voice(tmp_path, monkeypatch):
1166
+ monkeypatch.setattr(utils, "RUNTIME_DIR", str(tmp_path))
1167
+ agent = Agent(session="new", voice_enabled=False, voice="nova")
1168
+
1169
+ assert agent("/voice") == "voice: off; voice='nova'"
1170
+ assert agent("/voice on") == "Updated config: voice_enabled=True"
1171
+ assert agent.config.voice_enabled is True
1172
+ assert agent("/voice off") == "Updated config: voice_enabled=False"
1173
+ assert agent.config.voice_enabled is False
1174
+ assert agent("/voice shimmer") == "Updated config: voice='shimmer', voice_enabled=True"
1175
+ assert agent.config.voice == "shimmer"
1176
+ assert agent.config.voice_enabled is True
1177
+
1178
+ saved = json.loads((tmp_path / "agent_config.json").read_text())
1179
+ assert saved["voice"] == "shimmer"
1180
+ assert saved["voice_enabled"] is True
1181
+
1182
+
1165
1183
  def test_builtin_config_command_shows_and_updates_multiple_model_fields(tmp_path, monkeypatch):
1166
1184
  monkeypatch.setattr(utils, "RUNTIME_DIR", str(tmp_path))
1167
1185
  agent = Agent(session="new", voice_enabled=False)
@@ -1,20 +1,8 @@
1
- from rich.console import Console
2
-
3
1
  from codex_agent import Agent
4
- from codex_agent.chat import TerminalChat
2
+ from codex_agent.chat import Chat
5
3
  from codex_agent.message import DeveloperMessage
6
4
 
7
5
 
8
- class RecordingConsole(Console):
9
- def __init__(self):
10
- super().__init__(record=True, width=160)
11
- self.clear_count = 0
12
-
13
- def clear(self, *args, **kwargs):
14
- self.clear_count += 1
15
- return super().clear(*args, **kwargs)
16
-
17
-
18
6
  def test_agent_context_status_is_lightweight_and_does_not_call_providers():
19
7
  agent = Agent(session="new", voice_enabled=False, input_token_limit=10_000)
20
8
  agent.session.messages.append(DeveloperMessage(content="hello context"))
@@ -36,28 +24,15 @@ def test_agent_context_status_is_lightweight_and_does_not_call_providers():
36
24
  assert status.memory_entries == len(agent.memory.messages)
37
25
 
38
26
 
39
- def test_terminal_chat_status_bar_renders_context_summary():
27
+ def test_chat_status_bar_formats_context_summary():
40
28
  agent = Agent(session="new", voice_enabled=False, input_token_limit=10_000)
41
- console = RecordingConsole()
42
- chat = TerminalChat(agent, console=console)
29
+ status = agent.context_status()
43
30
 
44
- chat.print_status_bar()
31
+ output = Chat.format_status(status).plain
45
32
 
46
- output = console.export_text()
47
33
  assert "ctx" in output
48
34
  assert "%" in output
49
35
  assert "tok" in output
50
36
  assert "hist" in output
51
37
  assert "msgs" in output
52
38
  assert "mem" in output
53
-
54
- def test_terminal_chat_clear_screen_does_not_touch_agent_session():
55
- agent = Agent(session="new", voice_enabled=False)
56
- initial_count = len(agent.session.messages)
57
- console = RecordingConsole()
58
- chat = TerminalChat(agent, console=console)
59
-
60
- chat.clear_screen()
61
-
62
- assert console.clear_count == 1
63
- assert len(agent.session.messages) == initial_count
@@ -0,0 +1,22 @@
1
+ from codex_agent import __main__
2
+
3
+
4
+ class FakeAgent:
5
+ instances = []
6
+
7
+ def __init__(self, **kwargs):
8
+ self.kwargs = kwargs
9
+ self.interacted = False
10
+ FakeAgent.instances.append(self)
11
+
12
+ def interact(self):
13
+ self.interacted = True
14
+
15
+
16
+ def test_cli_starts_default_textual_chat(monkeypatch):
17
+ FakeAgent.instances.clear()
18
+ monkeypatch.setattr(__main__, "Agent", FakeAgent)
19
+
20
+ __main__.main([])
21
+
22
+ assert FakeAgent.instances[-1].interacted is True
@@ -1,146 +0,0 @@
1
- from prompt_toolkit import PromptSession
2
- from rich.console import Console
3
- from rich.panel import Panel
4
- from rich.rule import Rule
5
- from rich.text import Text
6
- from .event import ResponseContentDeltaEvent, ResponseDoneEvent, ResponseStartEvent, ServerToolCallEvent, ToolCallStartEvent
7
-
8
-
9
- class TerminalChat:
10
- """Small terminal UI wired to agent events."""
11
-
12
- def __init__(self, agent, console=None):
13
- self.agent = agent
14
- self.console = console or Console()
15
- self.prompt_session = PromptSession(multiline=True)
16
- self._in_response = False
17
- self._has_content = False
18
- self._seen_tool_calls = set()
19
-
20
- def attach(self):
21
- self.agent.on(ResponseStartEvent, self._on_response_start)
22
- self.agent.on(ResponseContentDeltaEvent, self._on_content_delta)
23
- self.agent.on(ToolCallStartEvent, self._on_tool_call_start)
24
- self.agent.on(ServerToolCallEvent, self._on_server_tool_call)
25
- self.agent.on(ResponseDoneEvent, self._on_response_done)
26
- return self
27
-
28
- def run(self):
29
- self.attach()
30
- self.console.print(Panel.fit(
31
- "[bold]Codex Agent[/bold]\n[dim]Alt+Enter submits multiline input. Type exit or quit to stop.[/dim]",
32
- border_style="cyan",
33
- ))
34
- while True:
35
- try:
36
- self.print_status_bar()
37
- prompt = self.prompt_session.prompt(
38
- [("class:prompt", "You > ")],
39
- prompt_continuation="... ",
40
- )
41
- command = prompt.strip().lower()
42
- if command in ["exit", "quit", "/exit", "/quit"]:
43
- self.console.print("[dim]Bye.[/dim]")
44
- break
45
- if command == "/clear":
46
- self.clear_screen()
47
- continue
48
- if not prompt.strip():
49
- continue
50
- try:
51
- result = self.agent(prompt)
52
- if isinstance(result, str) and result:
53
- self.console.print(result, style="dim")
54
- except KeyboardInterrupt:
55
- self.agent.interrupt("keyboard_interrupt")
56
- self.console.print("\n[dim]Interrupted.[/dim]")
57
- self.console.print()
58
- except (KeyboardInterrupt, EOFError):
59
- self.console.print("\n[dim]Bye.[/dim]")
60
- break
61
-
62
- def print_status_bar(self):
63
- try:
64
- status = self.agent.context_status()
65
- except Exception as exc:
66
- self.console.print(f"[dim red]status unavailable: {exc}[/dim red]")
67
- return
68
-
69
- percent = status.percent
70
- style = "green"
71
- if percent >= 90:
72
- style = "bold red"
73
- elif percent >= 75:
74
- style = "yellow"
75
- elif percent >= 50:
76
- style = "cyan"
77
-
78
- used = self._format_int(status.used_tokens)
79
- limit = self._format_int(status.input_token_limit)
80
- session = str(status.session_id or "-")[-15:]
81
- compact_hint = " auto-compact:on" if status.auto_compact else " auto-compact:off"
82
- if status.auto_compact and status.used_tokens >= status.auto_compact_threshold:
83
- compact_hint = " auto-compact:due"
84
-
85
- self.console.print(
86
- Text.assemble(
87
- ("ctx ", "dim"),
88
- (f"{percent:.1f}%", style),
89
- (f" {used}/{limit} tok", "dim"),
90
- (f" · hist {status.visible_history_messages}/{status.total_history_messages}", "dim"),
91
- (f" · msgs {status.total_session_messages}", "dim"),
92
- (f" · img {status.image_messages}", "dim"),
93
- (f" · mem {status.memory_entries}", "dim"),
94
- (f" · pending {status.pending_messages}", "dim"),
95
- (f" · {session}", "dim"),
96
- (compact_hint, "dim"),
97
- )
98
- )
99
-
100
- def clear_screen(self):
101
- self.console.clear()
102
-
103
- @staticmethod
104
- def _format_int(value):
105
- try:
106
- return f"{int(value):,}".replace(",", " ")
107
- except Exception:
108
- return str(value)
109
-
110
- def _on_response_start(self, event):
111
- if self._in_response:
112
- self.console.print()
113
- self._in_response = True
114
- self._has_content = False
115
- self._seen_tool_calls.clear()
116
- name = event.message.get("name") if event.get("message") else None
117
- self.console.print(Rule(Text(name or "Agent", style="bold cyan"), style="cyan"))
118
-
119
- def _on_content_delta(self, event):
120
- self._has_content = True
121
- self.console.print(event.delta, end="", markup=False, highlight=False, soft_wrap=True)
122
-
123
- def _on_tool_call_start(self, event):
124
- tool_call = event.get("tool_call") or {}
125
- name = tool_call.get("name") or "unknown"
126
- call_id = tool_call.get("call_id") or name
127
- self._print_tool_call(name, call_id)
128
-
129
- def _on_server_tool_call(self, event):
130
- name = event.get("name") or "unknown"
131
- call_id = event.get("call_id") or name
132
- self._print_tool_call(name, call_id)
133
-
134
- def _print_tool_call(self, name, call_id):
135
- if call_id in self._seen_tool_calls:
136
- return
137
- self._seen_tool_calls.add(call_id)
138
- if self._has_content:
139
- self.console.print()
140
- self.console.print(f"tool: {name}", style="dim cyan")
141
- self._has_content = False
142
-
143
- def _on_response_done(self, event):
144
- if self._has_content:
145
- self.console.print()
146
- self._in_response = False