python-codex 0.2.0__py3-none-any.whl → 0.2.2__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.
@@ -18,6 +18,7 @@ import typing
18
18
 
19
19
 
20
20
  EXIT_COMMANDS = {"/exit", "/quit"}
21
+ HELP_COMMAND = "/help"
21
22
  HISTORY_COMMAND = "/history"
22
23
  TITLE_COMMAND = "/title"
23
24
  MODEL_COMMAND = "/model"
@@ -27,7 +28,7 @@ COMPACT_COMMAND = "/compact"
27
28
  LINK_COMMAND = "/link"
28
29
  UNLINK_COMMAND = "/unlink"
29
30
  EXTRA_COMMANDS_LINE = (
30
- "Extra commands: /history, /title, /model, /resume, /compact, /link, /unlink"
31
+ "Extra commands: /help, /history, /title, /model, /resume, /compact, /link, /unlink"
31
32
  )
32
33
 
33
34
 
@@ -232,6 +233,9 @@ async def run_interactive_session(
232
233
  continue
233
234
  if prompt_text in EXIT_COMMANDS:
234
235
  break
236
+ if prompt_text == HELP_COMMAND:
237
+ view.write_line(EXTRA_COMMANDS_LINE)
238
+ continue
235
239
  if prompt_text == HISTORY_COMMAND:
236
240
  view.show_history()
237
241
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-codex
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A minimal Python extraction of Codex's main agent loop
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.6.2
@@ -7,7 +7,7 @@ pycodex/context.py,sha256=Hwh3vU-qSvaleUDdOWuWiGyNmbULVAZmboxiu9iiHt8,26549
7
7
  pycodex/doctor.py,sha256=De3M4hRBJq8ZeqsUJgHz0vitqrH18YugrEnz7oHhTdQ,10572
8
8
  pycodex/feishu_card.py,sha256=De6pM--3MfhgGo-WcWfhm-fop5UtzjwKrZ4gI2Lls3w,26198
9
9
  pycodex/feishu_link.py,sha256=XGV93CIorcc03pNgBnXay9SkivnDL4i2GkLR5NjBjq0,15588
10
- pycodex/interactive_session.py,sha256=VmQ4xLk5b9QrI4LvgrdyhXTvIk0K65loKxh2XbfV2FA,15617
10
+ pycodex/interactive_session.py,sha256=pcIC_GvQuqQSsjzcTTFB_crrS-XVufAGplJZxnHgy7I,15769
11
11
  pycodex/model.py,sha256=O5Gf35qCMAGrqvr_5d1ZQNk3vHmNRD5Uv-8k7-zbpXg,36679
12
12
  pycodex/portable.py,sha256=gxl2E2h5uZJbasMEPPs-nyALFPIvX79T2ZYsu6vXZrg,15656
13
13
  pycodex/portable_server.py,sha256=6I3pQkWj3e_SFlDXY2mGdCPns1w_3PSxByBV9wv5epI,7331
@@ -79,10 +79,10 @@ responses_server/tools/custom_adapter.py,sha256=LxO7ldydvR-GWachDz8GKC0Q8KGGFoFP
79
79
  responses_server/tools/web_search.py,sha256=pm4ZUiHUfxc0bGY1kEvt-BCzDrZIyP24xzPUcga2ul0,8908
80
80
  workspace_server/__init__.py,sha256=XbSU6aAeYBMsBTVi9Ug6ZW9sxcwefqPS7T_7wpt_VMo,462
81
81
  workspace_server/__main__.py,sha256=9SRp-Yw7ShGxc6DhSIXcDLKgGEdAVm3oBZ59rBOPjT0,62
82
- workspace_server/app.py,sha256=aDfUqIwyMsndavSTP47rfcBKIwgo37F8U_lkCgUfUc0,49260
83
- workspace_server/workspace.html,sha256=7d1eTxQ_4Hl-8YbMFHMTmRPpMRXE-a0KSJn1224JpXw,25563
84
- python_codex-0.2.0.dist-info/METADATA,sha256=oCzNpTEbt1eODGxsJeHJE-gjTf53He7UDvcs-bovqAc,16812
85
- python_codex-0.2.0.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
86
- python_codex-0.2.0.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
87
- python_codex-0.2.0.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
88
- python_codex-0.2.0.dist-info/RECORD,,
82
+ workspace_server/app.py,sha256=yORhk3BcZZiO1x-QTh0wFi4BXvrS5fbYMgVfEgGBgq8,51270
83
+ workspace_server/workspace.html,sha256=5cHWRXGFknkDDqrOtmiBeOI1HRQHbRmkrNFm8wZ4_JU,39202
84
+ python_codex-0.2.2.dist-info/METADATA,sha256=GbrTS_Leh6VMxhYAImSrdaFzDblJbwW4Xj5FdCRfdfI,16812
85
+ python_codex-0.2.2.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
86
+ python_codex-0.2.2.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
87
+ python_codex-0.2.2.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
88
+ python_codex-0.2.2.dist-info/RECORD,,
workspace_server/app.py CHANGED
@@ -25,7 +25,12 @@ from pycodex.utils.session_persist import (
25
25
  load_resumed_session_path,
26
26
  )
27
27
  from pycodex.utils import uuid7_string
28
- from pycodex.utils.visualize import IDLE_LISTENING_STATUS, shorten_title, tool_summary
28
+ from pycodex.utils.visualize import (
29
+ IDLE_LISTENING_STATUS,
30
+ percent_of_context_window_remaining,
31
+ shorten_title,
32
+ tool_summary,
33
+ )
29
34
  import typing
30
35
 
31
36
 
@@ -180,6 +185,8 @@ class WebSessionView:
180
185
  self._title = ""
181
186
  self._spinner_status = ""
182
187
  self._stream_buffer = ""
188
+ self._context_window_tokens: "typing.Union[int, None]" = None
189
+ self._context_remaining_percent: "typing.Union[int, None]" = None
183
190
  self._closed = False
184
191
  self._server_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
185
192
  self._worker_loop: "typing.Union[asyncio.AbstractEventLoop, None]" = None
@@ -347,7 +354,11 @@ class WebSessionView:
347
354
  self,
348
355
  context_window_tokens: "typing.Union[int, None]",
349
356
  ) -> None:
