codex-agent-framework 0.1.2__tar.gz → 0.1.4__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.2 → codex_agent_framework-0.1.4}/CHANGELOG.md +21 -0
  2. {codex_agent_framework-0.1.2/codex_agent_framework.egg-info → codex_agent_framework-0.1.4}/PKG-INFO +4 -2
  3. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/README.md +2 -0
  4. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/__init__.py +4 -0
  5. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/__main__.py +1 -1
  6. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/agent.py +21 -12
  7. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/ai.py +1 -0
  8. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/builtin_commands.py +19 -0
  9. codex_agent_framework-0.1.4/codex_agent/chat.py +356 -0
  10. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/event.py +17 -0
  11. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4/codex_agent_framework.egg-info}/PKG-INFO +4 -2
  12. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent_framework.egg-info/SOURCES.txt +1 -0
  13. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent_framework.egg-info/requires.txt +1 -1
  14. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/pyproject.toml +2 -2
  15. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_agent.py +29 -3
  16. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_chat.py +4 -29
  17. codex_agent_framework-0.1.4/tests/test_cli.py +22 -0
  18. codex_agent_framework-0.1.2/codex_agent/chat.py +0 -146
  19. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/LICENSE +0 -0
  20. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/MANIFEST.in +0 -0
  21. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/builtin_providers.py +0 -0
  22. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/builtin_tools.py +0 -0
  23. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/command.py +0 -0
  24. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/get_text/__init__.py +0 -0
  25. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/get_text/default_gitignore +0 -0
  26. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/get_text/get_text.py +0 -0
  27. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/get_text/simpler_get_text.py +0 -0
  28. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/get_webdriver.py +0 -0
  29. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/image.py +0 -0
  30. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/latex.py +0 -0
  31. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/memory.py +0 -0
  32. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/message.py +0 -0
  33. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/prompts/image_generation_system_prompt.txt +0 -0
  34. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/prompts/system_prompt.txt +0 -0
  35. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/provider.py +0 -0
  36. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/stream_utils.py +0 -0
  37. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/tool.py +0 -0
  38. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/utils.py +0 -0
  39. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/voice.py +0 -0
  40. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent/worker.py +0 -0
  41. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent_framework.egg-info/dependency_links.txt +0 -0
  42. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent_framework.egg-info/entry_points.txt +0 -0
  43. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/codex_agent_framework.egg-info/top_level.txt +0 -0
  44. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/setup.cfg +0 -0
  45. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_ai.py +0 -0
  46. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_events.py +0 -0
  47. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_image_message.py +0 -0
  48. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_memory.py +0 -0
  49. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_messages.py +0 -0
  50. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_utils.py +0 -0
  51. {codex_agent_framework-0.1.2 → codex_agent_framework-0.1.4}/tests/test_worker.py +0 -0
@@ -3,6 +3,27 @@
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.4] - 2026-05-03
7
+ ### Added
8
+ - 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.
9
+ - Add assistant turn and assistant step lifecycle events for UI and runtime integrations.
10
+ - Add a `/voice` command to show, toggle, or select the configured voice mode.
11
+
12
+ ### Changed
13
+ - Remove the legacy prompt-toolkit chat UI and the temporary `--textual` CLI flag.
14
+
15
+ ### Tests
16
+ - Add CLI coverage for starting the default chat UI and update chat status formatting tests.
17
+
18
+
19
+ ## [0.1.3] - 2026-05-03
20
+ ### Added
21
+ - 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.
22
+
23
+ ### Tests
24
+ - Add regression coverage for prompt cache key propagation through agent response calls and Codex stream parameters.
25
+
26
+
6
27
  ## [0.1.2] - 2026-05-03
7
28
  ### Added
8
29
  - Add a `/clear` terminal chat command that clears the screen without touching session history.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-agent-framework
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
346
+ - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
345
347
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
346
348
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
347
349
 
@@ -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.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
299
+ - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
298
300
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
299
301
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
300
302
 
