python-codex 0.2.0__tar.gz → 0.2.2__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 (121) hide show
  1. {python_codex-0.2.0 → python_codex-0.2.2}/PKG-INFO +1 -1
  2. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/interactive_session.py +5 -1
  3. {python_codex-0.2.0 → python_codex-0.2.2}/pyproject.toml +1 -1
  4. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_workspace_server.py +71 -4
  5. {python_codex-0.2.0 → python_codex-0.2.2}/workspace_server/app.py +51 -2
  6. {python_codex-0.2.0 → python_codex-0.2.2}/workspace_server/workspace.html +527 -98
  7. {python_codex-0.2.0 → python_codex-0.2.2}/.github/workflows/publish.yml +0 -0
  8. {python_codex-0.2.0 → python_codex-0.2.2}/.github/workflows/test.yml +0 -0
  9. {python_codex-0.2.0 → python_codex-0.2.2}/.gitignore +0 -0
  10. {python_codex-0.2.0 → python_codex-0.2.2}/AGENTS.md +0 -0
  11. {python_codex-0.2.0 → python_codex-0.2.2}/LICENSE +0 -0
  12. {python_codex-0.2.0 → python_codex-0.2.2}/README.md +0 -0
  13. {python_codex-0.2.0 → python_codex-0.2.2}/README_ZH.md +0 -0
  14. {python_codex-0.2.0 → python_codex-0.2.2}/docs/ALIGNMENT.md +0 -0
  15. {python_codex-0.2.0 → python_codex-0.2.2}/docs/CONTEXT.md +0 -0
  16. {python_codex-0.2.0 → python_codex-0.2.2}/docs/responses_server/README.md +0 -0
  17. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/__init__.py +0 -0
  18. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/agent.py +0 -0
  19. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/cli.py +0 -0
  20. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/collaboration.py +0 -0
  21. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/compat.py +0 -0
  22. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/context.py +0 -0
  23. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/doctor.py +0 -0
  24. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/feishu_card.py +0 -0
  25. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/feishu_link.py +0 -0
  26. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/model.py +0 -0
  27. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/portable.py +0 -0
  28. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/portable_server.py +0 -0
  29. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/collaboration_default.md +0 -0
  30. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/collaboration_plan.md +0 -0
  31. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/default_base_instructions.md +0 -0
  32. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/models.json +0 -0
  33. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/approval_policy/never.md +0 -0
  34. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/approval_policy/on_failure.md +0 -0
  35. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/approval_policy/on_request.md +0 -0
  36. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +0 -0
  37. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/approval_policy/unless_trusted.md +0 -0
  38. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +0 -0
  39. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/sandbox_mode/read_only.md +0 -0
  40. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/prompts/permissions/sandbox_mode/workspace_write.md +0 -0
  41. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/protocol.py +0 -0
  42. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/runtime.py +0 -0
  43. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/runtime_services.py +0 -0
  44. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/__init__.py +0 -0
  45. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/agent_tool_schemas.py +0 -0
  46. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/apply_patch_tool.py +0 -0
  47. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/base_tool.py +0 -0
  48. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/close_agent_tool.py +0 -0
  49. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/code_mode_manager.py +0 -0
  50. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/exec_command_tool.py +0 -0
  51. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/exec_runtime.js +0 -0
  52. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/exec_tool.py +0 -0
  53. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/grep_files_tool.py +0 -0
  54. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/ipython_tool.py +0 -0
  55. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/list_dir_tool.py +0 -0
  56. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/read_file_tool.py +0 -0
  57. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/request_permissions_tool.py +0 -0
  58. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/request_user_input_tool.py +0 -0
  59. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/resume_agent_tool.py +0 -0
  60. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/send_input_tool.py +0 -0
  61. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/shell_command_tool.py +0 -0
  62. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/shell_tool.py +0 -0
  63. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/spawn_agent_tool.py +0 -0
  64. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/unified_exec_manager.py +0 -0
  65. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/update_plan_tool.py +0 -0
  66. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/view_image_tool.py +0 -0
  67. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/wait_agent_tool.py +0 -0
  68. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/wait_tool.py +0 -0
  69. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/web_search_tool.py +0 -0
  70. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/tools/write_stdin_tool.py +0 -0
  71. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/__init__.py +0 -0
  72. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/async_bridge.py +0 -0
  73. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/compactor.py +0 -0
  74. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/debug.py +0 -0
  75. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/dotenv.py +0 -0
  76. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/get_env.py +0 -0
  77. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/random_ids.py +0 -0
  78. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/session_persist.py +0 -0
  79. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/toolcall_visualize.py +0 -0
  80. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/truncation.py +0 -0
  81. {python_codex-0.2.0 → python_codex-0.2.2}/pycodex/utils/visualize.py +0 -0
  82. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/__init__.py +0 -0
  83. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/__main__.py +0 -0
  84. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/app.py +0 -0
  85. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/config.py +0 -0
  86. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/messages_api.py +0 -0
  87. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/payload_processors.py +0 -0
  88. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/server.py +0 -0
  89. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/session_store.py +0 -0
  90. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/stream_router.py +0 -0
  91. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/tools/__init__.py +0 -0
  92. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/tools/custom_adapter.py +0 -0
  93. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/tools/web_search.py +0 -0
  94. {python_codex-0.2.0 → python_codex-0.2.2}/responses_server/trajectory_dump.py +0 -0
  95. {python_codex-0.2.0 → python_codex-0.2.2}/tests/TESTS.md +0 -0
  96. {python_codex-0.2.0 → python_codex-0.2.2}/tests/__init__.py +0 -0
  97. {python_codex-0.2.0 → python_codex-0.2.2}/tests/compare_request_user_input_roundtrip.py +0 -0
  98. {python_codex-0.2.0 → python_codex-0.2.2}/tests/compare_steer_request_bodies.py +0 -0
  99. {python_codex-0.2.0 → python_codex-0.2.2}/tests/compare_tool_schemas.py +0 -0
  100. {python_codex-0.2.0 → python_codex-0.2.2}/tests/fake_responses_server.py +0 -0
  101. {python_codex-0.2.0 → python_codex-0.2.2}/tests/fakes.py +0 -0
  102. {python_codex-0.2.0 → python_codex-0.2.2}/tests/responses_server/fake_chat_completions_server.py +0 -0
  103. {python_codex-0.2.0 → python_codex-0.2.2}/tests/responses_server/test_server.py +0 -0
  104. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_agent.py +0 -0
  105. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_async_bridge.py +0 -0
  106. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_builtin_tools.py +0 -0
  107. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_cli.py +0 -0
  108. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_compactor.py +0 -0
  109. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_context.py +0 -0
  110. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_doctor.py +0 -0
  111. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_fake_responses_server.py +0 -0
  112. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_feishu_card.py +0 -0
  113. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_feishu_link.py +0 -0
  114. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_ipython_tool.py +0 -0
  115. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_model.py +0 -0
  116. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_portable.py +0 -0
  117. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_py36_syntax.py +0 -0
  118. {python_codex-0.2.0 → python_codex-0.2.2}/tests/test_visualize.py +0 -0
  119. {python_codex-0.2.0 → python_codex-0.2.2}/tools/feishu_oauth.py +0 -0
  120. {python_codex-0.2.0 → python_codex-0.2.2}/workspace_server/__init__.py +0 -0
  121. {python_codex-0.2.0 → python_codex-0.2.2}/workspace_server/__main__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -18,6 +18,7 @@ import typing
