python-codex 0.2.1__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.1
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=_vu0Zm6ifuwc5rPx0ZCv-OF9oAb6jo4y4JwtJio3Yq0,29432
84
- python_codex-0.2.1.dist-info/METADATA,sha256=w0Pmo56JuaxuqaEOrLRB2et6FHDkRUDo64D-oCUlijo,16812
85
- python_codex-0.2.1.dist-info/WHEEL,sha256=KGYbc1zXlYddvwxnNty23BeaKzh7YuoSIvIMO4jEhvw,87
86
- python_codex-0.2.1.dist-info/entry_points.txt,sha256=vkV2UWCtEKvQNMJuPNjt8HyBKiwp83JyqBatrBNGDp8,80
87
- python_codex-0.2.1.dist-info/licenses/LICENSE,sha256=0X8ifk312hYAORM4hlzg8wVSEXYKNmiPgWlB1YIy2Nw,10926
88
- python_codex-0.2.1.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]",
@@ -136,6 +136,36 @@
136
136
  gap: var(--space-2);
137
137
  min-width: 0;
138
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
+ }
139
169
  .tabs {
140
170
  display: flex;
141
171
  align-items: center;
@@ -171,21 +201,27 @@
171
201
  background: #eef7f1;
172
202
  box-shadow: inset 0 -2px 0 var(--accent);
173
203
  }
174
- .tab.running::before {
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 {
175
218
  content: "";
176
- position: absolute;
177
- left: 6px;
178
- top: 50%;
179
219
  width: 5px;
180
220
  height: 5px;
181
- margin-top: -2.5px;
182
221
  border-radius: 999px;
183
222
  background: var(--accent);
184
223
  animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
185
224
  }
186
- .tab.running .tab-label {
187
- padding-left: var(--space-4);
188
- }
189
225
  .tab-label {
190
226
  min-width: 0;
191
227
  overflow: hidden;
@@ -352,9 +388,9 @@
352
388
  text-decoration: underline;
353
389
  }
354
390
  .composer {
391
+ position: relative;
355
392
  min-width: 0;
356
- border-top: 1px solid var(--line);
357
- padding: var(--space-4) var(--space-6) var(--space-5);
393
+ padding: var(--space-4);
358
394
  display: grid;
359
395
  gap: var(--space-3);
360
396
  align-content: end;
@@ -363,7 +399,6 @@
363
399
  textarea {
364
400
  min-width: 0;
365
401
  resize: vertical;
366
- min-height: 40px;
367
402
  max-height: 35vh;
368
403
  width: 100%;
369
404
  border: 1px solid var(--line);
@@ -385,14 +420,27 @@
385
420
  justify-content: flex-end;
386
421
  align-items: center;
387
422
  }
388
- .spinner {
423
+ .spinner-wrap {
389
424
  position: relative;
390
425
  display: inline-flex;
391
426
  align-items: center;
392
427
  justify-self: start;
393
428
  align-self: end;
394
429
  max-width: 100%;
395
- min-height: 20px;
430
+ }
431
+ .prompt-row {
432
+ display: flex;
433
+ align-items: center;
434
+ gap: var(--space-2);
435
+ min-width: 0;
436
+ }
437
+ .spinner {
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);
396
444
  color: var(--muted);
397
445
  font-size: 12px;
398
446
  white-space: nowrap;
@@ -402,11 +450,22 @@
402
450
  border-radius: 999px;
403
451
  background: rgba(240, 250, 244, 0.96);
404
452
  border: 1px solid rgba(172, 216, 188, 0.9);
453
+ font: inherit;
454
+ font-size: 12px;
455
+ cursor: pointer;
405
456
  }
406
457
  .spinner:empty {
407
458
  display: none;
408
459
  }
409
- .spinner:not(:empty) {
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)) {
410
469
  margin-bottom: var(--space-1);
411
470
  }
412
471
  .spinner:not(:empty)::before {
@@ -421,6 +480,38 @@
421
480
  background: var(--accent);
422
481
  animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
423
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
+ }
424
515
  @keyframes spinner-pulse {
425
516
  0%, 100% {
426
517
  opacity: 0.45;
@@ -445,11 +536,11 @@
445
536
  }
446
537
  @media (prefers-reduced-motion: reduce) {
447
538
  .entry.thinking,
448
- .tab.running::before,
539
+ .tab-status::before,
449
540
  .spinner:not(:empty)::before {
450
541
  animation: none;
451
542
  }
452
- .tab.running::before,
543
+ .tab-status::before,
453
544
  .spinner:not(:empty)::before {
454
545
  opacity: 0.8;
455
546
  transform: none;
@@ -485,8 +576,16 @@
485
576
  </div>
486
577
  <div id="log" class="log"></div>
487
578
  <form id="composer" class="composer">
488
- <div id="spinner" class="spinner" role="status" aria-live="polite"></div>
489
- <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>
490
589
  </form>
491
590
  </section>
492
591
  </div>
@@ -495,21 +594,25 @@
495
594
  <script>
496
595
  const log = document.getElementById("log");
497
596
  const spinner = document.getElementById("spinner");
597
+ const toast = document.getElementById("toast");
498
598
  const prompt = document.getElementById("prompt");
499
599
  const form = document.getElementById("composer");
500
600
  const boardFrame = document.getElementById("boardFrame");
501
601
  const workspace = document.querySelector(".workspace");
502
602
  const splitter = document.getElementById("splitter");
503
603
  const tabsEl = document.getElementById("tabs");
604
+ const contextMeter = document.getElementById("contextMeter");
605
+ const contextMeterValue = document.getElementById("contextMeterValue");
504
606
  const newTabButton = document.getElementById("newTab");
505
607
  let pollTimer = null;
506
608
  let boardPollTimer = null;
507
609
  let pollInFlight = false;
508
610
  let boardPollInFlight = false;
509
- let lastBoardSignature = "";
611
+ let lastBoardSignature = null;
510
612
  let lastRenderedSignature = "";
511
613
  let resizeStart = null;
512
614
  let spinnerText = "";
615
+ let toastTimer = null;
513
616
  let activeSessionId = "";
514
617
  let sessions = [];
515
618
  let isComposing = false;
@@ -627,9 +730,128 @@
627
730
  event.preventDefault();
628
731
  }
629
732
 
630
- function setSpinner(text) {
631
- spinnerText = String(text || "").trim();
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;
632
739
  spinner.textContent = spinnerText;
740
+ if (wasRunning && !isRunning && !options.suppressDoneNotification) {
741
+ const state = stateForSession(activeSessionId);
742
+ if (state.wasRunning) notifySessionDone(activeSessionId);
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);
755
+ }
756
+
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");
762
+ if (!spinnerText) {
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) {
802
+ return;
803
+ }
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();
633
855
  }
634
856
 
635
857
  function isNearBottom() {
@@ -642,16 +864,22 @@
642
864
 
643
865
  function resizePrompt() {
644
866
  prompt.style.height = "auto";
645
- const minHeight = 40;
646
- const maxHeight = Math.max(minHeight, Math.round(window.innerHeight * 0.35));
647
- const nextHeight = Math.max(minHeight, Math.min(prompt.scrollHeight, maxHeight));
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);
648
870
  prompt.style.height = `${nextHeight}px`;
649
871
  }
650
872
 
651
873
  function stateForSession(sessionId) {
652
874
  const key = String(sessionId || "");
653
875
  if (!sessionState.has(key)) {
654
- 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
+ });
655
883
  }
656
884
  return sessionState.get(key);
657
885
  }
