python-codex 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.3.2
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,8 @@ 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
+
28
30
  0.3.2 startup fixes: [release notes](docs/RELEASE_0.3.2.md).
29
31
 
30
32
  0.3.0 release preparation and Python API migration: `docs/RELEASE_0.3.0.md`.
@@ -264,6 +266,45 @@ Current behavior:
264
266
  - with an argv prompt or piped stdin, run a single turn
265
267
  - `pycodex-ws` starts the standalone browser workspace manager and serves each
266
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.
267
308
  - interactive mode exits through `/exit`, `/quit`, Ctrl+D on an empty prompt,
268
309
  or a single Ctrl+C; accepted work finishes before cleanup and normal exit.
269
310
  A `[closing]` message explains the wait. Press Ctrl+C again while closing
@@ -366,7 +407,7 @@ Current behavior:
366
407
  time when it expires.
367
408
  - while a background command or clock is pending, the idle status is
368
409
  `idle: sleeping`
369
- - 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
370
411
  the selected tab's conversation, draft, and scroll position immediately
371
412
  after its snapshot loads
372
413
  - `--vllm-endpoint http://host:port` automatically launches a local
@@ -387,6 +428,10 @@ Current behavior:
387
428
  reasoning, retries the same downstream request once, and only then emits
388
429
  `response.failed` with `type = "model_output_invalid"` if the retry is still
389
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
390
435
  - malformed JSON in ordinary downstream function-call arguments is detected by
391
436
  `responses_server` and returned as `response.failed` with `type = "server_error"`;
392
437
  the client's existing `stream_max_retries` policy retries the current request
@@ -73,20 +73,26 @@ responses_server/messages_api.py,sha256=ialAYTMmN0y-uxQB3Y51ukq-kvkSVO84WVLXaqLs
73
73
  responses_server/payload_processors.py,sha256=dqPwqHD-FhyiIwjzFEMf2wCtQDxkRyerZzbdqnH1aDE,3561
74
74
  responses_server/server.py,sha256=k4DbsOYkaHHcy38uF9u_QXahJC1cplKsTu-MdC3DCIs,2286
75
75
  responses_server/session_store.py,sha256=nwGxQL6nUVKnDVeDHz8IzqNQ7dWk8Ybf0URekOHdxTo,1133
76
- responses_server/stream_router.py,sha256=Bwuz_a_eEys7OuuN5qgtCIQF0mIo7DWDNojLI5mNetE,46062
76
+ responses_server/stream_router.py,sha256=TQX2-8uOGad3TBJnRu9SOtDaHBHjYwE-WERYz6rcuo8,46274
77
77
  responses_server/trajectory_dump.py,sha256=5_Wy6tslszrqx3j1JeP_boyTbzIicKy8No84PzMnmHk,5111
78
78
  responses_server/tools/__init__.py,sha256=ivsBSEy0SBUhY-Uea5v1XMLXShkwHdCVl0id-1FwdZg,150
79
79
  responses_server/tools/custom_adapter.py,sha256=SnIiNUdomff3APtTriURAC9YCeEdhCDjzGMEYusfUdk,8340
80
80
  responses_server/tools/web_search.py,sha256=pnKH9JyTScRehWpZS3HpXRvjWsQvC0QZqxjEOCT4ndg,8936
81
81
  workspace_server/__init__.py,sha256=PRM4ONODb6hxjBUHkBD4TprE_pkTCIlT9sfftK5ic6M,866
82
82
  workspace_server/__main__.py,sha256=876xZTqhPeDBD2GKKr8pKX33Af8PhFTosAI8bYQsFgQ,61
83
- 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
84
89
  workspace_server/owned_process.py,sha256=j1cJ89Nm7gS91Zj62kYKabFbb9qhbU7-q1YDsWcVcn8,895
85
- workspace_server/workspace.html,sha256=CXrMdUVM9eB03nCVwvC5coc6tMsmplOOx8wLBrJc9cw,71625
86
- workspace_server/workspaces.html,sha256=TpmfzpuCIXTVdFQzQrojxSCtBTHMSESn1zNGOmNz_W8,15787
87
- workspace_server/workspaces.py,sha256=cyyDBMo0UVqMvCjeAEHNwZCHQzRRsyn7bbYIb0INOo0,22371
88
- python_codex-0.3.2.dist-info/METADATA,sha256=PClQOhAVKTGw8gaoYjP_G2pfp6j5QEZQS8uHPccymqg,39624
89
- python_codex-0.3.2.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
90
- python_codex-0.3.2.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
91
- python_codex-0.3.2.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
92
- python_codex-0.3.2.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.",