18
18
 
19
19
 
20
20
  EXIT_COMMANDS = {"/exit", "/quit"}
21
+ HELP_COMMAND = "/help"
21
22
  HISTORY_COMMAND = "/history"
22
23
  TITLE_COMMAND = "/title"
23
24
  MODEL_COMMAND = "/model"
@@ -27,7 +28,7 @@ COMPACT_COMMAND = "/compact"
27
28
  LINK_COMMAND = "/link"
28
29
  UNLINK_COMMAND = "/unlink"
29
30
  EXTRA_COMMANDS_LINE = (
30
- "Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
31
+ "Extra commands: /help, /history, /title, /model, /resume, /compact, /link, /unlink"
31
32
  )
32
33
 
33
34
 
@@ -232,6 +233,9 @@ async def run_interactive_session(
232
233
  continue
233
234
  if prompt_text in EXIT_COMMANDS:
234
235
  break
236
+ if prompt_text == HELP_COMMAND:
237
+ view.write_line(EXTRA_COMMANDS_LINE)
238
+ continue
235
239
  if prompt_text == HISTORY_COMMAND:
236
240
  view.show_history()
237
241
  continue
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-codex"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "A minimal Python extraction of Codex's main agent loop"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.6.2"
@@ -8,6 +8,7 @@ import pytest
8
8
 
9
9
  from pycodex import (
10
10
  Agent,
11
+ AgentEvent,
11
12
  AssistantMessage,
12
13
  BaseTool,
13
14
  CliSubmissionQueue,
@@ -193,6 +194,7 @@ def test_workspace_app_missing_board_returns_pending_page(tmp_path) -> None:
193
194
  app = create_app(lambda: link, board)
194
195
 
195
196
  with TestClient(app) as client:
197
+ shell = client.get("/")
196
198
  response = client.get("/board")
197
199
  status = client.get("/api/board")
198
200
 
@@ -200,6 +202,9 @@ def test_workspace_app_missing_board_returns_pending_page(tmp_path) -> None:
200
202
  assert "Board pending" in response.text
201
203
  assert str(board) in response.text
202
204
  assert status.json() == {"exists": False}
205
+ assert "let lastBoardSignature = null;" in shell.text
206
+ assert "if (lastBoardSignature === null)" in shell.text
207
+ assert "boardFrame.src = `board?t=${Date.now()}`;" in shell.text
203
208
 
204
209
 
205
210
  def test_workspace_app_session_snapshot_uses_turns_not_raw_messages(tmp_path) -> None:
@@ -218,6 +223,9 @@ def test_workspace_app_session_snapshot_uses_turns_not_raw_messages(tmp_path) ->
218
223
  assert snapshot["turns"][0]["response"] == "pong"
219
224
  assert snapshot["turns"][0]["thinking"] == ""
220
225
 
226
+ sessions = response.json()["sessions"]
227
+ assert sessions[0]["last_assistant"] == "pong"
228
+
221
229
 
222
230
  def test_workspace_app_session_endpoint_returns_spinner_and_turns(tmp_path) -> None:
223
231
  board = tmp_path / "board.html"
@@ -236,6 +244,26 @@ def test_workspace_app_session_endpoint_returns_spinner_and_turns(tmp_path) -> N
236
244
  assert snapshot["turns"][0]["response"] == "response"
237
245
 
238
246
 
247
+ def test_workspace_view_tracks_context_remaining_percent() -> None:
248
+ view = WebSessionView()
249
+ assert view.snapshot()["context_remaining_percent"] is None
250
+
251
+ view.set_context_window_tokens(100_000)
252
+ assert view.snapshot()["context_remaining_percent"] == 100
253
+ assert view.summary()["context_remaining_percent"] == 100
254
+
255
+ view.handle_event(
256
+ AgentEvent(
257
+ kind="token_count",
258
+ turn_id="turn_1",
259
+ payload={"usage": {"total_tokens": 20_800}},
260
+ )
261
+ )
262
+
263
+ assert view.snapshot()["context_remaining_percent"] == 90
264
+ assert view.summary()["context_remaining_percent"] == 90
265
+
266
+
239
267
  def test_workspace_app_shell_uses_spinner_without_send_button(tmp_path) -> None:
240
268
  board = tmp_path / "board.html"
241
269
  board.write_text("<!doctype html><title>Board</title>", encoding="utf-8")
@@ -247,7 +275,30 @@ def test_workspace_app_shell_uses_spinner_without_send_button(tmp_path) -> None:
247
275
  response = client.get("/")
248
276
 
249
277
  assert response.status_code == 200
250
- assert 'id="spinner"' in response.text
278
+ assert 'class="spinner-wrap"' in response.text
279
+ assert 'class="prompt-row"' in response.text
280
+ assert 'id="spinner" class="spinner" type="button" role="checkbox"' in response.text
281
+ assert 'id="toast" class="toast" role="status"' in response.text
282
+ assert '<textarea id="prompt" rows="1"' in response.text
283
+ assert ".spinner-wrap:not(:has(.spinner:not(:empty)))" in response.text
284
+ assert response.text.index('class="prompt-row"') < response.text.index('id="contextMeter"')
285
+ assert response.text.index('id="contextMeter"') < response.text.index('id="prompt"')
286
+ assert 'id="contextMeter" class="context-meter hidden" aria-hidden="true"' in response.text
287
+ assert 'id="contextMeterValue" class="context-meter-value"' in response.text
288
+ assert 'contextMeter.style.setProperty("--context-remaining", `${value}%`)' in response.text
289
+ assert "contextMeter.setAttribute(\"aria-valuenow\"" not in response.text
290
+ assert "contextMeterValue.textContent = String(value)" in response.text
291
+ assert "sessionId === activeSessionId && spinnerText" in response.text
292
+ assert "spinner.addEventListener(\"click\", toggleSpinnerNotification)" in response.text
293
+ assert "new Notification(`session: ${title}`" in response.text
294
+ assert "body: lastAssistant" in response.text
295
+ assert "notifySessionDone(sessionId, session)" in response.text
296
+ assert "notify-on-done enabled" in response.text
297
+ assert "notify-on-done canceled" in response.text
298
+ assert "browser notifications blocked" in response.text
299
+ assert "requestNotificationPermission" in response.text
300
+ assert 'document.addEventListener("visibilitychange"' in response.text
301
+ assert 'document.visibilityState !== "visible"' in response.text
251
302
  assert ">Send<" not in response.text
252
303
  assert "Enter sends. Shift+Enter adds a newline." not in response.text
253
304
  assert "compositionstart" in response.text
@@ -281,7 +332,18 @@ def test_workspace_spinner_tool_call_preview_is_longer_than_title() -> None:
281
332
  assert spinner.startswith("calling exec_command(")
282
333
 
283
334
 
284
- def test_workspace_app_message_uses_shared_interactive_commands(tmp_path) -> None:
335
+ @pytest.mark.parametrize(
336
+ ("command", "expected_response"),
337
+ [
338
+ ("/history", "No history yet."),
339
+ ("/help", "/history"),
340
+ ],
341
+ )
342
+ def test_workspace_app_message_uses_shared_interactive_commands(
343
+ tmp_path,
344
+ command: str,
345
+ expected_response: str,
346
+ ) -> None:
285
347
  board = tmp_path / "board.html"
286
348
  board.write_text("<!doctype html><title>Board</title>", encoding="utf-8")
287
349
  model = ScriptedModelClient(
@@ -295,10 +357,11 @@ def test_workspace_app_message_uses_shared_interactive_commands(tmp_path) -> Non
295
357
  app = create_app(build_session, board)
296
358
 
297
359
  with TestClient(app) as client:
298
- response = client.post("/api/session/message", json={"prompt": "/history"})
360
+ response = client.post("/api/session/message", json={"prompt": command})
299
361
  snapshot = _wait_for_snapshot(
300
362
  client,
301
- lambda item: item["turns"] and item["turns"][-1]["response"] == "No history yet.",
363
+ lambda item: item["turns"]
364
+ and expected_response in item["turns"][-1]["response"],
302
365
  )
303
366
 
304
367
  assert response.status_code == 200
@@ -871,6 +934,8 @@ def test_workspace_app_session_list_uses_lightweight_summary(tmp_path) -> None:
871
934
  "running": True,
872
935
  "spinner": "thinking",
873
936
  "turn_count": 123,
937
+ "last_assistant": "latest answer",
938
+ "context_remaining_percent": 48,
874
939
  }
875
940
 
876
941
  def snapshot(self):
@@ -886,6 +951,8 @@ def test_workspace_app_session_list_uses_lightweight_summary(tmp_path) -> None:
886
951
  assert response.json()["sessions"][0]["running"] is True
887
952
  assert response.json()["sessions"][0]["spinner"] == "thinking"
888
953
  assert response.json()["sessions"][0]["turn_count"] == 123
954
+ assert response.json()["sessions"][0]["last_assistant"] == "latest answer"
955
+ assert response.json()["sessions"][0]["context_remaining_percent"] == 48
889
956
 
890
957
 
891
958
  def test_workspace_app_websocket_ping(tmp_path) -> None:
@@ -25,7 +25,12 @@ from pycodex.utils.session_persist import (
25
25
  load_resumed_session_path,
26
26
  )
27
27
  from pycodex.utils import uuid7_string
28
- from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
28
+ from pycodex.utils.visualize import (
29
+ IDLE_LISTENING_STATUS,
30
+ percent_of_context_window_remaining,
31
+ shorten_title,
32
+ tool_summary,
33
+ )
29
34
  import typing
30
35
 
31
36
 
@@ -180,6 +185,8 @@ class WebSessionView:
180
185
  self._title = ""
181
186
  self._spinner_status = ""
182
187
  self._stream_buffer = ""
188
+ self._context_window_tokens: "typing.Union[int, None]" = None
189
+ self._context_remaining_percent: "typing.Union[int, None]" = None
183
190
  self._closed = False
184
191
  self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
185
192
  self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
@@ -347,7 +354,11 @@ class WebSessionView:
347
354
  self,
348
355
  context_window_tokens: "typing.Union[int, None]",
349
356
  ) -> None:
350
- del context_window_tokens
357
+ with self._lock:
358
+ self._context_window_tokens = context_window_tokens
359
+ self._context_remaining_percent = (
360
+ 100 if context_window_tokens is not None else None
361
+ )
351
362
 
352
363
  def subscribe(self) -> "asyncio.Queue":
353
364
  queue: "asyncio.Queue" = asyncio.Queue()
@@ -386,6 +397,7 @@ class WebSessionView:
386
397
  "spinner": self._spinner_status,
387
398
  "model": "pycodex",
388
399
  "title": self._title,
400
+ "context_remaining_percent": self._context_remaining_percent,
389
401
  "turns": [_public_turn(turn) for turn in self._turns[-80:]],
390
402
  }