350
- del context_window_tokens
357
+ with self._lock:
358
+ self._context_window_tokens = context_window_tokens
359
+ self._context_remaining_percent = (
360
+ 100 if context_window_tokens is not None else None
361
+ )
351
362
 
352
363
  def subscribe(self) -> "asyncio.Queue":
353
364
  queue: "asyncio.Queue" = asyncio.Queue()
@@ -386,6 +397,7 @@ class WebSessionView:
386
397
  "spinner": self._spinner_status,
387
398
  "model": "pycodex",
388
399
  "title": self._title,
400
+ "context_remaining_percent": self._context_remaining_percent,
389
401
  "turns": [_public_turn(turn) for turn in self._turns[-80:]],
390
402
  }
391
403
 
@@ -396,6 +408,8 @@ class WebSessionView:
396
408
  "spinner": self._spinner_status,
397
409
  "title": self._title,
398
410
  "turn_count": len(self._turns),
411
+ "last_assistant": _last_assistant_text(self._turns),
412
+ "context_remaining_percent": self._context_remaining_percent,
399
413
  }
400
414
 
401
415
  def _apply_runtime_event(self, event: "AgentEvent") -> None:
@@ -403,6 +417,9 @@ class WebSessionView:
403
417
  payload = getattr(event, "payload", {})
404
418
  if not isinstance(payload, dict):
405
419
  payload = {}
420
+ if kind == "token_count":
421
+ self._update_context_window(payload.get("usage"))
422
+ return
406
423
  turn_id = str(payload.get("turn_id") or getattr(event, "turn_id", "") or "")
407
424
  submission_id = str(payload.get("submission_id") or turn_id or "")
408
425
  turn = self._turns_by_submission_id.get(submission_id)
@@ -475,6 +492,22 @@ class WebSessionView:
475
492
  turn["status"] = "interrupted"
476
493
  self._stream_buffer = ""
477
494
 
495
+ def _update_context_window(self, usage: "object") -> None:
496
+ if self._context_window_tokens is None:
497
+ return
498
+ if not isinstance(usage, dict):
499
+ self._context_remaining_percent = None
500
+ return
501
+ try:
502
+ total_tokens = int(usage["total_tokens"])
503
+ except (KeyError, TypeError, ValueError):
504
+ self._context_remaining_percent = None
505
+ return
506
+ self._context_remaining_percent = percent_of_context_window_remaining(
507
+ total_tokens,
508
+ self._context_window_tokens,
509
+ )
510
+
478
511
  def _apply_spinner_event(
479
512
  self,
480
513
  kind: str,
@@ -974,6 +1007,10 @@ class WorkspaceSessionManager:
974
1007
  "running": bool(summary.get("running")),
975
1008
  "spinner": summary.get("spinner") or "",
976
1009
  "turn_count": summary.get("turn_count") or 0,
1010
+ "last_assistant": summary.get("last_assistant") or "",
1011
+ "context_remaining_percent": summary.get(
1012
+ "context_remaining_percent"
1013
+ ),
977
1014
  }
978
1015
  )
979
1016
  return result
@@ -1178,9 +1215,21 @@ def _session_summary(session) -> "typing.Dict[str, object]":
1178
1215
  "running": bool(snapshot.get("running")),
1179
1216
  "spinner": snapshot.get("spinner") or "",
1180
1217
  "turn_count": len(snapshot.get("turns") or []),
1218
+ "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
1219
+ "context_remaining_percent": snapshot.get("context_remaining_percent"),
1181
1220
  }
1182
1221
 
1183
1222
 
1223
+ def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
1224
+ for turn in reversed(list(turns)):
1225
+ if str(turn.get("kind") or "assistant") == "control":
1226
+ continue
1227
+ response = str(turn.get("response") or "").strip()
1228
+ if response:
1229
+ return response
1230
+ return ""
1231
+
1232
+
1184
1233
  def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]":
1185
1234
  return typing.cast(
1186
1235
  "typing.Dict[str, object]",
@@ -7,14 +7,31 @@
7
7
  <style>
8
8
  :root {
9
9
  color-scheme: light;
10
- --bg: #f5f5f2;
10
+ --bg: #f2f6f3;
11
11
  --panel: #ffffff;
12
- --ink: #1f2528;
13
- --muted: #667178;
14
- --line: #d8ddd8;
15
- --accent: #126b43;
12
+ --ink: #1e2529;
13
+ --muted: #5f6f75;
14
+ --line: #d2ddd5;
15
+ --accent: #0f8f55;
16
16
  --accent-ink: #ffffff;
17
- --code: #f6f8fa;
17
+ --warn-soft: #fff5cf;
18
+ --info: #1f6fa4;
19
+ --info-soft: #edf8ff;
20
+ --danger: #b23a3a;
21
+ --danger-soft: #fff1ef;
22
+ --violet: #6b4fb0;
23
+ --violet-soft: #f5f0ff;
24
+ --code: #f3f7f8;
25
+ --entry-shadow: 0 1px 2px rgba(25, 35, 31, 0.04);
26
+ --space-1: 2px;
27
+ --space-2: 4px;
28
+ --space-3: 6px;
29
+ --space-4: 8px;
30
+ --space-5: 10px;
31
+ --space-6: 12px;
32
+ --space-7: 14px;
33
+ --radius-sm: 4px;
34
+ --radius-md: 6px;
18
35
  --chat-width: min(42vw, 760px);
19
36
  --splitter-width: 8px;
20
37
  }
@@ -34,7 +51,7 @@
34
51
  min-width: 0;
35
52
  }
36
53
  .workspace.collapsed .splitter::after {
37
- background: #7e9187;
54
+ background: #4f6f60;
38
55
  }
