pycodex-ws 0.4.8__tar.gz → 0.5.1__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 (49) hide show
  1. pycodex_ws-0.5.1/PKG-INFO +121 -0
  2. pycodex_ws-0.5.1/README.md +110 -0
  3. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/index.html +11 -1
  4. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_card.py +2 -2
  5. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_link.py +1 -1
  6. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pyproject.toml +2 -2
  7. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/app.py +54 -8
  8. pycodex_ws-0.5.1/workspace_server/content_views.css +1 -0
  9. pycodex_ws-0.5.1/workspace_server/content_views.mjs +32 -0
  10. pycodex_ws-0.5.1/workspace_server/customization.py +80 -0
  11. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/composer.mjs +28 -2
  12. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/content_views.css +27 -3
  13. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/content_views.mjs +41 -3
  14. pycodex_ws-0.5.1/workspace_server/frontend/prompt_history.mjs +85 -0
  15. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspace.html +501 -576
  16. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspaces.html +11 -1
  17. pycodex_ws-0.4.8/PKG-INFO +0 -166
  18. pycodex_ws-0.4.8/README.md +0 -155
  19. pycodex_ws-0.4.8/workspace_server/content_views.css +0 -1
  20. pycodex_ws-0.4.8/workspace_server/content_views.mjs +0 -31
  21. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/.gitignore +0 -0
  22. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/LICENSE +0 -0
  23. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/__init__.py +0 -0
  24. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/__main__.py +0 -0
  25. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/browser.py +0 -0
  26. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/client.py +0 -0
  27. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/protocol.py +0 -0
  28. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_router/server.py +0 -0
  29. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/__init__.py +0 -0
  30. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/pycodex_ws/feishu_oauth.py +0 -0
  31. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/__init__.py +0 -0
  32. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/__main__.py +0 -0
  33. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/agent_access.py +0 -0
  34. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/content_views.LICENSE.txt +0 -0
  35. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/editor.css +0 -0
  36. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/editor.mjs +0 -0
  37. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/.gitignore +0 -0
  38. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/build.mjs +0 -0
  39. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor.css +0 -0
  40. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor.mjs +0 -0
  41. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/editor_context.mjs +0 -0
  42. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/package-lock.json +0 -0
  43. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/package.json +0 -0
  44. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/touch_selection.mjs +0 -0
  45. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/frontend/view_labels.mjs +0 -0
  46. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/owned_process.py +0 -0
  47. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/runtime_info.py +0 -0
  48. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspace_views.py +0 -0
  49. {pycodex_ws-0.4.8 → pycodex_ws-0.5.1}/workspace_server/workspaces.py +0 -0
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.1
2
+ Name: pycodex-ws
3
+ Version: 0.5.1
4
+ Summary: Browser workspace and optional Feishu integration for python-codex
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: pillow>=9.0.0
8
+ Requires-Dist: python-codex==0.5.1
9
+ Requires-Dist: websockets<16,>=11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pycodex-ws
13
+
14
+ Browser conversations, a live HTML Canvas, read-only file/diff viewers and
15
+ optional Feishu integration for
16
+ [`python-codex`](https://pypi.org/project/python-codex/).
17
+ Requires Python 3.7+ and installs the matching core version.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ python3 -m pip install pycodex-ws
23
+ ```
24
+
25
+ Create `workspaces.json`:
26
+
27
+ ```json
28
+ {
29
+ "workspaces": [
30
+ {"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
31
+ ]
32
+ }
33
+ ```
34
+
35
+ ```bash
36
+ pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
37
+ ```
38
+
39
+ Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
40
+ is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
41
+ Model configuration comes from `~/.codex/config.toml`.
42
+
43
+ ## Conversations and views
44
+
45
+ The session sidebar starts collapsed. Hover to preview it, click to keep it
46
+ open, and click the toggle or empty space again to close it. Expansion narrows
47
+ the conversation while keeping the board fixed. On phones, use the header
48
+ buttons to switch between chat and board.
49
+
50
+ Use the expanded sidebar to create, rename or close sessions. Status dots show
51
+ running sessions and unread replies. `/cd <directory>` changes the current
52
+ session's working directory while idle; `/model`, `/resume`, `/compact` and
53
+ other core session commands are also available.
54
+
55
+ Click Markdown links to open files and comparisons, for example
56
+ `[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
57
+ select text and choose **Insert into chat** to add editable context to the draft.
58
+ Click a context block to edit or remove it before sending.
59
+ See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
60
+
61
+ When using a router, its directory groups workspaces by device and lists unread
62
+ workspaces at the top. Device handles reorder the list; pencil buttons edit
63
+ device names.
64
+
65
+ The [VS Code extension](extensions/vscode/README.md) uses this backend and is
66
+ distributed separately as a VSIX.
67
+
68
+ ## Customize the interface
69
+
70
+ Click the gear button to start a customization conversation with the Agent.
71
+ Custom files live in the server user's `~/.pycodex/frontend/`:
72
+
73
+ - `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
74
+ - `workspace.html`: optional full-page override; otherwise the packaged page is used.
75
+ - Other files: available at workspace-relative `frontend/<path>` URLs.
76
+
77
+ Refresh to apply changes and keep the selected conversation. Open the workspace
78
+ with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
79
+ to return. Changes apply to all workspaces for that OS user.
80
+ See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
81
+
82
+ ## Storage
83
+
84
+ Paths in `workspaces.json` resolve from the configuration directory.
85
+ `state_path` is a unique filename prefix: the example saves sessions in
86
+ `demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
87
+ `demo.html`. Suffixes are appended to the complete prefix, including any dots.
88
+
89
+ Set `enable_board` to `false` to disable Canvas while retaining conversations
90
+ and file views. Session directories and history survive server restarts;
91
+ changing a session's directory does not move Canvas or workspace state files.
92
+ Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
93
+
94
+ ## Feishu
95
+
96
+ Install the optional SDK in the workspace's Python environment, then authorize:
97
+
98
+ ```bash
99
+ python3 -m pip install lark-oapi
100
+ python3 -m pycodex_ws.feishu_oauth
101
+ ```
102
+
103
+ Authorization reads app credentials from `~/.codex/.env` and stores the refresh
104
+ token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
105
+ `/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
106
+ Other workspace features work without the SDK.
107
+
108
+ ## Development
109
+
110
+ From the repository root:
111
+
112
+ ```bash
113
+ uv sync --dev
114
+ uv run pycodex-ws --help
115
+ uv run --dev pytest packages/pycodex-ws/tests
116
+ uv build packages/pycodex-ws
117
+ ```
118
+
119
+ Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
120
+ commands. See [runtime contracts](../../docs/RUNTIME.md) and
121
+ [test documentation](../../tests/TESTS.md).
@@ -0,0 +1,110 @@
1
+ # pycodex-ws
2
+
3
+ Browser conversations, a live HTML Canvas, read-only file/diff viewers and
4
+ optional Feishu integration for
5
+ [`python-codex`](https://pypi.org/project/python-codex/).
6
+ Requires Python 3.7+ and installs the matching core version.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ python3 -m pip install pycodex-ws
12
+ ```
13
+
14
+ Create `workspaces.json`:
15
+
16
+ ```json
17
+ {
18
+ "workspaces": [
19
+ {"id": "demo", "work_dir": ".", "state_path": "demo", "enable_board": true}
20
+ ]
21
+ }
22
+ ```
23
+
24
+ ```bash
25
+ pycodex-ws --listen 127.0.0.1:6007 --workspace-config workspaces.json
26
+ ```
27
+
28
+ Open `http://127.0.0.1:6007`. Use `python3 -m workspace_server` if the command
29
+ is not on `PATH`. Keep stdin open while the server runs; EOF initiates shutdown.
30
+ Model configuration comes from `~/.codex/config.toml`.
31
+
32
+ ## Conversations and views
33
+
34
+ The session sidebar starts collapsed. Hover to preview it, click to keep it
35
+ open, and click the toggle or empty space again to close it. Expansion narrows
36
+ the conversation while keeping the board fixed. On phones, use the header
37
+ buttons to switch between chat and board.
38
+
39
+ Use the expanded sidebar to create, rename or close sessions. Status dots show
40
+ running sessions and unread replies. `/cd <directory>` changes the current
41
+ session's working directory while idle; `/model`, `/resume`, `/compact` and
42
+ other core session commands are also available.
43
+
44
+ Click Markdown links to open files and comparisons, for example
45
+ `[source](app.py:42)` or `[changes](diff:HEAD:app.py)`. In a code or diff view,
46
+ select text and choose **Insert into chat** to add editable context to the draft.
47
+ Click a context block to edit or remove it before sending.
48
+ See [view links and supported formats](../../docs/RUNTIME.md#workspace-content-views).
49
+
50
+ When using a router, its directory groups workspaces by device and lists unread
51
+ workspaces at the top. Device handles reorder the list; pencil buttons edit
52
+ device names.
53
+
54
+ The [VS Code extension](extensions/vscode/README.md) uses this backend and is
55
+ distributed separately as a VSIX.
56
+
57
+ ## Customize the interface
58
+
59
+ Click the gear button to start a customization conversation with the Agent.
60
+ Custom files live in the server user's `~/.pycodex/frontend/`:
61
+
62
+ - `custom.css` and `custom.js`: optional styles and an ES module, loaded automatically.
63
+ - `workspace.html`: optional full-page override; otherwise the packaged page is used.
64
+ - Other files: available at workspace-relative `frontend/<path>` URLs.
65
+
66
+ Refresh to apply changes and keep the selected conversation. Open the workspace
67
+ with `?builtin=1` to bypass all customizations; click “Re-enable customizations”
68
+ to return. Changes apply to all workspaces for that OS user.
69
+ See the [customization contract](../../docs/RUNTIME.md#frontend-customization).
70
+
71
+ ## Storage
72
+
73
+ Paths in `workspaces.json` resolve from the configuration directory.
74
+ `state_path` is a unique filename prefix: the example saves sessions in
75
+ `demo.pycodex-ws.json`, file views in `demo.pycodex-views.json`, and Canvas in
76
+ `demo.html`. Suffixes are appended to the complete prefix, including any dots.
77
+
78
+ Set `enable_board` to `false` to disable Canvas while retaining conversations
79
+ and file views. Session directories and history survive server restarts;
80
+ changing a session's directory does not move Canvas or workspace state files.
81
+ Configuration and restoration details are in the [runtime guide](../../docs/RUNTIME.md).
82
+
83
+ ## Feishu
84
+
85
+ Install the optional SDK in the workspace's Python environment, then authorize:
86
+
87
+ ```bash
88
+ python3 -m pip install lark-oapi
89
+ python3 -m pycodex_ws.feishu_oauth
90
+ ```
91
+
92
+ Authorization reads app credentials from `~/.codex/.env` and stores the refresh
93
+ token in `~/.codex/.feishu_refresh_token`. In a workspace session, use
94
+ `/link <feishu-email|open_id|chat_id>` to connect a card and `/unlink` to disconnect.
95
+ Other workspace features work without the SDK.
96
+
97
+ ## Development
98
+
99
+ From the repository root:
100
+
101
+ ```bash
102
+ uv sync --dev
103
+ uv run pycodex-ws --help
104
+ uv run --dev pytest packages/pycodex-ws/tests
105
+ uv build packages/pycodex-ws
106
+ ```
107
+
108
+ Python integrations use `pycodex_ws.build_runtime(agent)` to include workspace
109
+ commands. See [runtime contracts](../../docs/RUNTIME.md) and
110
+ [test documentation](../../tests/TESTS.md).
@@ -19,6 +19,8 @@
19
19
  --danger: #b54739;
20
20
  --unread-bg: #fff7f4;
21
21
  --unread-line: #ead0c8;
22
+ --breathing-duration: 1.2s;
23
+ --breathing-ease: cubic-bezier(0.4, 0, 0.2, 1);
22
24
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
25
  }
24
26
  @media (prefers-color-scheme: dark) {
@@ -97,7 +99,7 @@
97
99
  .activity-badge[hidden] { display: none; }
98
100
  .workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
99
101
  .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
100
- .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
102
+ .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse var(--breathing-duration) var(--breathing-ease) infinite; }
101
103
  @keyframes spinner-pulse {
102
104
  0%, 100% { opacity: .45; transform: scale(.82); }
103
105
  50% { opacity: 1; transform: scale(1.08); }
@@ -643,6 +645,14 @@
643
645
  document.getElementById("activity").textContent = running ? `${running} working` : "";
644
646
  }
645
647
 
648
+ document.addEventListener("animationstart", event => {
649
+ if (event.animationName !== "spinner-pulse") return;
650
+ // Include pseudo-elements and anchor new indicators to the page's breathing phase.
651
+ for (const animation of event.target.getAnimations({subtree: true})) {
652
+ if (animation.animationName === event.animationName) animation.startTime = 0;
653
+ }
654
+ });
655
+
646
656
  async function refresh() {
647
657
  if (refreshing) return;
648
658
  refreshing = true;
@@ -357,9 +357,9 @@ class PycodexCard:
357
357
  or getattr(action_payload, "input_value", None)
358
358
  or value.get("input_value")
359
359
  or ""
360
- ).strip()
360
+ )
361
361
  if not prompt and form_values:
362
- prompt = str(next(iter(form_values.values()))).strip()
362
+ prompt = str(next(iter(form_values.values())))
363
363
  action = str(
364
364
  value.get("action") or getattr(action_payload, "name", None) or "send"
365
365
  ).strip()
@@ -105,7 +105,7 @@ class PycodexRuntimeLink:
105
105
  "content": "Unsupported action: {0}".format(action.get("action")),
106
106
  }
107
107
  }
108
- prompt = str(action.get("prompt") or "").strip()
108
+ prompt = str(action.get("prompt") or "")
109
109
  future = asyncio.run_coroutine_threadsafe(
110
110
  self.runtime.submit_input(
111
111
  prompt, sender=str(action.get("sender") or "feishu")
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodex-ws"
7
- version = "0.4.8"
7
+ version = "0.5.1"
8
8
  description = "Browser workspace and optional Feishu integration for python-codex"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
11
11
  dependencies = [
12
- "python-codex==0.4.8",
12
+ "python-codex==0.5.1",
13
13
  "pillow>=9.0.0",
14
14
  "websockets>=11,<16",
15
15
  ]
@@ -68,6 +68,7 @@ from pycodex.events import (
68
68
  )
69
69
  from pycodex.model import DEFAULT_CODEX_CONFIG_PATH
70
70
  from pycodex.utils import uuid7_string
71
+ from pycodex.utils.compat import importlib_metadata
71
72
  from pycodex.utils.event_helpers import (
72
73
  completed_history,
73
74
  shorten_title,
@@ -76,6 +77,11 @@ from pycodex.utils.session_persist import load_display_history
76
77
  from pycodex_ws import build_runtime
77
78
  from pycodex_router.protocol import trusted_agent_request
78
79
 
80
+ from .customization import (
81
+ frontend_asset_response,
82
+ frontend_directory,
83
+ load_workspace_template,
84
+ )
79
85
  from .runtime_info import runtime_info_prompt
80
86
  from .workspace_views import WorkspaceViewOpener
81
87
  from .workspaces import (
@@ -391,6 +397,9 @@ class WebSessionView:
391
397
  active["submission_id"],
392
398
  )
393
399
  )
400
+ self._turns_by_turn_id[active["turn_id"]]["model_errors"] = list(
401
+ active.get("model_errors", [])
402
+ )
394
403
  elif event.reason == "title":
395
404
  self.set_session_title(state["title"])
396
405
  else:
@@ -458,6 +467,10 @@ class WebSessionView:
458
467
  turn["_thinking_active"] = True
459
468
  return
460
469
 
470
+ if isinstance(event, StreamErrorEvent):
471
+ turn.setdefault("model_errors", []).append(event.detail())
472
+ return
473
+
461
474
  if isinstance(event, ToolStartedEvent):
462
475
  turn["status"] = "tool"
463
476
  turn["tool_name"] = event.call.name
@@ -950,6 +963,7 @@ def create_multi_workspace_app(
950
963
  entry.definition.board_path,
951
964
  title=entry.definition.workspace_id,
952
965
  work_dir=entry.definition.work_dir,
966
+ builtin=request.query_params.get("builtin") == "1",
953
967
  )
954
968
  )
955
969
 
@@ -996,7 +1010,11 @@ def create_multi_workspace_app(
996
1010
 
997
1011
  async def workspace_new_session(request: Request) -> JSONResponse:
998
1012
  entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
999
- return await _new_session_response(entry.manager)
1013
+ return await _new_session_response(entry.manager, request)
1014
+
1015
+ def workspace_frontend_asset(request: Request) -> Response:
1016
+ _workspace_entry_or_404(registry, request.path_params["workspace_id"])
1017
+ return frontend_asset_response(request.path_params["asset_path"])
1000
1018
 
1001
1019
  async def workspace_delete_session(request: Request) -> JSONResponse:
1002
1020
  entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
@@ -1082,6 +1100,10 @@ def create_multi_workspace_app(
1082
1100
  ),
1083
1101
  Route("/w/{workspace_id}/api/sessions", workspace_sessions, methods=["GET"]),
1084
1102
  Route("/w/{workspace_id}/api/sessions", workspace_new_session, methods=["POST"]),
1103
+ Route(
1104
+ "/w/{workspace_id}/frontend/{asset_path:path}",
1105
+ workspace_frontend_asset, methods=["GET", "HEAD"],
1106
+ ),
1085
1107
  Route(
1086
1108
  "/w/{workspace_id}/api/sessions/{session_id}",
1087
1109
  workspace_delete_session, methods=["DELETE"],
@@ -1368,7 +1390,10 @@ def _install_workspace_routes(
1368
1390
  view_asset_cache = {}
1369
1391
 
1370
1392
  async def index(request: Request) -> HTMLResponse:
1371
- return _html_response(_render_workspace_shell(board_path, work_dir=work_dir))
1393
+ return _html_response(_render_workspace_shell(
1394
+ board_path, work_dir=work_dir,
1395
+ builtin=request.query_params.get("builtin") == "1",
1396
+ ))
1372
1397
 
1373
1398
  async def favicon(request: Request) -> Response:
1374
1399
  return Response(status_code=204)
@@ -1404,7 +1429,10 @@ def _install_workspace_routes(
1404
1429
  return _sessions_response(manager)
1405
1430
 
1406
1431
  async def new_session(request: Request) -> JSONResponse:
1407
- return await _new_session_response(manager)
1432
+ return await _new_session_response(manager, request)
1433
+
1434
+ def frontend_asset(request: Request) -> Response:
1435
+ return frontend_asset_response(request.path_params["asset_path"])
1408
1436
 
1409
1437
  async def delete_session(request: Request) -> JSONResponse:
1410
1438
  return await _delete_session_response(manager, request.path_params["session_id"])
@@ -1449,6 +1477,7 @@ def _install_workspace_routes(
1449
1477
  Route("/ws/session", websocket_backend_hint, methods=["GET"]),
1450
1478
  Route("/api/sessions", sessions, methods=["GET"]),
1451
1479
  Route("/api/sessions", new_session, methods=["POST"]),
1480
+ Route("/frontend/{asset_path:path}", frontend_asset, methods=["GET", "HEAD"]),
1452
1481
  Route("/api/sessions/{session_id}", delete_session, methods=["DELETE"]),
1453
1482
  Route("/api/sessions/{session_id}/read", read_session, methods=["POST"]),
1454
1483
  Route("/api/session", session, methods=["GET"]),
@@ -1619,15 +1648,27 @@ def _read_session_response(
1619
1648
  return _sessions_response(manager)
1620
1649
 
1621
1650
 
1622
- async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1623
- session_id = await manager.create_session()
1651
+ async def _new_session_response(
1652
+ manager: "WorkspaceSessionManager", request: Request,
1653
+ ) -> JSONResponse:
1654
+ payload = await _json_object_body(request) if await request.body() else {}
1655
+ title = payload.get("title", "")
1656
+ prompt = payload.get("prompt", "")
1657
+ if not isinstance(title, str) or not isinstance(prompt, str):
1658
+ raise HTTPException(status_code=422, detail="title and prompt must be strings")
1659
+ session_id = await manager.create_session(title=title)
1660
+ result = (
1661
+ await manager.get(session_id).submit(prompt, sender="web")
1662
+ if prompt else {"ok": True}
1663
+ )
1624
1664
  return JSONResponse(
1625
1665
  {
1626
- "ok": True,
1666
+ **result,
1627
1667
  "session_id": session_id,
1628
1668
  "sessions": manager.list_sessions(),
1629
1669
  "snapshot": session_snapshot(manager.get(session_id)),
1630
- }
1670
+ },
1671
+ status_code=200 if result["ok"] else 400,
1631
1672
  )
1632
1673
 
1633
1674
 
@@ -1781,6 +1822,7 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
1781
1822
  "queue": turn.get("queue", ""),
1782
1823
  "sender": turn.get("sender", ""),
1783
1824
  "kind": turn.get("kind", "assistant"),
1825
+ "model_errors": turn.get("model_errors", []),
1784
1826
  }
1785
1827
  ),
1786
1828
  )
@@ -2034,17 +2076,21 @@ def _render_workspace_shell(
2034
2076
  board_path: "typing.Union[Path, None]",
2035
2077
  title: "typing.Union[str, None]" = None,
2036
2078
  work_dir: "typing.Union[Path, None]" = None,
2079
+ builtin: bool = False,
2037
2080
  ) -> str:
2038
2081
  board_label = str(board_path) if board_path is not None else "No board"
2039
2082
  cwd_label = str(work_dir or Path.cwd())
2040
2083
  page_title = str(title or "pycodex workspace")
2041
- template = (Path(__file__).with_name("workspace.html")).read_text(encoding="utf-8")
2084
+ template = load_workspace_template(builtin)
2042
2085
  return (
2043
2086
  template.replace("__WORKSPACE_TITLE__", html.escape(page_title))
2044
2087
  .replace("__BOARD_LABEL__", html.escape(board_label))
2045
2088
  .replace("__CWD_LABEL__", html.escape(cwd_label))
2046
2089
  .replace("__ENABLE_BOARD__", "true" if board_path is not None else "false")
2047
2090
  .replace("__BOARD_URL__", "board" if board_path is not None else "about:blank")
2091
+ .replace("__FRONTEND_DIRECTORY__", html.escape(str(frontend_directory())))
2092
+ .replace("__PACKAGE_DIRECTORY__", html.escape(str(Path(__file__).resolve().parent)))
2093
+ .replace("__PACKAGE_VERSION__", html.escape(importlib_metadata.version("pycodex-ws")))
2048
2094
  )
2049
2095
 
2050
2096
 
@@ -0,0 +1 @@
1
+ .content-tabs{flex:1}.tab.content-tab{flex:0 0 auto}.tab.content-tab:first-child{min-width:0;padding-right:12px}.content-tab:focus-visible{outline:2px solid var(--accent);outline-offset:-1px}.content-tab-label{display:flex;align-items:baseline;gap:6px}.content-tab-name{flex:0 0 auto;max-width:100%;overflow:hidden;text-overflow:ellipsis}.content-tab-description{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font-size:11px;font-weight:400;opacity:.75}.content-status{max-width:50%;font-size:11px;color:var(--danger);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.content-mode-toggle{flex:0 0 auto;padding:4px 8px;border:0;border-radius:var(--radius-sm);background:transparent;color:var(--accent);font-size:11px;white-space:nowrap;cursor:pointer}.content-mode-toggle:hover{background:var(--accent-soft)}.content-mode-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.content-area{position:relative;min-width:0;min-height:0;overflow:hidden}.content-area>[hidden]{display:none}.content-view{width:100%;height:100%;overflow:auto;background:var(--board-chrome);color:var(--ink)}.content-view.code-view,.content-view.html-view{overflow:hidden}.snapshot-html{display:block;width:100%;height:100%;border:0;background:#fff}.snapshot{min-height:100%}.snapshot-heading{display:flex;align-items:center;gap:8px;min-height:42px;padding:6px 16px;border-bottom:1px solid var(--line);font-size:12px}.markdown-heading{position:sticky;top:0;z-index:1;background:var(--board-chrome)}.snapshot-label{min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}.snapshot-stamp{flex:0 0 auto;margin-left:auto;color:var(--muted);font-size:11px;white-space:nowrap}.snapshot-markdown{max-width:900px;padding:24px 32px;margin:0 auto}.snapshot-image{display:block;max-width:calc(100% - 40px);height:auto;margin:20px auto}.content-placeholder{padding:40px 24px;text-align:center;color:var(--muted)}@media(max-width:760px){.snapshot-heading{flex-wrap:wrap;gap:4px}.snapshot-stamp{margin-left:0}.markdown-heading{padding-right:48px}.markdown-heading .snapshot-label{flex:1 0 100%}.markdown-heading .snapshot-stamp{margin-left:auto}.snapshot-markdown{padding:20px}}
@@ -0,0 +1,32 @@
1
+ // Generated by workspace_server/frontend/build.mjs. See content_views.LICENSE.txt.
2
+ function j(l){function t(n){let r=n.lastIndexOf("/");return r<0?".":n.slice(0,r)||"/"}let e=new Map,i=l.map(n=>{let o=(n.kind==="diff"?n.title.split(" <-> "):[n.title]).map(c=>{let h=/^(?:([0-9a-f]{40}|[0-9a-f]{64})|.+ \(([0-9a-f]{40}|[0-9a-f]{64})\)):(.+)$/i.exec(c),u=h?h[3]:c,p=u.slice(u.lastIndexOf("/")+1);return e.has(p)||e.set(p,new Set),e.get(p).add(u),{path:u,name:p,revision:h?(h[1]||h[2]).slice(0,7):null}});return{view:n,sources:o}}),s=new Map;for(let n of e.values()){if(n.size<2)continue;let r=Array.from(n),o=r.map(c=>t(c).split("/"));r.forEach((c,h)=>{let u=1;for(;u<o[h].length&&o.some((p,S)=>S!==h&&p.slice(-u).join("/")===o[h].slice(-u).join("/"));)u+=1;s.set(c,o[h].slice(-u).join("/")||"/")})}return new Map(i.map(({view:n,sources:r})=>{let o=r.every(p=>p.path===r[0].path),c=o?r[0].name:r.map(p=>p.name).join(" \u2194 "),h="";o?h=s.get(r[0].path)||"":r.some(p=>s.has(p.path))&&(h=r.map(p=>s.get(p.path)||t(p.path)).join(" \u2194 "));let u=r.some(p=>p.revision)?r.map(p=>p.revision||"working tree").join(" \u2194 "):n.kind==="diff"&&o?"diff":"";return[n.id,{name:c,description:[h,u].filter(Boolean).join(" \xB7 ")}]}))}var E=class l{constructor(t,e,i,s,n,r=null){this.element=t,this.changed=e,this.submit=i,this.editContext=s,this.error=n,this.recallInput=r,this.recalledParts=null,this.composing=!1,this.selection={start:0,end:0},this.undo=[],this.redo=[],this.previous=this.read(),t.addEventListener("input",o=>{o.isComposing||this.record(),this.changed()}),t.addEventListener("compositionstart",()=>{this.composing=!0}),t.addEventListener("compositionend",()=>{this.composing=!1,this.record(),this.changed()}),document.addEventListener("selectionchange",()=>{this.captureSelection()&&this.changed()}),t.addEventListener("keydown",o=>this.keydown(o)),t.addEventListener("beforeinput",o=>{o.inputType==="historyUndo"||o.inputType==="historyRedo"?(o.preventDefault(),this.history(o.inputType==="historyUndo")):o.inputType.startsWith("format")&&o.preventDefault()}),t.addEventListener("click",o=>{let c=o.target.closest(".context-token");c&&this.editContext(JSON.parse(c.dataset.context))}),t.addEventListener("mousedown",o=>{o.target.closest(".context-token")&&o.preventDefault()}),t.addEventListener("copy",o=>this.copy(o,!1)),t.addEventListener("cut",o=>this.copy(o,!0)),t.addEventListener("paste",o=>{o.preventDefault(),this.paste(o.clipboardData)}),t.addEventListener("dragover",o=>o.preventDefault()),t.addEventListener("drop",o=>{o.preventDefault();let c=document.caretRangeFromPoint(o.clientX,o.clientY);if(c&&t.contains(c.startContainer)){let h=window.getSelection();h.removeAllRanges(),h.addRange(c),this.captureSelection()}this.paste(o.dataTransfer)})}static size(t){return t.reduce((e,i)=>e+(i.type==="context"?1:i.text.length),0)}static text(t){return t.map(e=>e.text).join("")}static slice(t,e,i){let s=[],n=0;for(let r of t){let o=l.size([r]);e<n+o&&i>n&&s.push(r.type==="context"?{...r}:{type:"text",text:r.text.slice(Math.max(0,e-n),i-n)}),n+=o}return s}parts(t=this.element){let e=[],i=n=>{if(!n)return;let r=e.at(-1);(r==null?void 0:r.type)==="text"?r.text+=n:e.push({type:"text",text:n})},s=n=>{var r;for(let o of n.childNodes)o.nodeType===Node.TEXT_NODE?i(o.data):(r=o.dataset)!=null&&r.context?e.push(JSON.parse(o.dataset.context)):o.nodeName==="BR"?!o.hasAttribute("data-tail")&&n.childNodes.length>1&&i(`
3
+ `):(["DIV","P"].includes(o.nodeName)&&e.length&&!e.at(-1).text.endsWith(`
4
+ `)&&i(`
5
+ `),s(o))};return s(t),e}captureSelection(){if(document.activeElement!==this.element)return!1;let t=window.getSelection();if(!t.rangeCount)return!1;let e=t.getRangeAt(0);if(!this.element.contains(e.startContainer)||!this.element.contains(e.endContainer))return!1;let i=document.createRange();i.selectNodeContents(this.element),i.setEnd(e.startContainer,e.startOffset);let s=l.size(this.parts(i.cloneContents()));return this.selection={start:s,end:s+l.size(this.parts(e.cloneContents()))},!0}read(){return this.captureSelection(),{parts:this.parts(),selection:{...this.selection}}}set(t){this.recalledParts=null,this.undo=[],this.redo=[],this.render(t),document.activeElement===this.element&&this.focus(),this.previous=this.read()}render(t){let e=t.parts||[];this.element.replaceChildren();for(let s of e)if(s.type==="text")this.element.append(document.createTextNode(s.text));else{let n=document.createElement("span");n.className="context-token",n.contentEditable="false",n.dataset.context=JSON.stringify(s),n.textContent=s.label,n.title=s.label+`
6
+ Click to edit context; Backspace / Delete to remove`,this.element.append(n,document.createTextNode(""))}if(!e.length||e.at(-1).type==="text"&&e.at(-1).text.endsWith(`
7
+ `)){let s=document.createElement("br");s.dataset.tail="",this.element.append(s)}let i=l.size(e);this.selection=t.selection||{start:i,end:i},this.selection={start:Math.min(this.selection.start,i),end:Math.min(this.selection.end,i)},this.element.classList.toggle("empty",!e.length)}focus(){this.element.focus({preventScroll:!0});let t=s=>{let n=r=>{var o;for(let c=0;c<r.childNodes.length;c++){let h=r.childNodes[c];if(h.nodeType===Node.TEXT_NODE){if(s<=h.length)return[h,s];s-=h.length}else if((o=h.dataset)!=null&&o.context){if(s===0)return[r,c];if(s-=1,s===0)return[r,c+1]}else if(h.nodeName==="BR"){if(s===0)return[r,c];h.hasAttribute("data-tail")||(s-=1)}else{let u=n(h);if(u)return u}}};return n(this.element)||[this.element,this.element.childNodes.length]},e=document.createRange();e.setStart(...t(this.selection.start)),e.setEnd(...t(this.selection.end));let i=window.getSelection();i.removeAllRanges(),i.addRange(e)}record(){let t=this.read();JSON.stringify(t.parts)!==JSON.stringify(this.previous.parts)&&(this.undo.push(this.previous),this.undo.length>40&&this.undo.shift(),this.redo=[],this.previous=t),this.element.classList.toggle("empty",!t.parts.length)}replace(t,e,i){let s=this.read(),n=[...l.slice(s.parts,0,t),...i,...l.slice(s.parts,e,l.size(s.parts))],r=t+l.size(i);this.render({parts:n,selection:{start:r,end:r}}),this.focus(),this.record(),this.changed()}insert(t){this.captureSelection(),this.replace(this.selection.start,this.selection.end,t.map(e=>({...e,type:"context",id:crypto.getRandomValues(new Uint32Array(4)).join("-")})))}updateContext(t,e){for(let i of this.element.querySelectorAll(".context-token")){let s=JSON.parse(i.dataset.context);s.id===t&&(i.dataset.context=JSON.stringify({...s,text:e}))}this.record(),this.changed()}removeContext(t){let e=0;for(let i of this.parts()){if(i.type==="context"&&i.id===t){this.replace(e,e+1,[]);return}e+=l.size([i])}}history(t){let e=t?this.undo:this.redo;e.length&&((t?this.redo:this.undo).push(this.read()),this.render(e.pop()),this.focus(),this.previous=this.read(),this.changed())}keydown(t){var i;if(t.isComposing||this.composing||t.keyCode===229)return;let e=t.ctrlKey||t.metaKey;if(this.recallInput&&["ArrowUp","ArrowDown"].includes(t.key)&&!e&&!t.altKey&&!t.shiftKey){let s=this.read();if(s.selection.start===s.selection.end){let n=s.selection.start,r=l.text(s.parts),o=t.key==="ArrowUp",c=l.text(l.slice(s.parts,0,n)),h=l.text(l.slice(s.parts,n,l.size(s.parts)));if(this.recalledParts===JSON.stringify(s.parts)||!r||!(o?c:h).includes(`
8
+ `)){let p=this.recallInput(o,s);p&&(t.preventDefault(),this.render(p),this.recalledParts=JSON.stringify(p.parts),this.focus(),this.record(),this.changed())}}}else if(e&&(t.key.toLowerCase()==="z"||t.key.toLowerCase()==="y"))t.preventDefault(),this.history(t.key.toLowerCase()==="z"&&!t.shiftKey);else if(t.key==="Enter")t.preventDefault(),t.shiftKey?(this.captureSelection(),this.replace(this.selection.start,this.selection.end,[{type:"text",text:`
9
+ `}])):this.submit();else if(["Backspace","Delete"].includes(t.key)&&!e&&!t.altKey){this.captureSelection();let{start:s,end:n}=this.selection,r=t.key==="Backspace"?s-1:s;s===n&&((i=l.slice(this.parts(),r,r+1)[0])==null?void 0:i.type)==="context"&&(t.preventDefault(),this.replace(r,r+1,[]))}}copy(t,e){if(!this.captureSelection()||this.selection.start===this.selection.end)return;let i=l.slice(this.parts(),this.selection.start,this.selection.end);t.clipboardData.setData("text/plain",l.text(i)),t.clipboardData.setData("application/x-pycodex-parts",JSON.stringify(i)),t.preventDefault(),e&&this.replace(this.selection.start,this.selection.end,[])}paste(t){try{let e=t.getData("application/x-pycodex-parts"),i=e?JSON.parse(e):[{type:"text",text:t.getData("text/plain")}];if(!Array.isArray(i)||i.some(s=>!s||typeof s.text!="string"||!["text","context"].includes(s.type)||s.type==="context"&&typeof s.label!="string"))throw new Error("Invalid context format in clipboard.");this.captureSelection(),this.replace(this.selection.start,this.selection.end,i.map(s=>s.type==="context"?{...s,id:crypto.getRandomValues(new Uint32Array(4)).join("-")}:s))}catch(e){this.error(e.message)}}};function W(l){let t=l.trim().match(/^<(clock_tick|exec_command_completed)>\s*([\s\S]*?)\s*<\/\1>$/);if(!t)return!1;try{let e=JSON.parse(t[2]);return e!==null&&typeof e=="object"&&!Array.isArray(e)}catch(e){return!1}}var D=class{constructor(){this.entries=[],this.seen=new Set,this.pending=[],this.index=null,this.draft=null}remember(t){let e=t.parts.map(i=>({...i}));E.text(e).trim()&&(this.append({parts:e}),this.pending.push(E.text(e).trim()),this.pending.length>100&&this.pending.shift(),this.reset())}append(t){this.entries.push(t),this.entries.length>100&&(this.entries.shift(),this.index!==null&&(this.index=Math.max(0,this.index-1)))}update(t){for(let e of t||[]){let i=e.submission_id||e.turn_id;if(!i||this.seen.has(i))continue;this.seen.add(i);let s=e.prompt;if(!s||!s.trim())continue;let n=this.pending.indexOf(s.trim());if(n!==-1){this.pending.splice(n,1);continue}e.kind==="compaction"||W(s)||!["web","cli","feishu","resume","user"].includes(e.sender)||this.append({parts:[{type:"text",text:s}]})}}reset(){this.index=null,this.draft=null}recall(t,e){if(this.index!==null&&JSON.stringify(e.parts)!==JSON.stringify(this.entries[this.index].parts)&&this.reset(),t){if(!this.entries.length||this.index===0)return null;this.index===null&&(this.draft=e,this.index=this.entries.length),this.index-=1}else{if(this.index===null)return null;if(this.index+=1,this.index>=this.entries.length){let i=this.draft;return this.reset(),i}}return{parts:this.entries[this.index].parts.map(i=>({...i}))}}};function Q(l,t=!0){if(typeof l!="string"||!l||l!==l.trim()||/[\x00-\x1f\x7f]/.test(l))return!1;if(/^(?:diff:|git:|workspace:\/\/canvas\/?$)/i.test(l))return!0;if(!t||/^(?:https?|ftps?|mailto|tel|sms|javascript|vbscript|data|blob|about|file):/i.test(l))return!1;let e=l.split("#");if(e.length>2||e.length===2&&!/^L-?\d+$/i.test(e[1]))return!1;let i=e[0].replace(/:-?\d+$/,"");return!!i&&!i.startsWith("//")&&!i.includes("?")&&(!/^[a-z][a-z\d+.-]*:/i.test(i)||/^[a-z]:(?:[/\\]|%2f|%5c)/i.test(i))}var O=class{constructor({request:t,onChange:e,onSelect:i,selected:s=null,enableBoard:n=!0}){this.request=t,this.onChange=e,this.onSelect=i,this.enableBoard=n,this.views=[],this.activeId=n?"board":null,this.snapshot=null,this.selection=null,this.displayMode=null,this.loading=!1,this.error="",this.closing=new Set,this.cache=new Map,this.initialSelection=s,this.initialized=!1,this.mutation=0,this.pollRequest=null,this.contentRequest=null,this.openRequest=null,this.timer=null}start(){this.timer===null&&(this.poll(),this.timer=setInterval(()=>this.poll(),2e3))}stop(){clearInterval(this.timer),this.timer=null,this.cancelOpen(),this.pollRequest&&this.pollRequest.abort(),this.contentRequest&&this.contentRequest.abort(),this.pollRequest=null,this.contentRequest=null,this.loading=!1}async poll(){if(this.pollRequest||this.openRequest)return;let t=new AbortController,e=this.mutation;this.pollRequest=t;try{let i=await this.request("api/views",{signal:t.signal});if(this.pollRequest!==t||e!==this.mutation)return;this.updateViews(i.views);let s=new Set(this.views.map(r=>r.id));this.error="";let n=this.activeId;this.initialized||(n=this.initialSelection||this.defaultId(),this.initialized=!0),(n==="board"&&!this.enableBoard||n!==null&&n!=="board"&&!s.has(n))&&(n=this.defaultId()),n!==this.activeId||n!==null&&n!=="board"&&!this.snapshot&&!this.loading?this.select(n):(n!==null&&n!=="board"&&!this.cache.has(n)&&!this.contentRequest&&this.load(n),this.onChange(this))}catch(i){this.pollRequest===t&&e===this.mutation&&i.name!=="AbortError"&&(this.error="Unable to load tabs: "+i.message,this.onChange(this))}finally{this.pollRequest===t&&(this.pollRequest=null)}}defaultId(){return this.enableBoard?"board":this.views.length?this.views[0].id:null}updateViews(t){this.views=t;let e=new Map(t.map(i=>[i.id,i.revision]));for(let[i,s]of this.cache)(!e.has(i)||s.revision<e.get(i))&&this.cache.delete(i)}cancelOpen(){this.openRequest&&this.openRequest.abort(),this.openRequest=null}async open(t,e,i){if(this.cancelOpen(),/^workspace:\/\/canvas\/?$/i.test(t)){if(!this.enableBoard)throw new Error("Canvas is disabled for this workspace.");await this.select("board");return}let s=new AbortController;this.openRequest=s,this.mutation+=1,this.error="",this.onChange(this);try{let n=await this.request("api/views/open",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({href:t,src_path:e,session_id:i}),signal:s.signal});if(this.openRequest!==s)return;this.mutation+=1,this.updateViews(n.views);let r=n.view;return this.cache.set(r.id,r),this.initialized=!0,this.openRequest=null,this.select(r.id,r.selection?{...r.selection,revision:r.reveal_revision,contentRevision:r.revision}:null,r.selection?"source":"preview")}catch(n){if(this.openRequest!==s||n.name==="AbortError")return;throw this.error="Unable to open view: "+n.message,n}finally{this.openRequest===s&&(this.openRequest=null,this.onChange(this))}}async select(t,e=null,i=null){if(t==="board"&&!this.enableBoard||t!==null&&t!=="board"&&!this.views.some(n=>n.id===t))return;this.cancelOpen();let s=t===this.activeId?this.snapshot:null;this.contentRequest&&this.contentRequest.abort(),this.contentRequest=null,this.activeId=t,this.selection=e,this.displayMode=i,this.initialized||(this.initialSelection=t),this.snapshot=this.cache.get(t)||s,this.loading=t!==null&&t!=="board"&&!this.snapshot,this.error="",this.onSelect(t),this.onChange(this),!(t===null||t==="board"||this.cache.has(t))&&await this.load(t)}setDisplayMode(t){!this.snapshot||this.activeId===null||this.activeId==="board"||!["source","preview"].includes(t)||(this.cancelOpen(),this.selection=null,this.displayMode=t,this.onChange(this))}async load(t){let e=new AbortController;this.contentRequest=e;try{let i=await this.request("api/views/"+encodeURIComponent(t),{signal:e.signal});if(this.contentRequest!==e||this.activeId!==t)return;let s=this.views.find(n=>n.id===t);if(!s||i.revision<s.revision)return;this.cache.set(t,i),this.snapshot=i,this.error=""}catch(i){if(this.contentRequest!==e||this.activeId!==t||i.name==="AbortError")return;this.error="Unable to load view: "+i.message}finally{this.contentRequest===e&&(this.contentRequest=null,this.loading=!1,this.onChange(this))}}async close(t){if(!(t===null||t==="board"||this.closing.has(t))){this.cancelOpen(),this.closing.add(t),this.mutation+=1,this.onChange(this);try{await this.request("api/views/"+encodeURIComponent(t),{method:"DELETE"}),this.mutation+=1;let e=this.views.findIndex(i=>i.id===t);this.views=this.views.filter(i=>i.id!==t),this.cache.delete(t),this.activeId===t&&await this.select(e>0?this.views[e-1].id:this.defaultId())}catch(e){this.error="Unable to close tab: "+e.message}finally{this.closing.delete(t),this.onChange(this)}}}};function g(l,t,e,i){let s=l.createElement(t);return s.className=e,i!==void 0&&(s.textContent=i),s}var P=class{constructor(t,e=[0,0]){this.document=t.ownerDocument,this.window=this.document.defaultView,this.position=e,this.generation=0,this.frame=g(this.document,"iframe","snapshot-html"),this.frame.title="HTML preview",this.receive=i=>{if(i.source!==this.frame.contentWindow)return;let s=i.data;!s||s.generation!==this.generation||(s.type==="workspace-preview-ready"?this.frame.contentWindow.postMessage({type:"workspace-preview-restore",generation:this.generation,position:this.position},"*"):s.type==="workspace-preview-scroll"&&Array.isArray(s.position)&&s.position.length===2&&s.position.every(Number.isFinite)&&(this.position=s.position))},this.window.addEventListener("message",this.receive),t.appendChild(this.frame)}update(t){if(t===this.source)return;let e=new this.window.DOMParser().parseFromString(t,"text/html"),i=this.generation+1,s=e.createElement("script");s.textContent=`(() => {
10
+ const generation = ${i};
11
+ let restored = false;
12
+ function report() {
13
+ if (restored) parent.postMessage({
14
+ type: "workspace-preview-scroll", generation, position: [scrollX, scrollY],
15
+ }, "*");
16
+ }
17
+ addEventListener("message", event => {
18
+ const message = event.data;
19
+ if (event.source !== parent || !message ||
20
+ message.type !== "workspace-preview-restore" || message.generation !== generation) return;
21
+ scrollTo(message.position[0], message.position[1]);
22
+ restored = true;
23
+ report();
24
+ });
25
+ addEventListener("scroll", report, {passive: true});
26
+ addEventListener("load", () => parent.postMessage({
27
+ type: "workspace-preview-ready", generation,
28
+ }, "*"));
29
+ })();`,e.head.prepend(s);let n=e.doctype?new this.window.XMLSerializer().serializeToString(e.doctype)+`
30
+ `:"";this.frame.srcdoc=n+e.documentElement.outerHTML,this.generation=i,this.source=t}capture(){return this.position}destroy(){this.window.removeEventListener("message",this.receive)}},T=class{constructor({content:t,renderMarkdown:e,loadEditor:i=null,onInsertContext:s=null,onChangeMode:n=null,createHtmlPreview:r=(o,c)=>new P(o,c)}){this.content=t,this.document=t.ownerDocument,this.renderMarkdown=e,this.onInsertContext=s,this.onChangeMode=n,this.loadEditor=i||(()=>this.loadEditorAssets()),this.editorAssets=null,this.createHtmlPreview=r,this.activeId=null,this.editor=null,this.htmlPreview=null,this.generation=0,this.remaining=new Set,this.editorStates=new Map,this.previews=new Map,this.scrollPositions=new Map,this.sourceViews=new Set,this.pendingSelection=null,this.selectionRevision=0}loadEditorAssets(){if(!this.editorAssets){let t=this.document.createElement("link");t.rel="stylesheet",t.href=new URL("./editor.css",import.meta.url).href;let e=new Promise((i,s)=>{t.addEventListener("load",i,{once:!0}),t.addEventListener("error",()=>s(new Error("Editor stylesheet request failed.")),{once:!0})});this.document.head.insertBefore(t,this.document.head.querySelector("[data-pycodex-custom-style]")),this.editorAssets=Promise.all([import("./editor.mjs"),e]).then(([i])=>i).catch(i=>{throw t.remove(),this.editorAssets=null,i})}return this.editorAssets}retain(t){this.remaining=new Set(t);for(let e of[this.editorStates,this.previews,this.scrollPositions,this.sourceViews])for(let i of e.keys())this.remaining.has(i)||e.delete(i)}clear(t=""){this.generation+=1,this.editor?(this.remaining.has(this.activeId)&&this.editorStates.set(this.activeId,this.editor.capture()),this.editor.destroy(),this.editor=null):this.htmlPreview?(this.remaining.has(this.activeId)&&this.scrollPositions.set(this.activeId,this.htmlPreview.capture()),this.htmlPreview.destroy(),this.htmlPreview=null):this.remaining.has(this.activeId)&&this.previews.has(this.activeId)&&this.scrollPositions.set(this.activeId,[this.content.scrollLeft,this.content.scrollTop]),this.activeId=null,this.content.classList.remove("code-view"),this.content.classList.remove("html-view"),this.content.textContent="",t&&this.content.appendChild(g(this.document,"div","content-placeholder",t))}addModeToggle(t,e,i=null){if(!this.onChangeMode)return;let s=g(this.document,"button","content-mode-toggle",e?"Show preview":"Show source");s.type="button",s.title=e?"Show rendered Markdown":"Show Markdown source",s.setAttribute("aria-label",s.textContent),s.addEventListener("click",()=>this.onChangeMode(e?"preview":"source")),t.insertBefore(s,i)}async show(t,e,i=null,s=null){i&&e.revision<i.contentRevision&&(i=null),this.pendingSelection=i,s==="preview"?this.sourceViews.delete(t):(i||s==="source")&&this.sourceViews.add(t);let n=e.documents[0],r=e.kind==="file"&&!this.sourceViews.has(t),o=r&&/\.(md|markdown)$/i.test(n.label)&&this.renderMarkdown,c=r&&/\.html?$/i.test(n.label);if(this.activeId===t&&this.htmlPreview&&c)return this.htmlPreview.update(n.text);if(this.activeId===t&&this.editor&&!o&&!c){let w=this.generation;try{await this.editor.update(e),w===this.generation&&await this.applySelection(t,i)}catch(v){if(w===this.generation)throw v}return}this.clear(),this.activeId=t;let h=this.generation;if(e.kind==="diff"||!n.image_url&&!o&&!c){this.content.classList.add("code-view"),this.content.appendChild(g(this.document,"div","content-placeholder","Loading code viewer\u2026"));try{let w=await this.loadEditor();if(h!==this.generation)return;this.content.textContent="";let v=g(this.document,"div","workspace-editor");this.content.appendChild(v);let m=this.onInsertContext&&(x=>{h===this.generation&&this.activeId===t&&this.onInsertContext(x)}),I=await w.createSnapshotEditor(v,e,this.editorStates.get(t),m);if(h!==this.generation){I.destroy();return}if(this.editor=I,this.onChangeMode&&this.renderMarkdown&&e.kind==="file"&&/\.(md|markdown)$/i.test(n.label)){let x=v.querySelector(".editor-toolbar"),a=x.querySelector(".editor-actions");x.insertBefore(g(this.document,"span","snapshot-stamp","Read only"),a),this.addModeToggle(x,!0,a)}}catch(w){if(h!==this.generation)return;this.content.textContent="",this.content.appendChild(g(this.document,"div","content-placeholder","Unable to load code viewer: "+w.message));return}await this.applySelection(t,i);return}if(c)return this.content.classList.add("html-view"),this.htmlPreview=this.createHtmlPreview(this.content,this.scrollPositions.get(t)),this.htmlPreview.update(n.text);if(!this.previews.has(t)){let w=g(this.document,"div","snapshot"),v=g(this.document,"div","snapshot-heading");if(v.appendChild(g(this.document,"span","snapshot-label",n.label)),v.appendChild(g(this.document,"span","snapshot-stamp","Read only")),o&&this.onChangeMode&&(v.classList.add("markdown-heading"),this.addModeToggle(v,!1)),w.appendChild(v),n.image_url){let m=g(this.document,"img","snapshot-image");m.alt=n.label,w.appendChild(m)}else w.appendChild(g(this.document,"div","snapshot-markdown markdown"));this.previews.set(t,w)}this.content.appendChild(this.previews.get(t));let u=this.content.querySelector(".snapshot-image");if(u&&u.src!==n.image_url&&(u.src=n.image_url),o){let w=this.content.querySelector(".snapshot-markdown");n.source&&w.setAttribute("data-src-path",n.source.path),this.renderMarkdown(w,n.text)}let[p,S]=this.scrollPositions.get(t)||[0,0];this.content.scrollLeft=p,this.content.scrollTop=S}async applySelection(t,e){if(!e||e.revision<=this.selectionRevision)return;let i=this.editor,s=()=>this.activeId===t&&this.editor===i&&this.pendingSelection===e&&e.revision>this.selectionRevision;s()&&await i.revealLine(e.document,e.line,s)&&(this.selectionRevision=e.revision)}};function Z({tabs:l,frame:t,content:e,status:i,request:s,renderMarkdown:n,onInsertContext:r,enableBoard:o=!0}){let c=l.ownerDocument,h=new T({content:e,renderMarkdown:n,onInsertContext:r,onChangeMode:a=>m.setDisplayMode(a)}),u,p=null,S=!1,w=null,v=null,m;function I(a){let k=c.getElementById("content-tab-"+a);k&&(k.focus({preventScroll:!0}),k.scrollIntoView({block:"nearest",inline:"nearest"}))}function x(a){let k=a.snapshot?a.snapshot.documents.map(d=>d.error):[];i.textContent=[a.error,...k].filter(Boolean).join("; ")||(a.openRequest?"Opening view\u2026":""),i.title=i.textContent,i.hidden=!i.textContent;let R=new Map(Array.from(l.children,d=>[d.dataset.viewId,d])),$=(o?[{id:"board",title:"Canvas",kind:"board"}]:[]).concat(a.views),z=j(a.views);z.set("board",{name:"Canvas",description:""}),$.forEach((d,q)=>{let f=R.get(d.id);if(!f){f=g(c,"div","tab content-tab"),f.dataset.viewId=d.id,f.id="content-tab-"+d.id,f.setAttribute("role","tab"),f.setAttribute("aria-controls",d.id==="board"?t.id:e.id);let N=g(c,"span","tab-label content-tab-label");if(N.appendChild(g(c,"span","content-tab-name")),N.appendChild(g(c,"span","content-tab-description")),f.appendChild(N),f.addEventListener("click",()=>m.select(d.id)),f.addEventListener("keydown",y=>{if(y.target!==f)return;let b=(o?["board"]:[]).concat(m.views.map(K=>K.id)),V=b.indexOf(d.id),C=null;y.key==="ArrowRight"&&(C=b[(V+1)%b.length]),y.key==="ArrowLeft"&&(C=b[(V+b.length-1)%b.length]),y.key==="Home"&&(C=b[0]),y.key==="End"&&(C=b[b.length-1]),(y.key==="Enter"||y.key===" ")&&(C=d.id),y.key==="Delete"&&d.id!=="board"&&(y.preventDefault(),m.close(d.id)),C!==null&&(y.preventDefault(),m.select(C),c.getElementById("content-tab-"+C).focus())}),d.id!=="board"){let y=g(c,"button","tab-close content-tab-close");y.type="button",y.title="Close tab",y.setAttribute("aria-label","Close "+d.title),y.innerHTML='<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>',y.addEventListener("click",b=>{b.stopPropagation(),m.close(d.id)}),f.appendChild(y)}}R.delete(d.id);let A=z.get(d.id);f.querySelector(".content-tab-name").textContent=A.name;let U=f.querySelector(".content-tab-description");U.textContent=A.description,U.hidden=!A.description,f.className="tab content-tab"+(d.id===a.activeId?" active":""),f.tabIndex=d.id===a.activeId?0:-1,f.title=d.id==="board"?l.dataset.boardTitle:d.title+(d.live?`
31
+ Read only \xB7 follows file changes \xB7 `:`
32
+ Read-only snapshot \xB7 `)+new Date(d.created_at).toLocaleString(),f.setAttribute("aria-selected",String(d.id===a.activeId)),f.setAttribute("aria-label",d.title);let M=f.querySelector(".content-tab-close");M&&(M.disabled=a.closing.has(d.id),M.setAttribute("aria-label","Close "+d.title)),l.children[q]!==f&&l.insertBefore(f,l.children[q]||null)}),R.forEach(d=>d.remove()),h.retain(a.views.map(d=>d.id));let L=a.activeId==="board",B=u!==a.activeId,J=B||p!==a.snapshot||S!==a.loading||w!==a.selection||v!==a.displayMode;if(L&&J&&h.clear(),t.hidden=!L,e.hidden=L,!!J&&(u=a.activeId,p=a.snapshot,S=a.loading,w=a.selection,v=a.displayMode,a.activeId?e.setAttribute("aria-labelledby","content-tab-"+a.activeId):e.removeAttribute("aria-labelledby"),e.setAttribute("aria-busy",String(a.loading)),!L)){if(a.snapshot){let d=a.activeId,q=a.snapshot;h.show(d,q,a.selection,a.displayMode).catch(f=>{u!==d||p!==q||(p=null,a.error="Unable to update view: "+f.message,i.textContent=i.title=a.error,i.hidden=!1)})}else h.clear(a.loading?"Loading view\u2026":a.activeId?"View unavailable.":"Open a file link to view it here.");B&&I(a.activeId)}}return m=new O({request:s,enableBoard:o,onChange:x,onSelect:a=>{e.closest(".workspace").dataset.mobilePane="board",I(a)}}),x(m),{start(){m.start(),x(m)},stop(){m.stop(),h.clear(),u=null},poll(){return m.poll()},open(a,k,R){return m.open(a,k,R)}}}export{E as Composer,O as ContentTabs,D as PromptHistory,T as SnapshotDisplay,Q as isWorkspaceLink,Z as mountContentViews};
@@ -0,0 +1,80 @@
1
+ """Workspace frontend template and static resource loading."""
2
+
3
+ import mimetypes
4
+ from html.parser import HTMLParser
5
+ from pathlib import Path
6
+
7
+ from starlette.exceptions import HTTPException
8
+ from starlette.responses import FileResponse
9
+
10
+
11
+ class _AssetInsertions(HTMLParser):
12
+ def __init__(self, template: str, additions: dict) -> None:
13
+ super().__init__()
14
+ self.lines = template.splitlines(keepends=True)
15
+ self.additions = additions
16
+ self.positions = []
17
+
18
+ def handle_endtag(self, tag: str) -> None:
19
+ markup = self.additions.pop(tag, None)
20
+ if markup is not None:
21
+ line, column = self.getpos()
22
+ offset = sum(len(row) for row in self.lines[:line - 1]) + column
23
+ self.positions.append((offset, markup))
24
+
25
+
26
+ def frontend_directory() -> Path:
27
+ return Path.home() / ".pycodex" / "frontend"
28
+
29
+
30
+ def frontend_file(name: str) -> Path:
31
+ directory = frontend_directory().resolve()
32
+ path = (directory / name).resolve()
33
+ try:
34
+ path.relative_to(directory)
35
+ except ValueError:
36
+ raise HTTPException(status_code=404, detail="frontend file not found")
37
+ return path
38
+
39
+
40
+ def load_workspace_template(builtin: bool = False) -> str:
41
+ packaged = Path(__file__).with_name("workspace.html")
42
+ if builtin:
43
+ return packaged.read_text(encoding="utf-8")
44
+ try:
45
+ template = frontend_file("workspace.html").read_text(
46
+ encoding="utf-8", errors="replace"
47
+ )
48
+ except FileNotFoundError:
49
+ template = packaged.read_text(encoding="utf-8")
50
+ additions = {}
51
+ for name, closing_tag, markup in [
52
+ ("custom.css", "head", '<link rel="stylesheet" href="frontend/custom.css" data-pycodex-custom-style>'),
53
+ ("custom.js", "body", '<script type="module" src="frontend/custom.js"></script>'),
54
+ ]:
55
+ try:
56
+ frontend_file(name).stat()
57
+ except FileNotFoundError:
58
+ continue
59
+ additions[closing_tag] = markup
60
+ if additions:
61
+ parser = _AssetInsertions(template, additions)
62
+ parser.feed(template)
63
+ for offset, markup in reversed(parser.positions):
64
+ template = template[:offset] + markup + "\n" + template[offset:]
65
+ return template
66
+
67
+
68
+ def frontend_asset_response(name: str) -> FileResponse:
69
+ path = frontend_file(name)
70
+ if not path.is_file():
71
+ raise HTTPException(status_code=404, detail="frontend file not found")
72
+ media_type = (
73
+ "text/javascript" if path.suffix.lower() in {".js", ".mjs"}
74
+ else mimetypes.guess_type(str(path))[0] or "application/octet-stream"
75
+ )
76
+ return FileResponse(
77
+ str(path),
78
+ media_type=media_type,
79
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
80
+ )