@@ -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
@@ -906,6 +906,7 @@ class Agent:
906
906
  message=self.ai.run(
907
907
  messages=messages,
908
908
  tools=self.get_response_tools(),
909
+ prompt_cache_key=self.current_session_id,
909
910
  **self.config.extract('model','reasoning_effort','verbosity')
910
911
  )
911
912
  self.add_message(message)
@@ -1055,15 +1056,24 @@ class Agent:
1055
1056
 
1056
1057
  self.clear_interrupt()
1057
1058
  self.age_context_messages()
1059
+ turn_message = None
1060
+ self.emit(AssistantTurnStartEvent)
1058
1061
  try:
1059
1062
  while True:
1060
1063
  self.check_interrupt()
1061
1064
  self.dump_pending()
1062
- message=self.get_response()
1063
- ThreadPool.join_all()
1064
- self.check_interrupt()
1065
- called_tools=self.call_tools(message)
1066
- 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)
1067
1077
 
1068
1078
  if called_tools:
1069
1079
  if self.config.get('auto_proceed',True):
@@ -1076,6 +1086,8 @@ class Agent:
1076
1086
  self.dump_pending()
1077
1087
  self.add_message(DeveloperMessage(content="The previous agent response was interrupted by the user."))
1078
1088
  return False
1089
+ finally:
1090
+ self.emit(AssistantTurnEndEvent, message=turn_message)
1079
1091
 
1080
1092
  def speak(self,text,**kwargs):
1081
1093
  from .voice import silent_play
@@ -1167,13 +1179,10 @@ class Agent:
1167
1179
  return self.process()
1168
1180
 
1169
1181
  def interact(self):
1170
- """Starts an interactive command-line loop for user prompts until 'exit' or 'quit' is typed.
1171
-
1172
- Uses Alt+Enter for multi-line input submission.
1173
- """
1182
+ """Starts the interactive Textual chat UI."""
1174
1183
  from .utils import set_root_path
1175
1184
  import os
1176
1185
  if os.environ.get('AGENT_ROOT_PATH') is None:
1177
1186
  set_root_path(file=__file__)
1178
- from .chat import TerminalChat
1179
- TerminalChat(self).run()
1187
+ from .chat import Chat
1188
+ Chat(self).run()
@@ -289,6 +289,7 @@ class AIClient:
289
289
  "reasoning": reasoning,
290
290
  "reasoning_summary": "auto" if reasoning else None,
291
291
  "include_reasoning": bool(reasoning),
292
+ "prompt_cache_key": params.get("prompt_cache_key"),
292
293
  }
293
294
 
294
295
  def _codex_event_to_message_chunk(self, event, tool_call_index=0):