39
56
  .board-pane {
40
57
  min-width: 0;
@@ -46,7 +63,7 @@
46
63
  .boardbar {
47
64
  min-width: 0;
48
65
  border-bottom: 1px solid var(--line);
49
- padding: 8px 12px;
66
+ padding: var(--space-4) var(--space-6);
50
67
  color: var(--muted);
51
68
  font-size: 12px;
52
69
  white-space: nowrap;
@@ -61,24 +78,36 @@
61
78
  background: #fff;
62
79
  }
63
80
  .splitter {
81
+ position: relative;
64
82
  width: 8px;
65
83
  height: 100dvh;
66
84
  border: 0;
67
- border-left: 1px solid var(--line);
68
- border-right: 1px solid var(--line);
69
- background: #eef1ed;
85
+ border-left: 1px solid #c1cec6;
86
+ border-right: 1px solid #c1cec6;
87
+ background: #dfe8e2;
70
88
  cursor: col-resize;
71
89
  padding: 0;
72
90
  touch-action: none;
73
91
  }
74
92
  .splitter::after {
75
93
  content: "";
76
- display: block;
77
- width: 2px;
94
+ position: absolute;
95
+ left: 50%;
96
+ top: 50%;
97
+ width: 3px;
78
98
  height: 42px;
79
- margin: calc(50dvh - 21px) auto 0;
80
99
  border-radius: 999px;
81
- background: #b9c1ba;
100
+ background: #7d9588;
101
+ transform: translate(-50%, -50%);
102
+ }
103
+ .splitter:hover,
104
+ .workspace.resizing .splitter {
105
+ background: #d3dfd7;
106
+ border-color: #aebeb4;
107
+ }
108
+ .splitter:hover::after,
109
+ .workspace.resizing .splitter::after {
110
+ background: #527465;
82
111
  }
83
112
  .workspace.resizing,
84
113
  .workspace.resizing * {
@@ -96,37 +125,68 @@
96
125
  }
97
126
  .topbar {
98
127
  min-width: 0;
99
- padding: 12px 14px;
128
+ padding: var(--space-4) var(--space-7);
100
129
  border-bottom: 1px solid var(--line);
101
130
  display: grid;
102
- gap: 10px;
131
+ gap: var(--space-4);
103
132
  }
104
133
  .tabbar {
105
134
  display: flex;
106
135
  align-items: center;
107
- gap: 6px;
136
+ gap: var(--space-2);
108
137
  min-width: 0;
109
138
  }
139
+ .context-meter {
140
+ --context-remaining: 0%;
141
+ --context-meter-size: 22px;
142
+ position: relative;
143
+ display: inline-grid;
144
+ place-items: center;
145
+ width: var(--context-meter-size);
146
+ height: var(--context-meter-size);
147
+ border-radius: 999px;
148
+ background: conic-gradient(#4ca56f 0 var(--context-remaining), #cfd8d2 0 100%);
149
+ color: #335243;
150
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
151
+ font-size: 9px;
152
+ line-height: 1;
153
+ flex: 0 0 auto;
154
+ }
155
+ .context-meter.hidden {
156
+ display: none;
157
+ }
158
+ .context-meter::before {
159
+ content: "";
160
+ position: absolute;
161
+ inset: 4px;
162
+ border-radius: 999px;
163
+ background: #fff;
164
+ }
165
+ .context-meter-value {
166
+ position: relative;
167
+ z-index: 1;
168
+ }
110
169
  .tabs {
111
170
  display: flex;
112
171
  align-items: center;
113
- gap: 6px;
172
+ gap: var(--space-1);
114
173
  min-width: 0;
115
174
  overflow-x: auto;
116
175
  scrollbar-width: thin;
117
176
  }
118
177
  .tab {
178
+ position: relative;
119
179
  display: inline-flex;
120
180
  align-items: center;
121
- gap: 6px;
181
+ gap: var(--space-2);
122
182
  min-width: 88px;
123
183
  max-width: 180px;
124
- height: 28px;
125
- border: 1px solid var(--line);
126
- border-radius: 6px;
127
- background: #f8faf8;
184
+ height: 31px;
185
+ border: 1px solid #d5ddd7;
186
+ border-radius: var(--radius-sm);
187
+ background: #f6f8f7;
128
188
  color: var(--ink);
129
- padding: 0 7px;
189
+ padding: 0 var(--space-3) 0 var(--space-4);
130
190
  font: inherit;
131
191
  cursor: pointer;
132
192
  user-select: none;
@@ -137,8 +197,30 @@
137
197
  outline-offset: 1px;
138
198
  }
139
199
  .tab.active {
140
- border-color: #9bb7a6;
141
- background: #edf6f0;
200
+ border-color: #afd6bd;
201
+ background: #eef7f1;
202
+ box-shadow: inset 0 -2px 0 var(--accent);
203
+ }
204
+ .tab-status {
205
+ position: relative;
206
+ display: inline-flex;
207
+ align-items: center;
208
+ justify-content: center;
209
+ width: 15px;
210
+ height: 15px;
211
+ border: 1px solid transparent;
212
+ border-radius: 999px;
213
+ background: transparent;
214
+ padding: 0;
215
+ flex: 0 0 auto;
216
+ }
217
+ .tab-status::before {
218
+ content: "";
219
+ width: 5px;
220
+ height: 5px;
221
+ border-radius: 999px;
222
+ background: var(--accent);
223
+ animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
142
224
  }
143
225
  .tab-label {
144
226
  min-width: 0;
@@ -150,11 +232,11 @@
150
232
  }
151
233
  .tab-close,
152
234
  .tab-new {
153
- width: 24px;
154
- height: 24px;
235
+ width: 31px;
236
+ height: 31px;
155
237
  border: 1px solid var(--line);
156
- border-radius: 6px;
157
- background: #fff;
238
+ border-radius: var(--radius-md);
239
+ background: #eef7f1;
158
240
  color: var(--muted);
159
241
  padding: 0;
160
242
  line-height: 1;
@@ -166,56 +248,79 @@
166
248
  height: 18px;
167
249
  border: 0;
168
250
  background: transparent;
251
+ opacity: 0.42;
252
+ font-size: 17px;
253
+ font-weight: 500;
254
+ line-height: 18px;
255
+ }
256
+ .tab-new {
257
+ font-size: 22px;
258
+ font-weight: 400;
259
+ line-height: 31px;
260
+ }
261
+ .tab:hover .tab-close,
262
+ .tab:focus-within .tab-close,
263
+ .tab.active .tab-close {
264
+ opacity: 0.85;
265
+ }
266
+ .tab-close:disabled {
267
+ opacity: 0.22;
268
+ cursor: default;
169
269
  }
170
270
  .tab-close:hover,
171
271
  .tab-new:hover {
172
272
  color: var(--ink);
173
- background: #f3f6f4;
273
+ background: #ffffff;
174
274
  }
175
275
  .log {
176
276
  min-height: 0;
177
277
  overflow-y: auto;
178
278
  overflow-x: hidden;
179
- padding: 14px;
279
+ padding: var(--space-6) var(--space-7);
180
280
  display: flex;
181
281
  flex-direction: column;
182
- gap: 10px;
282
+ gap: var(--space-4);
183
283
  background: #fbfcfa;
184
284
  }
185
285
  .entry {
186
286
  min-width: 0;
187
287
  border: 1px solid var(--line);
188
- border-radius: 6px;
189
- padding: 10px 11px;
288
+ border-radius: var(--radius-sm);
289
+ padding: var(--space-4) var(--space-5);
190
290
  background: #fff;
291
+ box-shadow: var(--entry-shadow);
191
292
  }
192
293
  .entry.user {
193
- border-color: #e6d790;
194
- background: #fff9d8;
294
+ border-color: #edd17a;
295
+ background: var(--warn-soft);
296
+ color: #5a4714;
195
297
  }
196
298
  .entry.response,
197
299
  .entry.assistant {
198
- border-color: #d9ded9;
300
+ border-color: #d0ddd5;
199
301
  background: #ffffff;
200
302
  }
201
303
  .entry.thinking {
202
- border-color: #b9d9c7;
203
- background: #f5fbf6;
304
+ border-color: #b9dcc5;
305
+ background: #f0fbf3;
306
+ color: #256b43;
307
+ animation: thinking-breathe 4.8s ease-in-out infinite;
204
308
  }
205
309
  .entry.control {
206
- border-color: #c8d2dc;
207
- background: #f7fafd;
310
+ border-color: #b9d8ea;
311
+ background: var(--info-soft);
312
+ color: var(--info);
208
313
  }
209
314
  .entry.tool {
210
- border-color: #d7d1e6;
211
- background: #faf8ff;
212
- color: #4c4261;
315
+ border-color: #d7c8f0;
316
+ background: var(--violet-soft);
317
+ color: var(--violet);
213
318
  font-size: 12px;
214
319
  }
215
320
  .entry.error {
216
- border-color: #e4b6b6;
217
- background: #fff7f7;
218
- color: #7a2c2c;
321
+ border-color: #efb4ad;
322
+ background: var(--danger-soft);
323
+ color: var(--danger);
219
324
  }
220
325
  .text {
221
326
  white-space: pre-wrap;
@@ -226,7 +331,7 @@
226
331
  overflow-wrap: anywhere;
227
332
  }
228
333
  .markdown p {
229
- margin: 0 0 8px;
334
+ margin: 0 0 var(--space-4);
230
335
  }
231
336
  .markdown p:last-child,
232
337
  .markdown ul:last-child,
@@ -237,17 +342,17 @@
237
342
  }
238
343
  .markdown ul,
239
344
  .markdown ol {
240
- margin: 0 0 8px 18px;
345
+ margin: 0 0 var(--space-4) 18px;
241
346
  padding: 0;
242
347
  }
243
348
  .markdown li {
244
349
  margin: 2px 0;
245
350
  }
246
351
  .markdown pre {
247
- margin: 0 0 8px;
248
- padding: 9px 10px;
352
+ margin: 0 0 var(--space-4);
353
+ padding: var(--space-4) var(--space-5);
249
354
  border: 1px solid var(--line);
250
- border-radius: 6px;
355
+ border-radius: var(--radius-sm);
251
356
  background: var(--code);
252
357
  overflow-x: auto;
253
358
  white-space: pre;
@@ -260,69 +365,191 @@
260
365
  }
261
366
  .markdown table {
262
367
  width: 100%;
263
- margin: 0 0 8px;
368
+ margin: 0 0 var(--space-4);
264
369
  border-collapse: collapse;
265
370
  font-size: 13px;
266
371
  }
267
372
  .markdown th,
268
373
  .markdown td {
269
374
  border: 1px solid var(--line);
270
- padding: 5px 7px;
375
+ padding: var(--space-3) var(--space-4);
271
376
  text-align: left;
272
377
  vertical-align: top;
273
378
  }
274
379
  .markdown th {
275
- background: #f3f6f4;
380
+ background: #eef7f1;
276
381
  font-weight: 650;
277
382
  }
278
383
  .markdown a {
279
- color: #0b6bcb;
384
+ color: #176fa8;
280
385
  text-decoration: none;
281
386
  }
282
387
  .markdown a:hover {
283
388
  text-decoration: underline;
284
389
  }
285
390
  .composer {
391
+ position: relative;
286
392
  min-width: 0;
287
- border-top: 1px solid var(--line);
288
- padding: 12px 12px 28px;
393
+ padding: var(--space-4);
289
394
  display: grid;
290
- gap: 8px;
395
+ gap: var(--space-3);
396
+ align-content: end;
291
397
  background: #fff;
292
398
  }
293
399
  textarea {
294
400
  min-width: 0;
295
401
  resize: vertical;
296
- min-height: 88px;
297
402
  max-height: 35vh;
298
403
  width: 100%;
299
404
  border: 1px solid var(--line);
300
- border-radius: 6px;
301
- padding: 10px;
405
+ border-radius: var(--radius-sm);
406
+ padding: var(--space-4) var(--space-5);
407
+ background: #ffffff;
408
+ color: var(--ink);
302
409
  font: inherit;
303
410
  line-height: 1.4;
411
+ overflow-y: auto;
412
+ }
413
+ textarea:focus {
414
+ border-color: #65b782;
415
+ box-shadow: 0 0 0 3px rgba(15, 143, 85, 0.13);
416
+ outline: 0;
304
417
  }
305
418
  .actions {
306
419
  display: flex;
307
420
  justify-content: flex-end;
308
421
  align-items: center;
309
422
  }
423
+ .spinner-wrap {
424
+ position: relative;
425
+ display: inline-flex;
426
+ align-items: center;
427
+ justify-self: start;
428
+ align-self: end;
429
+ max-width: 100%;
430
+ }
431
+ .prompt-row {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: var(--space-2);
435
+ min-width: 0;
436
+ }
310
437
  .spinner {
311
- position: absolute;
312
- left: 12px;
313
- right: 12px;
314
- bottom: 6px;
315
- min-height: 18px;
438
+ --spinner-pill-height: 22px;
439
+ position: relative;
440
+ display: inline-flex;
441
+ align-items: center;
442
+ max-width: 100%;
443
+ height: var(--spinner-pill-height);
316
444
  color: var(--muted);
317
445
  font-size: 12px;
318
446
  white-space: nowrap;
319
447
  overflow: hidden;
320
448
  text-overflow: ellipsis;
449
+ padding: var(--space-1) var(--space-4) var(--space-1) 18px;
450
+ border-radius: 999px;
451
+ background: rgba(240, 250, 244, 0.96);
452
+ border: 1px solid rgba(172, 216, 188, 0.9);
453
+ font: inherit;
454
+ font-size: 12px;
455
+ cursor: pointer;
456
+ }
457
+ .spinner:empty {
458
+ display: none;
459
+ }
460
+ .spinner-wrap:not(:has(.spinner:not(:empty))) {
461
+ display: none;
462
+ }
463
+ .spinner:focus-visible {
464
+ border-color: #65b782;
465
+ box-shadow: 0 0 0 3px rgba(15, 143, 85, 0.13);
466
+ outline: 0;
467
+ }
468
+ .spinner-wrap:has(.spinner:not(:empty)) {
469
+ margin-bottom: var(--space-1);
470
+ }
471
+ .spinner:not(:empty)::before {
472
+ content: "";
473
+ position: absolute;
474
+ left: 7px;
475
+ top: 50%;
476
+ width: 6px;
477
+ height: 6px;
478
+ margin-top: -3px;
479
+ border-radius: 999px;
480
+ background: var(--accent);
481
+ animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
482
+ }
483
+ .spinner.notify-enabled {
484
+ color: #0b6b43;
485
+ background: #e6f4eb;
486
+ border-color: #4ca56f;
487
+ box-shadow:
488
+ inset 0 1px 2px rgba(9, 93, 55, 0.16),
489
+ inset 0 0 0 1px rgba(15, 143, 85, 0.1);
490
+ }
491
+ .toast {
492
+ position: absolute;
493
+ left: 0;
494
+ bottom: 100%;
495
+ z-index: 5;
496
+ width: max-content;
497
+ max-width: min(320px, calc(100vw - var(--space-6) * 2));
498
+ border: 1px solid #bfd6c8;
499
+ border-radius: var(--radius-sm);
500
+ background: #f6fbf8;
501
+ color: var(--ink);
502
+ box-shadow: 0 8px 20px rgba(25, 35, 31, 0.12);
503
+ padding: var(--space-4) var(--space-6);
504
+ font-size: 12px;
505
+ opacity: 0;
506
+ pointer-events: none;
507
+ transform: translateY(4px);
508
+ transition: opacity 120ms ease, transform 120ms ease;
509
+ margin-bottom: var(--space-3);
510
+ }
511
+ .toast.visible {
512
+ opacity: 1;
513
+ transform: translateY(0);
514
+ }
515
+ @keyframes spinner-pulse {
516
+ 0%, 100% {
517
+ opacity: 0.45;
518
+ transform: scale(0.82);
519
+ }
520
+ 50% {
521
+ opacity: 1;
522
+ transform: scale(1.08);
523
+ }
524
+ }
525
+ @keyframes thinking-breathe {
526
+ 0%, 100% {
527
+ border-color: #b9dcc5;
528
+ box-shadow: var(--entry-shadow);
529
+ }
530
+ 50% {
531
+ border-color: #72d494;
532
+ box-shadow:
533
+ 0 0 0 2px rgba(22, 168, 90, 0.16),
534
+ 0 2px 7px rgba(25, 35, 31, 0.08);
535
+ }
536
+ }
537
+ @media (prefers-reduced-motion: reduce) {
538
+ .entry.thinking,
539
+ .tab-status::before,
540
+ .spinner:not(:empty)::before {
541
+ animation: none;
542
+ }
543
+ .tab-status::before,
544
+ .spinner:not(:empty)::before {
545
+ opacity: 0.8;
546
+ transform: none;
547
+ }
321
548
  }
322
549
  code {
323
550
  background: var(--code);
324
551
  border: 1px solid var(--line);
325
- border-radius: 4px;
552
+ border-radius: var(--radius-sm);
326
553
  padding: 1px 4px;
327
554
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
328
555
  font-size: 12px;
@@ -343,15 +570,23 @@
343
570
  <section class="chat" aria-label="pycodex chat">
344
571
  <div class="topbar">
345
572
  <div class="tabbar">
346
- <div id="tabs" class="tabs"></div>
573
+ <div id="tabs" class="tabs" role="tablist"></div>
347
574
  <button id="newTab" class="tab-new" type="button" title="New session" aria-label="New session">+</button>
348
575
  </div>
349
576
  </div>
350
577
  <div id="log" class="log"></div>
351
578
  <form id="composer" class="composer">
352
- <textarea id="prompt" placeholder="Ask pycodex or type /help..."></textarea>
579
+ <div class="spinner-wrap">
580
+ <div id="toast" class="toast" role="status" aria-live="polite"></div>
581
+ <button id="spinner" class="spinner" type="button" role="checkbox" aria-checked="false" aria-live="polite"></button>
582
+ </div>
583
+ <div class="prompt-row">
584
+ <div id="contextMeter" class="context-meter hidden" aria-hidden="true">
585
+ <span id="contextMeterValue" class="context-meter-value"></span>
586
+ </div>
587
+ <textarea id="prompt" rows="1" placeholder="Ask pycodex or type /help..."></textarea>
588
+ </div>
353
589
  </form>
354
- <div id="spinner" class="spinner"></div>
355
590
  </section>
356
591
  </div>
357
592
  <script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
@@ -359,30 +594,31 @@
359
594
  <script>
360
595
  const log = document.getElementById("log");
361
596
  const spinner = document.getElementById("spinner");
597
+ const toast = document.getElementById("toast");
362
598
  const prompt = document.getElementById("prompt");
363
599
  const form = document.getElementById("composer");
364
600
  const boardFrame = document.getElementById("boardFrame");
365
601
  const workspace = document.querySelector(".workspace");
366
602
  const splitter = document.getElementById("splitter");
367
603
  const tabsEl = document.getElementById("tabs");
604
+ const contextMeter = document.getElementById("contextMeter");
605
+ const contextMeterValue = document.getElementById("contextMeterValue");
368
606
  const newTabButton = document.getElementById("newTab");
369
607
  let pollTimer = null;
370
608
  let boardPollTimer = null;
371
609
  let pollInFlight = false;
372
610
  let boardPollInFlight = false;
373
- let lastBoardSignature = "";
611
+ let lastBoardSignature = null;
374
612
  let lastRenderedSignature = "";
375
613
  let resizeStart = null;
376
- let spinnerTimer = null;
377
- let spinnerFrameIndex = 0;
378
614
  let spinnerText = "";
615
+ let toastTimer = null;
379
616
  let activeSessionId = "";
380
617
  let sessions = [];
381
618
  let isComposing = false;
382
619
  const sessionState = new Map();
383
620
  let restoreScrollTop = null;
384
621
  let suppressScrollSave = false;
385
- const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
386
622
  const markdownRenderer = window.markdownit
387
623
  ? window.markdownit({html: false, linkify: true, breaks: false})
388
624
  : null;
@@ -494,27 +730,128 @@
494
730
  event.preventDefault();
495
731
  }
496
732
 
497
- function setSpinner(text) {
498
- spinnerText = String(text || "").trim();
499
- spinnerFrameIndex = 0;
500
- renderSpinner();
501
- if (spinnerText && !spinnerTimer) {
502
- spinnerTimer = setInterval(renderSpinner, 120);
503
- }
504
- if (!spinnerText && spinnerTimer) {
505
- clearInterval(spinnerTimer);
506
- spinnerTimer = null;
733
+ function setSpinner(text, options = {}) {
734
+ const previousText = spinnerText;
735
+ const nextText = String(text || "").trim();
736
+ const wasRunning = Boolean(previousText);
737
+ const isRunning = Boolean(nextText);
738
+ spinnerText = nextText;
739
+ spinner.textContent = spinnerText;
740
+ if (wasRunning && !isRunning && !options.suppressDoneNotification) {
741
+ const state = stateForSession(activeSessionId);
742
+ if (state.wasRunning) notifySessionDone(activeSessionId);
507
743
  }
744
+ updateSpinnerNotifyUi();
745
+ }
746
+
747
+ function showToast(text) {
748
+ if (toastTimer) clearTimeout(toastTimer);
749
+ toast.textContent = text;
750
+ toast.classList.add("visible");
751
+ toastTimer = setTimeout(() => {
752
+ toastTimer = null;
753
+ toast.classList.remove("visible");
754
+ }, 1400);
508
755
  }
509
756
 
510
- function renderSpinner() {
757
+ function updateSpinnerNotifyUi() {
758
+ const state = stateForSession(activeSessionId);
759
+ const enabled = Boolean(spinnerText && state.notifyWhenDone);
760
+ spinner.classList.toggle("notify-enabled", enabled);
761
+ spinner.setAttribute("aria-checked", enabled ? "true" : "false");
511
762
  if (!spinnerText) {
512
- spinner.textContent = "";
763
+ spinner.title = "Spinner is idle";
764
+ } else if (enabled) {
765
+ spinner.title = "Notify when done is on";
766
+ } else {
767
+ spinner.title = "Notify when done is off";
768
+ }
769
+ }
770
+
771
+ function notificationAvailable() {
772
+ return "Notification" in window;
773
+ }
774
+
775
+ function requestNotificationPermission() {
776
+ try {
777
+ const requestResult = Notification.requestPermission();
778
+ if (requestResult && typeof requestResult.then === "function") {
779
+ return requestResult;
780
+ }
781
+ return Promise.resolve(Notification.permission);
782
+ } catch (_error) {
783
+ return Promise.resolve(Notification.permission);
784
+ }
785
+ }
786
+
787
+ function notifySessionDone(sessionId, sessionSummary = null) {
788
+ if (!sessionId) return;
789
+ const state = stateForSession(sessionId);
790
+ if (!state.notifyWhenDone) return;
791
+ state.notifyWhenDone = false;
792
+ if (!notificationAvailable() || Notification.permission !== "granted") return;
793
+ const session = sessionSummary || sessionById(sessionId);
794
+ const title = (session && session.title) || "pycodex";
795
+ const lastAssistant = (session && session.last_assistant) || "Done.";
796
+ try {
797
+ new Notification(`session: ${title}`, {
798
+ body: lastAssistant,
799
+ tag: `pycodex-workspace-done-${sessionId}`,
800
+ });
801
+ } catch (_error) {
513
802
  return;
514
803
  }
515
- const frame = spinnerFrames[spinnerFrameIndex % spinnerFrames.length];
516
- spinnerFrameIndex += 1;
517
- spinner.textContent = `${frame} ${spinnerText}`;
804
+ }
805
+
806
+ function sessionById(sessionId) {
807
+ return sessions.find((session) => (session.id || "") === sessionId) || null;
808
+ }
809
+
810
+ function isSessionRunning(sessionId) {
811
+ if (sessionId === activeSessionId && spinnerText) return true;
812
+ const session = sessionById(sessionId);
813
+ if (session) return Boolean(session.running);
814
+ return false;
815
+ }
816
+
817
+ async function toggleSpinnerNotification() {
818
+ const sessionId = activeSessionId;
819
+ if (!sessionId || !isSessionRunning(sessionId)) {
820
+ showToast("notify-on-done unavailable");
821
+ return;
822
+ }
823
+ const state = stateForSession(sessionId);
824
+ if (state.notifyWhenDone) {
825
+ state.notifyWhenDone = false;
826
+ showToast("notify-on-done canceled");
827
+ updateSpinnerNotifyUi();
828
+ return;
829
+ }
830
+ if (!notificationAvailable()) {
831
+ showToast("browser notifications unavailable");
832
+ return;
833
+ }
834
+ if (Notification.permission === "denied") {
835
+ showToast("browser notifications blocked");
836
+ return;
837
+ }
838
+ state.notifyWhenDone = true;
839
+ showToast("notify-on-done enabled");
840
+ updateSpinnerNotifyUi();
841
+ if (Notification.permission !== "granted") {
842
+ const permission = await requestNotificationPermission();
843
+ if (permission !== "granted") {
844
+ state.notifyWhenDone = false;
845
+ showToast("notify-on-done canceled");
846
+ updateSpinnerNotifyUi();
847
+ return;
848
+ }
849
+ }
850
+ if (!isSessionRunning(sessionId)) {
851
+ state.notifyWhenDone = false;
852
+ showToast("notify-on-done canceled");
853
+ }
854
+ updateSpinnerNotifyUi();
518
855
  }
519
856
 
520
857
  function isNearBottom() {
@@ -525,10 +862,24 @@
525
862
  log.scrollTop = log.scrollHeight;
526
863
  }
527
864
 
865
+ function resizePrompt() {
866
+ prompt.style.height = "auto";
867
+ const borderHeight = prompt.offsetHeight - prompt.clientHeight;
868
+ const maxHeight = Math.round(window.innerHeight * 0.35);
869
+ const nextHeight = Math.min(prompt.scrollHeight + borderHeight, maxHeight);
870
+ prompt.style.height = `${nextHeight}px`;
871
+ }
872
+
528
873
  function stateForSession(sessionId) {
529
874
  const key = String(sessionId || "");
530
875
  if (!sessionState.has(key)) {
531
- sessionState.set(key, {lastRenderedSignature: "", scrollTop: null, draft: ""});
876
+ sessionState.set(key, {
877
+ lastRenderedSignature: "",
878
+ scrollTop: null,
879
+ draft: "",
880
+ notifyWhenDone: false,
881
+ wasRunning: false,
882
+ });
532
883
  }
533
884
  return sessionState.get(key);
534
885
  }
@@ -544,11 +895,27 @@
544
895
  }
545
896
 
546
897
  function updateSessions(nextSessions) {
547
- sessions = Array.isArray(nextSessions) ? nextSessions : sessions;
898
+ const incomingSessions = Array.isArray(nextSessions) ? nextSessions : sessions;
899
+ incomingSessions.forEach((session) => {
900
+ const sessionId = session.id || "";
901
+ if (!sessionId) return;
902
+ const state = stateForSession(sessionId);
903
+ const wasRunning = Boolean(state.wasRunning);
904
+ const isRunning = Boolean(session.running);
905
+ if (!wasRunning && isRunning) {
906
+ state.notifyWhenDone = false;
907
+ } else if (wasRunning && !isRunning) {
908
+ notifySessionDone(sessionId, session);
909
+ }
910
+ state.wasRunning = isRunning;
911
+ });
912
+ sessions = incomingSessions;
548
913
  if (!activeSessionId && sessions.length) {
549
914
  activeSessionId = sessions[0].id || "";
550
915
  }
551
916
  renderTabs();
917
+ updateContextMeter();
918
+ updateSpinnerNotifyUi();
552
919
  }
553
920
 
554
921
  function renderTabs() {
@@ -556,12 +923,23 @@
556
923
  sessions.forEach((session, index) => {
557
924
  const sessionId = session.id || "";
558
925
  const tab = document.createElement("div");
559
- tab.className = "tab" + (sessionId === activeSessionId ? " active" : "");
926
+ tab.className =
927
+ "tab" +
928
+ (sessionId === activeSessionId ? " active" : "") +
929
+ (session.running ? " running" : "");
560
930
  tab.title = session.title || `Session ${index + 1}`;
561
931
  tab.setAttribute("role", "tab");
562
932
  tab.setAttribute("aria-selected", sessionId === activeSessionId ? "true" : "false");
933
+ if (session.running) tab.setAttribute("aria-busy", "true");
563
934
  tab.addEventListener("click", () => switchSession(sessionId));
564
935
 
936
+ if (session.running) {
937
+ const status = document.createElement("span");
938
+ status.className = "tab-status";
939
+ status.setAttribute("aria-hidden", "true");
940
+ tab.appendChild(status);
941
+ }
942
+
565
943
  const label = document.createElement("span");
566
944
  label.className = "tab-label";
567
945
  label.textContent = session.title || `Session ${index + 1}`;
@@ -583,6 +961,38 @@
583
961
  });
584
962
  }
585
963
 
964
+ function updateContextMeter(snapshot = null) {
965
+ let percent = null;
966
+ if (snapshot && snapshot.context_remaining_percent !== undefined) {
967
+ percent = snapshot.context_remaining_percent;
968
+ } else {
969
+ const session = sessionById(activeSessionId);
970
+ if (session && session.context_remaining_percent !== undefined) {
971
+ percent = session.context_remaining_percent;
972
+ }
973
+ }
974
+ if (percent === null || percent === undefined || percent === "") {
975
+ contextMeter.classList.add("hidden");
976
+ contextMeterValue.textContent = "";
977
+ return;
978
+ }
979
+ const value = Math.max(0, Math.min(100, Math.round(Number(percent))));
980
+ if (!Number.isFinite(value)) {
981
+ contextMeter.classList.add("hidden");
982
+ contextMeterValue.textContent = "";
983
+ return;
984
+ }
985
+ if (value >= 100) {
986
+ contextMeter.classList.remove("hidden");
987
+ contextMeter.style.setProperty("--context-remaining", `${value}%`);
988
+ contextMeterValue.textContent = "";
989
+ return;
990
+ }
991
+ contextMeter.classList.remove("hidden");
992
+ contextMeter.style.setProperty("--context-remaining", `${value}%`);
993
+ contextMeterValue.textContent = String(value);
994
+ }
995
+
586
996
  function messageSignature(snapshot) {
587
997
  const turns = (snapshot && snapshot.turns) || [];
588
998
  return JSON.stringify(
@@ -644,10 +1054,14 @@
644
1054
  const response = await fetch(relativeUrl("api/board"));
645
1055
  if (!response.ok) return;
646
1056
  const payload = await response.json();
647
- if (!payload.exists) return;
1057
+ if (!payload.exists) {
1058
+ lastBoardSignature = null;
1059
+ return;
1060
+ }
648
1061
  const signature = `${payload.mtime_ns || ""}:${payload.size || ""}`;
649
- if (!lastBoardSignature) {
1062
+ if (lastBoardSignature === null) {
650
1063
  lastBoardSignature = signature;
1064
+ boardFrame.src = `board?t=${Date.now()}`;
651
1065
  return;
652
1066
  }
653
1067
  if (signature !== lastBoardSignature) {
@@ -673,6 +1087,7 @@
673
1087
  const signature = messageSignature(snapshot);
674
1088
  if (signature === state.lastRenderedSignature) {
675
1089
  setSpinner(snapshot.spinner || "");
1090
+ updateContextMeter(snapshot);
676
1091
  return;
677
1092
  }
678
1093
  state.lastRenderedSignature = signature;
@@ -687,6 +1102,7 @@
687
1102
  turns.forEach((turn) => renderTurn(turn, shouldStick));
688
1103
  }
689
1104
  setSpinner(snapshot.spinner || "");
1105
+ updateContextMeter(snapshot);
690
1106
  if (restoreScrollTop !== null) {
691
1107
  log.scrollTop = Math.min(
692
1108
  restoreScrollTop,
@@ -758,10 +1174,11 @@
758
1174
  state.lastRenderedSignature = "";
759
1175
  lastRenderedSignature = "";
760
1176
  prompt.value = state.draft || "";
1177
+ resizePrompt();
761
1178
  suppressScrollSave = true;
762
1179
  log.textContent = "";
763
1180
  suppressScrollSave = false;
764
- setSpinner("");
1181
+ setSpinner("", {suppressDoneNotification: true});
765
1182
  restoreScrollTop = state.scrollTop;
766
1183
  await pollSession();
767
1184
  renderTabs();
@@ -782,6 +1199,7 @@
782
1199
  state.lastRenderedSignature = "";
783
1200
  lastRenderedSignature = "";
784
1201
  prompt.value = "";
1202
+ resizePrompt();
785
1203
  restoreScrollTop = null;
786
1204
  updateSessions(payload.sessions || []);
787
1205
  renderSnapshot(payload.snapshot);
@@ -803,6 +1221,7 @@
803
1221
  activeSessionId = sessions.length ? sessions[0].id || "" : "";
804
1222
  lastRenderedSignature = "";
805
1223
  prompt.value = "";
1224
+ resizePrompt();
806
1225
  suppressScrollSave = true;
807
1226
  log.textContent = "";
808
1227
  suppressScrollSave = false;
@@ -817,12 +1236,14 @@
817
1236
  const text = prompt.value.trim();
818
1237
  if (!text) return;
819
1238
  prompt.value = "";
1239
+ resizePrompt();
820
1240
  stateForSession(activeSessionId).draft = "";
821
1241
  submitPrompt(text);
822
1242
  });
823
1243
 
824
1244
  prompt.addEventListener("input", () => {
825
1245
  stateForSession(activeSessionId).draft = prompt.value;
1246
+ resizePrompt();
826
1247
  });
827
1248
 
828
1249
  prompt.addEventListener("compositionstart", () => {
@@ -852,13 +1273,21 @@
852
1273
  splitter.addEventListener("pointerup", endResize);
853
1274
  splitter.addEventListener("pointercancel", endResize);
854
1275
  splitter.addEventListener("dblclick", centerSplit);
1276
+ spinner.addEventListener("click", toggleSpinnerNotification);
855
1277
  newTabButton.addEventListener("click", createSession);
856
1278
  window.addEventListener("resize", () => {
857
1279
  const current = Number(window.localStorage.getItem("pycodex.workspace.chatWidth") || 0);
858
1280
  if (current > 0) setChatWidth(current);
1281
+ resizePrompt();
1282
+ });
1283
+ document.addEventListener("visibilitychange", () => {
1284
+ if (document.visibilityState !== "visible") return;
1285
+ pollSession();
1286
+ pollBoard();
859
1287
  });
860
1288
 
861
1289
  loadChatWidth();
1290
+ resizePrompt();
862
1291
  startPolling();
863
1292
  startBoardPolling();
864
1293
  </script>