@@ -667,11 +895,27 @@
667
895
  }
668
896
 
669
897
  function updateSessions(nextSessions) {
670
- 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;
671
913
  if (!activeSessionId && sessions.length) {
672
914
  activeSessionId = sessions[0].id || "";
673
915
  }
674
916
  renderTabs();
917
+ updateContextMeter();
918
+ updateSpinnerNotifyUi();
675
919
  }
676
920
 
677
921
  function renderTabs() {
@@ -689,6 +933,13 @@
689
933
  if (session.running) tab.setAttribute("aria-busy", "true");
690
934
  tab.addEventListener("click", () => switchSession(sessionId));
691
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
+
692
943
  const label = document.createElement("span");
693
944
  label.className = "tab-label";
694
945
  label.textContent = session.title || `Session ${index + 1}`;
@@ -710,6 +961,38 @@
710
961
  });
711
962
  }
712
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
+
713
996
  function messageSignature(snapshot) {
714
997
  const turns = (snapshot && snapshot.turns) || [];
715
998
  return JSON.stringify(
@@ -771,10 +1054,14 @@
771
1054
  const response = await fetch(relativeUrl("api/board"));
772
1055
  if (!response.ok) return;
773
1056
  const payload = await response.json();
774
- if (!payload.exists) return;
1057
+ if (!payload.exists) {
1058
+ lastBoardSignature = null;
1059
+ return;
1060
+ }
775
1061
  const signature = `${payload.mtime_ns || ""}:${payload.size || ""}`;
776
- if (!lastBoardSignature) {
1062
+ if (lastBoardSignature === null) {
777
1063
  lastBoardSignature = signature;
1064
+ boardFrame.src = `board?t=${Date.now()}`;
778
1065
  return;
779
1066
  }
780
1067
  if (signature !== lastBoardSignature) {
@@ -800,6 +1087,7 @@
800
1087
  const signature = messageSignature(snapshot);
801
1088
  if (signature === state.lastRenderedSignature) {
802
1089
  setSpinner(snapshot.spinner || "");
1090
+ updateContextMeter(snapshot);
803
1091
  return;
804
1092
  }
805
1093
  state.lastRenderedSignature = signature;
@@ -814,6 +1102,7 @@
814
1102
  turns.forEach((turn) => renderTurn(turn, shouldStick));
815
1103
  }
816
1104
  setSpinner(snapshot.spinner || "");
1105
+ updateContextMeter(snapshot);
817
1106
  if (restoreScrollTop !== null) {
818
1107
  log.scrollTop = Math.min(
819
1108
  restoreScrollTop,
@@ -889,7 +1178,7 @@
889
1178
  suppressScrollSave = true;
890
1179
  log.textContent = "";
891
1180
  suppressScrollSave = false;
892
- setSpinner("");
1181
+ setSpinner("", {suppressDoneNotification: true});
893
1182
  restoreScrollTop = state.scrollTop;
894
1183
  await pollSession();
895
1184
  renderTabs();
@@ -984,12 +1273,18 @@
984
1273
  splitter.addEventListener("pointerup", endResize);
985
1274
  splitter.addEventListener("pointercancel", endResize);
986
1275
  splitter.addEventListener("dblclick", centerSplit);
1276
+ spinner.addEventListener("click", toggleSpinnerNotification);
987
1277
  newTabButton.addEventListener("click", createSession);
988
1278
  window.addEventListener("resize", () => {
989
1279
  const current = Number(window.localStorage.getItem("pycodex.workspace.chatWidth") || 0);
990
1280
  if (current > 0) setChatWidth(current);
991
1281
  resizePrompt();
992
1282
  });
1283
+ document.addEventListener("visibilitychange", () => {
1284
+ if (document.visibilityState !== "visible") return;
1285
+ pollSession();
1286
+ pollBoard();
1287
+ });
993
1288
 
994
1289
  loadChatWidth();
995
1290
  resizePrompt();