@@ -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,356 @@
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: #171a1f;
19
+ color: #c6c8c4;
20
+ }
21
+
22
+ #main {
23
+ height: 100%;
24
+ padding: 0 1;
25
+ background: #171a1f;
26
+ }
27
+
28
+ #conversation {
29
+ height: 1fr;
30
+ padding: 1 1;
31
+ background: #171a1f;
32
+ }
33
+
34
+ #status {
35
+ height: 1;
36
+ color: #7d8590;
37
+ background: #171a1f;
38
+ }
39
+
40
+ #input {
41
+ height: 5;
42
+ margin-top: 0;
43
+ padding: 0 1;
44
+ background: #1f242c;
45
+ color: #c6c8c4;
46
+ border: blank #1f242c;
47
+ }
48
+
49
+ TextArea > .text-area--cursor {
50
+ background: #6f98bd;
51
+ color: #0d1117;
52
+ }
53
+
54
+ Footer {
55
+ background: #171a1f;
56
+ color: #7d8590;
57
+ }
58
+ """
59
+
60
+ def __init__(self, agent):
61
+ self.agent = agent
62
+ self.app = None
63
+ self._seen_tool_calls = set()
64
+ self._agent_buffer = ""
65
+ self._pending_tools = []
66
+ self._transcript = []
67
+
68
+ def attach(self):
69
+ self.agent.on(AssistantTurnStartEvent, self._on_assistant_turn_start)
70
+ self.agent.on(AssistantStepStartEvent, self._on_assistant_step_start)
71
+ self.agent.on(AssistantStepEndEvent, self._on_assistant_step_end)
72
+ self.agent.on(AssistantTurnEndEvent, self._on_assistant_turn_end)
73
+ self.agent.on(ResponseContentDeltaEvent, self._on_content_delta)
74
+ self.agent.on(ToolCallStartEvent, self._on_tool_call_start)
75
+ self.agent.on(ServerToolCallEvent, self._on_server_tool_call)
76
+ return self
77
+
78
+ def run(self):
79
+ from textual.app import App, ComposeResult
80
+ from textual.containers import Vertical
81
+ from textual.widgets import Footer, RichLog, Static, TextArea
82
+
83
+ chat = self
84
+
85
+ class AgentTextualApp(App):
86
+ CSS = chat.CSS
87
+ TITLE = "Codex Agent"
88
+ BINDINGS = [
89
+ ("ctrl+c", "interrupt", "Interrupt"),
90
+ ("ctrl+l", "clear", "Clear"),
91
+ ("ctrl+q", "quit", "Quit"),
92
+ ("ctrl+s", "submit", "Submit"),
93
+ ("alt+enter", "submit", "Submit"),
94
+ ("ctrl+enter", "submit", "Submit"),
95
+ ("f2", "submit", "Submit"),
96
+ ]
97
+
98
+ def compose(self) -> ComposeResult:
99
+ with Vertical(id="main"):
100
+ yield RichLog(id="conversation", wrap=True, highlight=False, markup=True)
101
+ yield Static("", id="status")
102
+ yield TextArea(
103
+ "",
104
+ placeholder="› message · Entrée = newline · Ctrl+S/F2 = envoyer · Ctrl+Q = quitter",
105
+ id="input",
106
+ show_line_numbers=False,
107
+ soft_wrap=True,
108
+ )
109
+ yield Footer()
110
+
111
+ def on_mount(self):
112
+ chat.app = self
113
+ chat.attach()
114
+ self.refresh_status()
115
+ self.query_one("#input", TextArea).focus()
116
+ self.write_info("Codex Agent prêt.")
117
+
118
+ def action_quit(self):
119
+ self.exit()
120
+
121
+ def action_submit(self):
122
+ input_widget = self.query_one("#input", TextArea)
123
+ prompt = input_widget.text
124
+ input_widget.load_text("")
125
+ command = prompt.strip().lower()
126
+ if not command:
127
+ return
128
+ if command in ["exit", "quit", "/exit", "/quit"]:
129
+ self.exit()
130
+ return
131
+ if command == "/clear":
132
+ self.action_clear()
133
+ return
134
+
135
+ self.write_user(prompt)
136
+ self.refresh_status()
137
+ self.run_worker(lambda: self._run_agent(prompt), thread=True)
138
+
139
+ def _run_agent(self, prompt):
140
+ try:
141
+ result = chat.agent(prompt)
142
+ if isinstance(result, str) and result:
143
+ self.call_from_thread(self.write_info, result)
144
+ except Exception as exc:
145
+ self.call_from_thread(self.write_error, str(exc))
146
+ finally:
147
+ self.call_from_thread(self.refresh_status)
148
+
149
+ def action_clear(self):
150
+ chat._transcript.clear()
151
+ self.query_one("#conversation", RichLog).clear()
152
+ self.write_info("écran nettoyé · historique conservé")
153
+ self.refresh_status()
154
+
155
+ def action_interrupt(self):
156
+ chat.agent.interrupt("keyboard_interrupt")
157
+ self.write_error("interruption demandée")
158
+
159
+ def log_width(self):
160
+ # Keep visual separators a few columns narrower than the RichLog
161
+ # viewport: Textual reserves space for padding/scrollbar.
162
+ raw_width = self.query_one("#conversation", RichLog).size.width or 80
163
+ return max(32, raw_width - 6)
164
+
165
+ def speaker_rule(self, name, style):
166
+ width = self.log_width()
167
+ label = f" {name} "
168
+ available = max(2, width - len(label))
169
+ left = available // 2
170
+ right = available - left
171
+ line = "─" * left + label + "─" * right
172
+ return Text(line, style=style)
173
+
174
+ def qed_marker(self, style="#6f98bd"):
175
+ return Text(" " * max(0, self.log_width() - 1) + "▪", style=style)
176
+
177
+ def render_transcript(self):
178
+ log = self.query_one("#conversation", RichLog)
179
+ log.clear()
180
+ for entry in chat._transcript:
181
+ kind = entry[0]
182
+ if kind == "speaker":
183
+ _, name, style = entry
184
+ log.write(self.speaker_rule(name, style))
185
+ log.write("")
186
+ elif kind == "user":
187
+ log.write(Text(entry[1], style="#c6c8c4"))
188
+ elif kind == "agent":
189
+ log.write(Text(entry[1], style="#c6c8c4"))
190
+ elif kind == "tool":
191
+ _, name, call_id, status = entry
192
+ suffix = f" · {status}" if status else ""
193
+ call = f" · {call_id}" if call_id else ""
194
+ log.write(
195
+ Text("tool › ", style="bold #6f98bd")
196
+ + Text(name or "tool", style="#c6c8c4")
197
+ + Text(f"{call}{suffix}", style="#7d8590")
198
+ )
199
+ elif kind == "step_end":
200
+ log.write(self.qed_marker())
201
+ elif kind == "user_end":
202
+ log.write(self.qed_marker("#88b38a"))
203
+ elif kind == "info":
204
+ log.write(Text("info › ", style="bold #7d8590") + Text(str(entry[1]), style="#8b949e"))
205
+ elif kind == "error":
206
+ log.write(Text("error › ", style="bold #ff7b72") + Text(str(entry[1]), style="#ff7b72"))
207
+
208
+ def write_user(self, prompt):
209
+ username = chat.agent.config.get("username") or "Vous"
210
+ chat._transcript.append(("speaker", username, "#88b38a"))
211
+ chat._transcript.append(("user", prompt))
212
+ chat._transcript.append(("user_end",))
213
+ self.render_transcript()
214
+
215
+ def write_agent_start(self, name):
216
+ chat._agent_buffer = ""
217
+ chat._pending_tools.clear()
218
+ chat._seen_tool_calls.clear()
219
+ agent_name = name or chat.agent.config.get("name") or "Pandora"
220
+ chat._transcript.append(("speaker", agent_name, "#789fbe"))
221
+ self.render_transcript()
222
+
223
+ def start_agent_step(self):
224
+ self.render_transcript()
225
+
226
+ def write_agent_delta(self, delta):
227
+ chat._agent_buffer += delta
228
+ if chat._transcript and chat._transcript[-1][0] == "agent":
229
+ chat._transcript[-1] = ("agent", chat._agent_buffer)
230
+ else:
231
+ chat._transcript.append(("agent", chat._agent_buffer))
232
+ self.render_transcript()
233
+
234
+ def write_tool(self, name, call_id=None, status=None):
235
+ chat._pending_tools.append(("tool", name, call_id, status))
236
+
237
+ def end_agent_step(self):
238
+ had_content = bool(chat._agent_buffer or chat._pending_tools)
239
+ if chat._transcript and chat._transcript[-1][0] == "agent":
240
+ if chat._agent_buffer:
241
+ chat._transcript[-1] = ("agent", chat._agent_buffer)
242
+ else:
243
+ chat._transcript.pop()
244
+ if chat._pending_tools:
245
+ chat._transcript.extend(chat._pending_tools)
246
+ chat._pending_tools.clear()
247
+ if had_content and (not chat._transcript or chat._transcript[-1][0] != "step_end"):
248
+ chat._transcript.append(("step_end",))
249
+ self.render_transcript()
250
+
251
+ def write_info(self, text):
252
+ chat._transcript.append(("info", str(text)))
253
+ self.render_transcript()
254
+
255
+ def write_error(self, text):
256
+ chat._transcript.append(("error", str(text)))
257
+ self.render_transcript()
258
+
259
+ def refresh_status(self):
260
+ try:
261
+ status = chat.agent.context_status()
262
+ text = chat.format_status(status).plain
263
+ except Exception as exc:
264
+ text = f"status unavailable: {exc}"
265
+ self.query_one("#status", Static).update(text)
266
+
267
+ AgentTextualApp().run()
268
+
269
+
270
+ @classmethod
271
+ def format_status(cls, status):
272
+ percent = status.percent
273
+ style = "green"
274
+ if percent >= 90:
275
+ style = "bold red"
276
+ elif percent >= 75:
277
+ style = "yellow"
278
+ elif percent >= 50:
279
+ style = "cyan"
280
+
281
+ used = cls._format_int(status.used_tokens)
282
+ limit = cls._format_int(status.input_token_limit)
283
+ session = str(status.session_id or "-")[-15:]
284
+ compact_hint = " auto-compact:on" if status.auto_compact else " auto-compact:off"
285
+ if status.auto_compact and status.used_tokens >= status.auto_compact_threshold:
286
+ compact_hint = " auto-compact:due"
287
+
288
+ return Text.assemble(
289
+ ("ctx ", "dim"),
290
+ (f"{percent:.1f}%", style),
291
+ (f" {used}/{limit} tok", "dim"),
292
+ (f" · hist {status.visible_history_messages}/{status.total_history_messages}", "dim"),
293
+ (f" · msgs {status.total_session_messages}", "dim"),
294
+ (f" · img {status.image_messages}", "dim"),
295
+ (f" · mem {status.memory_entries}", "dim"),
296
+ (f" · pending {status.pending_messages}", "dim"),
297
+ (f" · {session}", "dim"),
298
+ (compact_hint, "dim"),
299
+ )
300
+
301
+ @staticmethod
302
+ def _format_int(value):
303
+ try:
304
+ return f"{int(value):,}".replace(",", " ")
305
+ except Exception:
306
+ return str(value)
307
+
308
+ def _call_app(self, method, *args):
309
+ if self.app is None:
310
+ return
311
+ callback = getattr(self.app, method)
312
+ try:
313
+ self.app.call_from_thread(callback, *args)
314
+ except RuntimeError:
315
+ callback(*args)
316
+
317
+ def _on_assistant_turn_start(self, event):
318
+ name = self.agent.config.get("name") or "Pandora"
319
+ self._call_app("write_agent_start", name)
320
+
321
+ def _on_assistant_step_start(self, event):
322
+ self._agent_buffer = ""
323
+ self._call_app("start_agent_step")
324
+
325
+ def _on_assistant_step_end(self, event):
326
+ self._call_app("end_agent_step")
327
+ self._call_app("refresh_status")
328
+
329
+ def _on_assistant_turn_end(self, event):
330
+ self._call_app("refresh_status")
331
+
332
+ def _on_content_delta(self, event):
333
+ self._call_app("write_agent_delta", event.delta)
334
+
335
+ def _on_tool_call_start(self, event):
336
+ tool_call = event.get("tool_call") or {}
337
+ name = tool_call.get("name") or "unknown"
338
+ call_id = tool_call.get("call_id") or name
339
+ self._print_tool_call(name, call_id)
340
+
341
+ def _on_server_tool_call(self, event):
342
+ name = event.get("name") or "unknown"
343
+ call_id = event.get("call_id") or name
344
+ status = event.get("status") or ""
345
+ self._print_tool_call(name, call_id, status=status)
346
+
347
+ def _print_tool_call(self, name, call_id, status=None):
348
+ if call_id in self._seen_tool_calls:
349
+ return
350
+ self._seen_tool_calls.add(call_id)
351
+ self._call_app("write_tool", name, call_id, status)
352
+
353
+ def _on_response_done(self, event):
354
+ # Tool call lines are flushed at assistant-turn end, not after each
355
+ # backend response, to keep agentic loops visually contiguous.
356
+ 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.2
3
+ Version: 0.1.4
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.4`: make the Textual REPL UI the default, add multiline input, assistant turn/step events, cleaner tool-call rendering, and `/voice` configuration.
346
+ - `0.1.3`: pass the current session id as `prompt_cache_key` to the Codex backend for prompt cache reuse.
345
347
  - `0.1.2`: add `/clear` in the terminal chat to clear the screen without changing session history.
346
348
  - `0.1.1`: image observations are now validated before session persistence, preventing broken `ImageMessage` entries from poisoning later turns.
347
349
 
@@ -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.2"
7
+ version = "0.1.4"
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
  ]
@@ -60,8 +60,12 @@ class FakeImageAI:
60
60
  class FakeCodexClient:
61
61
  def __init__(self, events):
62
62
  self.events = events
63
+ self.args = None
64
+ self.kwargs = None
63
65
 
64
- def stream(self, *_args, **_kwargs):
66
+ def stream(self, *args, **kwargs):
67
+ self.args = args
68
+ self.kwargs = kwargs
65
69
  return iter(self.events)
66
70
 
67
71
 
@@ -290,6 +294,7 @@ def test_agent_get_response_emits_events_and_persists_assistant_message():
290
294
  assert agent.session.messages[-1] is message
291
295
  assert json.loads(json.dumps(message))["type"] == "assistant_message"
292
296
  assert agent.ai.params["verbosity"] == agent.config.verbosity
297
+ assert agent.ai.params["prompt_cache_key"] == agent.current_session_id
293
298
  assert any(tool["type"] == "custom" and tool["name"] == "python" for tool in agent.ai.params["tools"])
294
299
  assert any(tool["type"] == "custom" and tool["name"] == "bash" for tool in agent.ai.params["tools"])
295
300
  assert any(tool["type"] == "function" and tool["name"] == "read" for tool in agent.ai.params["tools"])
@@ -525,6 +530,7 @@ def test_agent_emits_server_tool_call_event_from_output_items():
525
530
  )
526
531
 
527
532
  assert seen == [("web_search", "ws_123", "completed")]
533
+ assert agent.ai._codex_client.kwargs["prompt_cache_key"] is None
528
534
 
529
535
 
530
536
  def test_agent_persists_server_tool_output_items_as_messages():
@@ -1059,15 +1065,17 @@ def test_agent_can_enable_builtin_server_tools_from_config():
1059
1065
  assert set(agent.server_tools) == {"web_search", "image_generation"}
1060
1066
 
1061
1067
 
1062
- def test_codex_stream_params_forward_verbosity():
1068
+ def test_codex_stream_params_forward_verbosity_and_prompt_cache_key():
1063
1069
  agent = Agent(session="new", voice_enabled=False, verbosity="low")
1064
1070
 
1065
1071
  params = agent.ai._to_codex_stream_params({
1066
1072
  "messages": agent.get_context(),
1067
1073
  "verbosity": agent.config.verbosity,
1074
+ "prompt_cache_key": agent.current_session_id,
1068
1075
  })
1069
1076
 
1070
1077
  assert params["verbosity"] == "low"
1078
+ assert params["prompt_cache_key"] == agent.current_session_id
1071
1079
 
1072
1080
 
1073
1081
  def test_agent_provider_messages_are_ephemeral():
@@ -1135,7 +1143,7 @@ def test_agent_command_supports_varargs_and_kwargs():
1135
1143
  def test_agent_loads_builtin_commands():
1136
1144
  agent = Agent(session="new", voice_enabled=False)
1137
1145
 
1138
- assert {"help", "compact", "config", "model", "reasoning", "verbosity"}.issubset(agent.commands)
1146
+ assert {"help", "compact", "config", "model", "reasoning", "verbosity", "voice"}.issubset(agent.commands)
1139
1147
  assert "/help" in agent("/help")
1140
1148
  assert "/compact" in agent("/help")
1141
1149
 
@@ -1154,6 +1162,24 @@ def test_builtin_model_config_commands_update_and_persist(tmp_path, monkeypatch)
1154
1162
  assert agent.config.verbosity == "high"
1155
1163
 
1156
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
+
1157
1183
  def test_builtin_config_command_shows_and_updates_multiple_model_fields(tmp_path, monkeypatch):
1158
1184
  monkeypatch.setattr(utils, "RUNTIME_DIR", str(tmp_path))
1159
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