python-codex 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycodex/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
pycodex/model.py CHANGED
@@ -107,8 +107,17 @@ class ResponsesProviderConfig:
107
107
  raise ValueError(f"unknown Codex profile: {profile}")
108
108
  selected.update(overrides)
109
109
 
110
- provider_name = selected["model_provider"]
111
- provider = data["model_providers"][provider_name]
110
+ provider_name = selected.get("model_provider", "openai")
111
+ provider = data.get("model_providers", {}).get(provider_name)
112
+ if provider is None:
113
+ if provider_name != "openai":
114
+ raise ValueError(f"unknown Codex model provider: {provider_name}")
115
+ provider = {
116
+ "base_url": (
117
+ selected.get("openai_base_url") or "https://api.openai.com/v1"
118
+ ),
119
+ "env_key": "OPENAI_API_KEY",
120
+ }
112
121
  wire_api = provider.get("wire_api", "responses")
113
122
  if wire_api != "responses":
114
123
  raise ValueError(f"unsupported wire_api for Python client: {wire_api}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.3.1
3
+ Version: 0.3.3
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
@@ -25,6 +25,10 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  English README. Chinese version: `README_ZH.md`
27
27
 
28
+ 0.3.3 workspace viewers and reasoning stream fix: [release notes](docs/RELEASE_0.3.3.md).
29
+
30
+ 0.3.2 startup fixes: [release notes](docs/RELEASE_0.3.2.md).
31
+
28
32
  0.3.0 release preparation and Python API migration: `docs/RELEASE_0.3.0.md`.
29
33
 
30
34
  PyPI distributions:
@@ -60,11 +64,24 @@ Relevant Rust reference points:
60
64
  Install the full package or the workspace-oriented alias:
61
65
 
62
66
  ```bash
63
- pip install python-codex
64
- pip install pycodex-ws
67
+ python3 -m pip install python-codex
68
+ python3 -m pip install pycodex-ws
65
69
  ```
66
70
 
67
- Install dependencies first:
71
+ Use Python 3.6.2 or newer. Both console commands are installed into that
72
+ interpreter's scripts directory. On a machine with multiple Python installations,
73
+ you can select the same interpreter explicitly for startup:
74
+
75
+ ```bash
76
+ python3 -m pycodex
77
+ python3 -m workspace_server --help
78
+ ```
79
+
80
+ If `pycodex` is missing from `PATH` or resolves to an older installation, use these
81
+ module entry points. `python3 -m pip --version` shows which Python environment
82
+ received the installation.
83
+
84
+ For development from this repository, install dependencies first:
68
85
 
69
86
  ```bash
70
87
  uv sync
@@ -200,6 +217,18 @@ from pycodex import ResponsesModelClient
200
217
  client = ResponsesModelClient.from_codex_config()
201
218
  ```
202
219
 
220
+ `model_provider` is optional and defaults to `openai`. The built-in provider
221
+ does not require a `[model_providers.openai]` table: it uses
222
+ `https://api.openai.com/v1` and reads `OPENAI_API_KEY`. Set `openai_base_url` to
223
+ override this address. Explicit provider tables retain their configured
224
+ `base_url` and optional `env_key`; unknown provider names raise a configuration
225
+ error. The model still comes from `model` in the configuration or selected
226
+ profile.
227
+
228
+ Authentication currently uses API keys from environment variables or `.env`;
229
+ Codex desktop/CLI ChatGPT sign-in credentials in `auth.json` or the OS keychain
230
+ are not loaded.
231
+
203
232
  The current implementation uses the streaming OpenAI-compatible `/responses`
204
233
  endpoint. This path has already been validated against the local
205
234
  `~/.codex/config.toml` setup.
@@ -237,6 +266,45 @@ Current behavior:
237
266
  - with an argv prompt or piped stdin, run a single turn
238
267
  - `pycodex-ws` starts the standalone browser workspace manager and serves each
239
268
  workspace with a board pane and a pycodex session pane
269
+ - the left pane is the board, containing a live HTML Canvas tab and closable read-only file/diff tabs that
270
+ scroll together in one toolbar; a mouse wheel scrolls overflowing tab strips
271
+ horizontally.
272
+ Click Markdown file links such as `[source](src/app.py:42)` or
273
+ `[source](/data/project/app.py#L42)` to open a view. Git-style diff links use
274
+ `[changes](diff:HEAD:src/app.py)` for working changes,
275
+ `[versions](diff:HEAD~1..HEAD:src/app.py)` for two revisions, and
276
+ `[files](diff:old.py..new.py)` for two local files. Append `#L42` or `#R42`
277
+ to a diff link to select a 1-based line on the left or right.
278
+ Views open when the user clicks; model replies alone do not create tabs.
279
+ Reopening the same file or comparison selects its existing tab in that
280
+ browser, including when the line changes. Other pages keep their selection.
281
+ Tabs show filenames first, adding a short directory suffix
282
+ for duplicate names. Git versions use short commit IDs in a secondary label.
283
+ Long labels use the same width limit as session tabs, shortening the secondary
284
+ information before the filename;
285
+ hover titles retain the complete paths and revisions. Text,
286
+ Markdown, HTML, common raster images and text diffs are supported. Monaco
287
+ provides syntax highlighting, find, folding and character-level diffs.
288
+ Its code and styles load only when a code or diff view opens, so Canvas,
289
+ Markdown and HTML previews do not load the editor. Viewer assets use gzip
290
+ when supported and revalidate cached content with ETags.
291
+ Narrow content panes automatically use a unified diff; targeting the left
292
+ side keeps both sides visible. Markdown and HTML links default to previews;
293
+ line targets open their source. Clicking a link without a line returns that
294
+ same tab to preview. Manual tab switches preserve the chosen mode and reading state.
295
+ All views remain
296
+ read-only, with text selection and copying available. Local files update
297
+ automatically, including both sides of file diffs and the working side of
298
+ Git diffs; Git revisions stay fixed. Updates keep the active editor and
299
+ reading state without replaying an earlier line request.
300
+ Canvas is for explanations and custom visuals; source and diffs use links.
301
+ `[Canvas](workspace://canvas)` returns to the live page; an HTML file link opens
302
+ a preview tab, and `page.html:1` opens its source. File links work even with an empty `--toolset`.
303
+ Canvas and HTML previews share the same origin and script permissions;
304
+ the browser loads their resources with the workspace login cookie.
305
+ Open views survive reloads and server restarts. See
306
+ [workspace content views](docs/RUNTIME.md#workspace-content-views) for the
307
+ complete syntax and limits.
240
308
  - interactive mode exits through `/exit`, `/quit`, Ctrl+D on an empty prompt,
241
309
  or a single Ctrl+C; accepted work finishes before cleanup and normal exit.
242
310
  A `[closing]` message explains the wait. Press Ctrl+C again while closing
@@ -339,7 +407,7 @@ Current behavior:
339
407
  time when it expires.
340
408
  - while a background command or clock is pending, the idle status is
341
409
  `idle: sleeping`
342
- - only the active workspace tab shows its close button; closing it restores
410
+ - only the active workspace session tab shows its close button; closing it restores
343
411
  the selected tab's conversation, draft, and scroll position immediately
344
412
  after its snapshot loads
345
413
  - `--vllm-endpoint http://host:port` automatically launches a local
@@ -360,6 +428,10 @@ Current behavior:
360
428
  reasoning, retries the same downstream request once, and only then emits
361
429
  `response.failed` with `type = "model_output_invalid"` if the retry is still
362
430
  reasoning-only
431
+ - compat reasoning fragments are forwarded immediately as
432
+ `response.reasoning_text.delta`, including thinking from `/v1/messages`.
433
+ Continuous reasoning keeps the client's read idle timeout from expiring
434
+ while it waits for assistant text; only finalized reasoning items enter history
363
435
  - malformed JSON in ordinary downstream function-call arguments is detected by
364
436
  `responses_server` and returned as `response.failed` with `type = "server_error"`;
365
437
  the client's existing `stream_max_retries` policy retries the current request
@@ -1,4 +1,5 @@
1
1
  pycodex/__init__.py,sha256=4BBGUXImsNBdSQjp2KtshkjQK9BMz5gK93DzpWokkWY,3235
2
+ pycodex/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
2
3
  pycodex/agent.py,sha256=zCIG2DV0oQntLXHFj8FVRkgBp5TCC0YJ7sz0lni9J1s,21177
3
4
  pycodex/bootstrap.py,sha256=DSVx8u94tHRYbUa_e9zdKPqozOv_CrfctbTKZ4JG064,13765
4
5
  pycodex/cli.py,sha256=m5IJhnodNqsfMTIzdwbuaHBl2r-N8x1MxYSpe_hlHiM,12997
@@ -8,7 +9,7 @@ pycodex/doctor.py,sha256=9p624b1zloXbhyMRi1-Of1YtnYeoF4zHxfGFhfujDS4,10620
8
9
  pycodex/events.py,sha256=MZljtqra6-Hnt9Emjbn_wgp5cGaODx1w3CbmIKd5htI,29692
9
10
  pycodex/feishu_card.py,sha256=Gclo4TI3KTfdT5NDLiv58CRg6hH3sUCRXuSrHgXgYjI,27767
10
11
  pycodex/feishu_link.py,sha256=yUbEvO-8g-Bxo8_cF9s6vLZguz3NA6q3qt2A9Q09fsE,14523
11
- pycodex/model.py,sha256=cKG2STZ_Kc3ThelY6KTTRkBTCrIwcmF74Rhfhj71N08,44175
12
+ pycodex/model.py,sha256=HeHeaYEC06idh_66mM4uzyc7IZc_AS789ajByvOqbr8,44571
12
13
  pycodex/model_metadata.py,sha256=RMYhvgkm2v1nhCslSiDXPAwrRAx8FPz9QJqv8QTbWlc,1332
13
14
  pycodex/portable.py,sha256=rfOpVnBZ01RS0Kyjo_w2FNKHY2t-lUcHVtWhPsbp1-k,16158
14
15
  pycodex/portable_server.py,sha256=A4IY3xHtBQLRxdHQ4O5zdaVGjqENaLvypsSkLGZvCZ0,7498
@@ -72,20 +73,26 @@ responses_server/messages_api.py,sha256=ialAYTMmN0y-uxQB3Y51ukq-kvkSVO84WVLXaqLs
72
73
  responses_server/payload_processors.py,sha256=dqPwqHD-FhyiIwjzFEMf2wCtQDxkRyerZzbdqnH1aDE,3561
73
74
  responses_server/server.py,sha256=k4DbsOYkaHHcy38uF9u_QXahJC1cplKsTu-MdC3DCIs,2286
74
75
  responses_server/session_store.py,sha256=nwGxQL6nUVKnDVeDHz8IzqNQ7dWk8Ybf0URekOHdxTo,1133
75
- responses_server/stream_router.py,sha256=Bwuz_a_eEys7OuuN5qgtCIQF0mIo7DWDNojLI5mNetE,46062
76
+ responses_server/stream_router.py,sha256=TQX2-8uOGad3TBJnRu9SOtDaHBHjYwE-WERYz6rcuo8,46274
76
77
  responses_server/trajectory_dump.py,sha256=5_Wy6tslszrqx3j1JeP_boyTbzIicKy8No84PzMnmHk,5111
77
78
  responses_server/tools/__init__.py,sha256=ivsBSEy0SBUhY-Uea5v1XMLXShkwHdCVl0id-1FwdZg,150
78
79
  responses_server/tools/custom_adapter.py,sha256=SnIiNUdomff3APtTriURAC9YCeEdhCDjzGMEYusfUdk,8340
79
80
  responses_server/tools/web_search.py,sha256=pnKH9JyTScRehWpZS3HpXRvjWsQvC0QZqxjEOCT4ndg,8936
80
81
  workspace_server/__init__.py,sha256=PRM4ONODb6hxjBUHkBD4TprE_pkTCIlT9sfftK5ic6M,866
81
82
  workspace_server/__main__.py,sha256=876xZTqhPeDBD2GKKr8pKX33Af8PhFTosAI8bYQsFgQ,61
82
- workspace_server/app.py,sha256=oYvUFxTGurXN0PDP_opUePyW_tBE3SauiSma_U4cc9c,59260
83
+ workspace_server/app.py,sha256=aoo5vq5AciEHe3r5gcgt2B349uuWLY6fbFYbOQ2CRAE,67164
84
+ workspace_server/content_views.LICENSE.txt,sha256=PUOceZvrZI9c2LeLVh6wJNv5wwALKoJ8jOOfq-05WmQ,64183
85
+ workspace_server/content_views.css,sha256=n1yPrOZbzNS0phv2JGwaYbK8t81MI2kKe9rc3sY2TRI,1662
86
+ workspace_server/content_views.mjs,sha256=8sbYqAgwYrAE9SMd3bO6jv6DlHwWru1gboID8h2oTLE,16241
87
+ workspace_server/editor.css,sha256=4qBe2ZLqloAFQ2YNyJYvsJelvTHIIQCxpfg4EVTubk8,346450
88
+ workspace_server/editor.mjs,sha256=oZpBsmnA56VIogpbg_NDg8cs4bIQZkuUCtXPJnVLook,4110455
83
89
  workspace_server/owned_process.py,sha256=j1cJ89Nm7gS91Zj62kYKabFbb9qhbU7-q1YDsWcVcn8,895
84
- workspace_server/workspace.html,sha256=CXrMdUVM9eB03nCVwvC5coc6tMsmplOOx8wLBrJc9cw,71625
85
- workspace_server/workspaces.html,sha256=TpmfzpuCIXTVdFQzQrojxSCtBTHMSESn1zNGOmNz_W8,15787
86
- workspace_server/workspaces.py,sha256=cyyDBMo0UVqMvCjeAEHNwZCHQzRRsyn7bbYIb0INOo0,22371
87
- python_codex-0.3.1.dist-info/METADATA,sha256=WGqQcyA3LSrEQz0deJJkAotv7P2HSog5lw-ITWaSquw,38398
88
- python_codex-0.3.1.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
89
- python_codex-0.3.1.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
90
- python_codex-0.3.1.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
91
- python_codex-0.3.1.dist-info/RECORD,,
90
+ workspace_server/workspace.html,sha256=r1cBihjnyt8cxjI9xGqTVBs3zboMcoYHemgvwNMpN2A,74728
91
+ workspace_server/workspace_views.py,sha256=acV-SS3Uw-Qy5AI_KV16AowB1R3cbJN4Okk6CDbpPzw,22172
92
+ workspace_server/workspaces.html,sha256=72WEextEEUZeAYCmZ5UcWbg1m7voFezxgL6j48koRuU,15794
93
+ workspace_server/workspaces.py,sha256=hMvGyZtuFshGV3l56AMhGJN0vDOvvVj8iv9-VAJJHhw,22524
94
+ python_codex-0.3.3.dist-info/METADATA,sha256=ucgUraJ7rlCeUstF-FlAE8qiURNuqF-qOw6xKMbYF7M,42715
95
+ python_codex-0.3.3.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
96
+ python_codex-0.3.3.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
97
+ python_codex-0.3.3.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
98
+ python_codex-0.3.3.dist-info/RECORD,,
@@ -894,19 +894,23 @@ class StreamRouter:
894
894
 
895
895
  reasoning = delta.get("reasoning")
896
896
  reasoning_content = delta.get("reasoning_content")
897
- if (
898
- isinstance(reasoning, str)
899
- and reasoning
900
- and isinstance(reasoning_content, str)
901
- and reasoning == reasoning_content
902
- ):
903
- # Some chat providers expose the same delta under both aliases.
904
- reasoning_parts.append(reasoning)
905
- else:
906
- if isinstance(reasoning, str) and reasoning:
907
- reasoning_parts.append(reasoning)
908
- if isinstance(reasoning_content, str) and reasoning_content:
909
- reasoning_parts.append(reasoning_content)
897
+ reasoning_deltas = [reasoning]
898
+ # Some chat providers expose the same delta under both aliases.
899
+ if reasoning_content != reasoning:
900
+ reasoning_deltas.append(reasoning_content)
901
+ for reasoning_delta in reasoning_deltas:
902
+ if isinstance(reasoning_delta, str) and reasoning_delta:
903
+ reasoning_parts.append(reasoning_delta)
904
+ events.append(
905
+ (
906
+ "response.reasoning_text.delta",
907
+ {
908
+ "type": "response.reasoning_text.delta",
909
+ "content_index": 0,
910
+ "delta": reasoning_delta,
911
+ },
912
+ )
913
+ )
910
914
 
911
915
  content = delta.get("content")
912
916
  if isinstance(content, str) and content:
workspace_server/app.py CHANGED
@@ -1,10 +1,13 @@
1
1
  import argparse
2
2
  import asyncio
3
+ import gzip
4
+ import hashlib
3
5
  import html
4
6
  import json
5
7
  import mimetypes
6
8
  import os
7
9
  import secrets
10
+ import subprocess
8
11
  import sys
9
12
  import threading
10
13
  from dataclasses import asdict, fields, is_dataclass
@@ -63,6 +66,7 @@ from pycodex.utils.event_helpers import (
63
66
  shorten_title,
64
67
  )
65
68
 
69
+ from .workspace_views import WorkspaceViewOpener, WorkspaceViewStore
66
70
  from .workspaces import (
67
71
  WorkspaceDefinition,
68
72
  WorkspaceEntry,
@@ -122,7 +126,7 @@ def build_parser() -> "argparse.ArgumentParser":
122
126
  "--toolset",
123
127
  nargs="*",
124
128
  default=None,
125
- help="Builtin tool names for all sessions; an empty list disables tools.",
129
+ help="Builtin tool names for all sessions. An empty list disables all tools.",
126
130
  )
127
131
  parser.add_argument(
128
132
  "--timeout-seconds",
@@ -815,6 +819,7 @@ def create_app(
815
819
  session_source: "typing.Union[WorkspaceSessionManager, SessionFactory]",
816
820
  board_path: "typing.Union[Path, None]",
817
821
  password: "typing.Union[str, None]" = None,
822
+ work_dir: "typing.Union[Path, None]" = None,
818
823
  ) -> FastAPI:
819
824
  manager = (
820
825
  session_source
@@ -823,7 +828,7 @@ def create_app(
823
828
  )
824
829
  app = _create_lifespan_app(manager.start, manager.close)
825
830
  auth_token = _install_auth(app, password)
826
- _install_workspace_routes(app, manager, board_path)
831
+ _install_workspace_routes(app, manager, board_path, Path(work_dir or Path.cwd()).resolve())
827
832
  app.state.workspace_auth_token = auth_token
828
833
  return app
829
834
 
@@ -834,6 +839,7 @@ def create_multi_workspace_app(
834
839
  ) -> FastAPI:
835
840
  app = _create_lifespan_app(registry.start, registry.close)
836
841
  auth_token = _install_auth(app, password)
842
+ view_asset_cache = {}
837
843
 
838
844
  @app.get("/")
839
845
  async def index() -> Response:
@@ -899,6 +905,45 @@ def create_multi_workspace_app(
899
905
  entry = _workspace_entry_or_404(registry, workspace_id)
900
906
  return _board_status_response(entry.definition.board_path)
901
907
 
908
+ @app.get("/w/{workspace_id}/api/views")
909
+ def workspace_views(workspace_id: str) -> JSONResponse:
910
+ entry = _workspace_entry_or_404(registry, workspace_id)
911
+ return _views_response(entry.manager)
912
+
913
+ @app.post("/w/{workspace_id}/api/views/open")
914
+ def workspace_open_view(
915
+ workspace_id: str, payload: "typing.Dict[str, object]"
916
+ ) -> JSONResponse:
917
+ entry = _workspace_entry_or_404(registry, workspace_id)
918
+ return _open_view_response(entry.manager, entry.definition.work_dir, payload)
919
+
920
+ @app.get("/w/{workspace_id}/api/views/{view_id}")
921
+ def workspace_view(workspace_id: str, view_id: str) -> JSONResponse:
922
+ entry = _workspace_entry_or_404(registry, workspace_id)
923
+ return _view_response(entry.manager, view_id)
924
+
925
+ @app.delete("/w/{workspace_id}/api/views/{view_id}")
926
+ def workspace_close_view(workspace_id: str, view_id: str) -> JSONResponse:
927
+ entry = _workspace_entry_or_404(registry, workspace_id)
928
+ return _close_view_response(entry.manager, view_id)
929
+
930
+ @app.api_route(
931
+ "/w/{workspace_id}/assets/content_views.mjs", methods=["GET", "HEAD"]
932
+ )
933
+ @app.api_route(
934
+ "/w/{workspace_id}/assets/content_views.css", methods=["GET", "HEAD"]
935
+ )
936
+ @app.api_route(
937
+ "/w/{workspace_id}/assets/content_views.LICENSE.txt", methods=["GET", "HEAD"]
938
+ )
939
+ @app.api_route("/w/{workspace_id}/assets/editor.mjs", methods=["GET", "HEAD"])
940
+ @app.api_route("/w/{workspace_id}/assets/editor.css", methods=["GET", "HEAD"])
941
+ def workspace_view_asset(workspace_id: str, request: Request) -> Response:
942
+ _workspace_entry_or_404(registry, workspace_id)
943
+ return _view_asset_response(
944
+ request.url.path.rsplit("/", 1)[-1], request, view_asset_cache
945
+ )
946
+
902
947
  @app.get("/w/{workspace_id}/ws/session")
903
948
  async def workspace_websocket_backend_hint(workspace_id: str) -> JSONResponse:
904
949
  _workspace_entry_or_404(registry, workspace_id)
@@ -1139,10 +1184,13 @@ def _install_workspace_routes(
1139
1184
  app: FastAPI,
1140
1185
  manager: WorkspaceSessionManager,
1141
1186
  board_path: "typing.Union[Path, None]",
1187
+ work_dir: Path,
1142
1188
  ) -> None:
1189
+ view_asset_cache = {}
1190
+
1143
1191
  @app.get("/")
1144
1192
  async def index() -> HTMLResponse:
1145
- return _html_response(_render_workspace_shell(board_path))
1193
+ return _html_response(_render_workspace_shell(board_path, work_dir=work_dir))
1146
1194
 
1147
1195
  @app.get("/favicon.ico")
1148
1196
  async def favicon() -> Response:
@@ -1156,6 +1204,32 @@ def _install_workspace_routes(
1156
1204
  async def board_status() -> JSONResponse:
1157
1205
  return _board_status_response(board_path)
1158
1206
 
1207
+ @app.get("/api/views")
1208
+ def views() -> JSONResponse:
1209
+ return _views_response(manager)
1210
+
1211
+ @app.post("/api/views/open")
1212
+ def open_view(payload: "typing.Dict[str, object]") -> JSONResponse:
1213
+ return _open_view_response(manager, work_dir, payload)
1214
+
1215
+ @app.get("/api/views/{view_id}")
1216
+ def view(view_id: str) -> JSONResponse:
1217
+ return _view_response(manager, view_id)
1218
+
1219
+ @app.delete("/api/views/{view_id}")
1220
+ def close_view(view_id: str) -> JSONResponse:
1221
+ return _close_view_response(manager, view_id)
1222
+
1223
+ @app.api_route("/assets/content_views.mjs", methods=["GET", "HEAD"])
1224
+ @app.api_route("/assets/content_views.css", methods=["GET", "HEAD"])
1225
+ @app.api_route("/assets/content_views.LICENSE.txt", methods=["GET", "HEAD"])
1226
+ @app.api_route("/assets/editor.mjs", methods=["GET", "HEAD"])
1227
+ @app.api_route("/assets/editor.css", methods=["GET", "HEAD"])
1228
+ def view_asset(request: Request) -> Response:
1229
+ return _view_asset_response(
1230
+ request.url.path.rsplit("/", 1)[-1], request, view_asset_cache
1231
+ )
1232
+
1159
1233
  @app.get("/ws/session")
1160
1234
  async def websocket_backend_hint() -> JSONResponse:
1161
1235
  return _websocket_backend_hint_response()
@@ -1199,6 +1273,117 @@ def _install_workspace_routes(
1199
1273
  return _board_asset_response(board_path, asset_path)
1200
1274
 
1201
1275
 
1276
+ def _views_response(manager: WorkspaceSessionManager) -> JSONResponse:
1277
+ try:
1278
+ views = manager.views.list_views()
1279
+ except (OSError, ValueError) as error:
1280
+ raise HTTPException(status_code=400, detail=str(error))
1281
+ return JSONResponse({"views": views}, headers={"Cache-Control": "no-store"})
1282
+
1283
+
1284
+ def _open_view_response(
1285
+ manager: WorkspaceSessionManager, work_dir: Path, payload: "typing.Dict[str, object]"
1286
+ ) -> JSONResponse:
1287
+ try:
1288
+ view = WorkspaceViewOpener(manager.views, work_dir).open(payload.get("href"))
1289
+ views = manager.views.list_views()
1290
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
1291
+ raise HTTPException(status_code=400, detail=str(error))
1292
+ return JSONResponse(
1293
+ {"view": view, "views": views}, headers={"Cache-Control": "no-store"}
1294
+ )
1295
+
1296
+
1297
+ def _view_response(manager: WorkspaceSessionManager, view_id: str) -> JSONResponse:
1298
+ try:
1299
+ view = manager.views.get(view_id)
1300
+ except KeyError:
1301
+ raise HTTPException(status_code=404, detail="view not found")
1302
+ except (OSError, ValueError) as error:
1303
+ raise HTTPException(status_code=400, detail=str(error))
1304
+ return JSONResponse(view, headers={"Cache-Control": "no-store"})
1305
+
1306
+
1307
+ def _close_view_response(
1308
+ manager: WorkspaceSessionManager, view_id: str
1309
+ ) -> JSONResponse:
1310
+ try:
1311
+ manager.views.close(view_id)
1312
+ except KeyError:
1313
+ raise HTTPException(status_code=404, detail="view not found")
1314
+ return JSONResponse({"ok": True})
1315
+
1316
+
1317
+ def _view_asset_encoding(accept_encoding: str) -> str:
1318
+ qualities = {}
1319
+ for entry in accept_encoding.lower().split(","):
1320
+ name, *parameters = entry.strip().split(";")
1321
+ quality = 1.0
1322
+ for parameter in parameters:
1323
+ key, _, value = parameter.strip().partition("=")
1324
+ if key != "q":
1325
+ continue
1326
+ try:
1327
+ quality = float(value)
1328
+ except ValueError as exc:
1329
+ raise HTTPException(
1330
+ status_code=400, detail="invalid encoding quality"
1331
+ ) from exc
1332
+ if not 0 <= quality <= 1:
1333
+ raise HTTPException(status_code=400, detail="invalid encoding quality")
1334
+ qualities[name.strip()] = quality
1335
+ gzip_quality = qualities.get("gzip", qualities.get("*", 0))
1336
+ if gzip_quality > 0 and gzip_quality >= qualities.get("identity", 0):
1337
+ return "gzip"
1338
+ if qualities.get("identity", 0 if qualities.get("*") == 0 else 1) > 0:
1339
+ return "identity"
1340
+ raise HTTPException(status_code=406, detail="no acceptable asset encoding")
1341
+
1342
+
1343
+ def _view_asset_response(asset_name: str, request: Request, cache: dict) -> Response:
1344
+ media_types = {
1345
+ "content_views.mjs": "text/javascript",
1346
+ "content_views.css": "text/css",
1347
+ "content_views.LICENSE.txt": "text/plain",
1348
+ "editor.mjs": "text/javascript",
1349
+ "editor.css": "text/css",
1350
+ }
1351
+ if asset_name not in media_types:
1352
+ raise HTTPException(status_code=404, detail="asset not found")
1353
+ encoding = _view_asset_encoding(request.headers.get("accept-encoding", ""))
1354
+ path = Path(__file__).with_name(asset_name)
1355
+ stat = path.stat()
1356
+ stamp = (stat.st_mtime_ns, stat.st_size)
1357
+ key = (asset_name, encoding)
1358
+ cached = cache.get(key)
1359
+ if cached is None or cached[0] != stamp:
1360
+ body = path.read_bytes()
1361
+ if encoding == "gzip":
1362
+ body = gzip.compress(body, compresslevel=6)
1363
+ etag = '"' + hashlib.sha256(body).hexdigest() + '"'
1364
+ cached = cache[key] = (stamp, body, etag)
1365
+ _, body, etag = cached
1366
+ headers = {
1367
+ "Cache-Control": "no-cache",
1368
+ "X-Content-Type-Options": "nosniff",
1369
+ "Vary": "Accept-Encoding",
1370
+ "ETag": etag,
1371
+ }
1372
+ if encoding == "gzip":
1373
+ headers["Content-Encoding"] = "gzip"
1374
+ for candidate in request.headers.get("if-none-match", "").split(","):
1375
+ candidate = candidate.strip()
1376
+ if candidate.startswith("W/"):
1377
+ candidate = candidate[2:]
1378
+ if candidate in ("*", etag):
1379
+ return Response(status_code=304, headers=headers)
1380
+ headers["Content-Length"] = str(len(body))
1381
+ return Response(
1382
+ b"" if request.method == "HEAD" else body,
1383
+ media_type=media_types[asset_name], headers=headers,
1384
+ )
1385
+
1386
+
1202
1387
  def _board_status_response(board_path: "typing.Union[Path, None]") -> JSONResponse:
1203
1388
  if board_path is None or not board_path.is_file():
1204
1389
  return JSONResponse({"exists": False})
@@ -1560,6 +1745,8 @@ def _build_workspace_entry(
1560
1745
  args: "argparse.Namespace",
1561
1746
  persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
1562
1747
  ) -> "WorkspaceEntry":
1748
+ views = WorkspaceViewStore(definition.board_path)
1749
+
1563
1750
  def build_session() -> "WorkspaceInteractiveSession":
1564
1751
  model = build_model(
1565
1752
  config_path=args.config,
@@ -1575,7 +1762,7 @@ def _build_workspace_entry(
1575
1762
  profile=args.profile,
1576
1763
  system_prompt=args.system_prompt,
1577
1764
  extra_contextual_user_messages=(
1578
- [_board_context_text(definition.board_path, definition.work_dir)]
1765
+ [_workspace_context_text(definition.board_path, definition.work_dir)]
1579
1766
  if definition.board_path is not None
1580
1767
  else []
1581
1768
  ),
@@ -1598,18 +1785,24 @@ def _build_workspace_entry(
1598
1785
  session_factory,
1599
1786
  definition.board_path,
1600
1787
  persist_callback=persist_callback,
1788
+ views=views,
1601
1789
  ),
1602
1790
  )
1603
1791
 
1604
1792
 
1605
- def _board_context_text(
1793
+ def _workspace_context_text(
1606
1794
  board_path: Path,
1607
- work_dir: "typing.Union[Path, None]" = None,
1795
+ work_dir: "typing.Union[Path, None]",
1608
1796
  ) -> str:
1609
1797
  return (
1610
- "Current workspace board file: {0}. "
1611
- "Changes you make to this file are shown to the user in real time. "
1612
- "You can create or modify this file anytime."
1798
+ "Current workspace Canvas file: {0}. "
1799
+ "Changes you make to this HTML file are shown to the user in real time. "
1800
+ "You can create or modify this file anytime.\n\n"
1801
+ "Replies and Canvas HTML can use extended links to open files and diffs "
1802
+ "in the board: git:REV:path, diff:REV:path, diff:BASE..HEAD:path, "
1803
+ "and diff:left..right. Append #L42 to select a file or left-side line, "
1804
+ "or #R42 for the right side of a diff. "
1805
+ "Use workspace://canvas to return to the live Canvas."
1613
1806
  ).format(_format_board_path_for_prompt(board_path, work_dir=work_dir))
1614
1807
 
1615
1808
 
@@ -1662,7 +1855,7 @@ def _render_empty_board() -> str:
1662
1855
 
1663
1856
  def _render_missing_board(board_path: Path) -> str:
1664
1857
  return _render_board_placeholder(
1665
- "Board pending",
1858
+ "Canvas pending",
1666
1859
  "Your work will appear here",
1667
1860
  "Ask pycodex to create a page, a report, or a visual. "
1668
1861
  "This canvas updates as you work.",