391
403
 
@@ -396,6 +408,8 @@ class WebSessionView:
396
408
  "spinner": self._spinner_status,
397
409
  "title": self._title,
398
410
  "turn_count": len(self._turns),
411
+ "last_assistant": _last_assistant_text(self._turns),
412
+ "context_remaining_percent": self._context_remaining_percent,
399
413
  }
400
414
 
401
415
  def _apply_runtime_event(self, event: "AgentEvent") -> None:
@@ -403,6 +417,9 @@ class WebSessionView:
403
417
  payload = getattr(event, "payload", {})
404
418
  if not isinstance(payload, dict):
405
419
  payload = {}
420
+ if kind == "token_count":
421
+ self._update_context_window(payload.get("usage"))
422
+ return
406
423
  turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
407
424
  submission_id = str(payload.get("submission_id") or turn_id or "")
408
425
  turn = self._turns_by_submission_id.get(submission_id)
@@ -475,6 +492,22 @@ class WebSessionView:
475
492
  turn["status"] = "interrupted"
476
493
  self._stream_buffer = ""
477
494
 
495
+ def _update_context_window(self, usage: "object") -> None:
496
+ if self._context_window_tokens is None:
497
+ return
498
+ if not isinstance(usage, dict):
499
+ self._context_remaining_percent = None
500
+ return
501
+ try:
502
+ total_tokens = int(usage["total_tokens"])
503
+ except (KeyError, TypeError, ValueError):
504
+ self._context_remaining_percent = None
505
+ return
506
+ self._context_remaining_percent = percent_of_context_window_remaining(
507
+ total_tokens,
508
+ self._context_window_tokens,
509
+ )
510
+
478
511
  def _apply_spinner_event(
479
512
  self,
480
513
  kind: str,
@@ -974,6 +1007,10 @@ class WorkspaceSessionManager:
974
1007
  "running": bool(summary.get("running")),
975
1008
  "spinner": summary.get("spinner") or "",
976
1009
  "turn_count": summary.get("turn_count") or 0,
1010
+ "last_assistant": summary.get("last_assistant") or "",
1011
+ "context_remaining_percent": summary.get(
1012
+ "context_remaining_percent"
1013
+ ),
977
1014
  }
978
1015
  )
979
1016
  return result
@@ -1178,9 +1215,21 @@ def _session_summary(session) -> "typing.Dict[str, object]":
1178
1215
  "running": bool(snapshot.get("running")),
1179
1216
  "spinner": snapshot.get("spinner") or "",
1180
1217
  "turn_count": len(snapshot.get("turns") or []),
1218
+ "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
1219
+ "context_remaining_percent": snapshot.get("context_remaining_percent"),
1181
1220
  }
1182
1221
 
1183
1222
 
1223
+ def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
1224
+ for turn in reversed(list(turns)):
1225
+ if str(turn.get("kind") or "assistant") == "control":
1226
+ continue
1227
+ response = str(turn.get("response") or "").strip()
1228
+ if response:
1229
+ return response
1230
+ return ""
1231
+
1232
+
1184
1233
  def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
1185
1234
  return typing.cast(
1186
1235
  "typing.Dict[str, object]",