codex-autorunner 1.1.0__py3-none-any.whl → 1.2.1__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.
Files changed (134) hide show
  1. codex_autorunner/agents/opencode/client.py +113 -4
  2. codex_autorunner/agents/opencode/supervisor.py +4 -0
  3. codex_autorunner/agents/registry.py +17 -7
  4. codex_autorunner/bootstrap.py +219 -1
  5. codex_autorunner/core/__init__.py +17 -1
  6. codex_autorunner/core/about_car.py +124 -11
  7. codex_autorunner/core/app_server_threads.py +6 -0
  8. codex_autorunner/core/config.py +238 -3
  9. codex_autorunner/core/context_awareness.py +39 -0
  10. codex_autorunner/core/docs.py +0 -122
  11. codex_autorunner/core/filebox.py +265 -0
  12. codex_autorunner/core/flows/controller.py +71 -1
  13. codex_autorunner/core/flows/reconciler.py +4 -1
  14. codex_autorunner/core/flows/runtime.py +22 -0
  15. codex_autorunner/core/flows/store.py +61 -9
  16. codex_autorunner/core/flows/transition.py +23 -16
  17. codex_autorunner/core/flows/ux_helpers.py +18 -3
  18. codex_autorunner/core/flows/worker_process.py +32 -6
  19. codex_autorunner/core/hub.py +198 -41
  20. codex_autorunner/core/lifecycle_events.py +253 -0
  21. codex_autorunner/core/path_utils.py +2 -1
  22. codex_autorunner/core/pma_audit.py +224 -0
  23. codex_autorunner/core/pma_context.py +683 -0
  24. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  25. codex_autorunner/core/pma_lifecycle.py +527 -0
  26. codex_autorunner/core/pma_queue.py +367 -0
  27. codex_autorunner/core/pma_safety.py +221 -0
  28. codex_autorunner/core/pma_state.py +115 -0
  29. codex_autorunner/core/ports/agent_backend.py +2 -5
  30. codex_autorunner/core/ports/run_event.py +1 -4
  31. codex_autorunner/core/prompt.py +0 -80
  32. codex_autorunner/core/prompts.py +56 -172
  33. codex_autorunner/core/redaction.py +0 -4
  34. codex_autorunner/core/review_context.py +11 -9
  35. codex_autorunner/core/runner_controller.py +35 -33
  36. codex_autorunner/core/runner_state.py +147 -0
  37. codex_autorunner/core/runtime.py +829 -0
  38. codex_autorunner/core/sqlite_utils.py +13 -4
  39. codex_autorunner/core/state.py +7 -10
  40. codex_autorunner/core/state_roots.py +5 -0
  41. codex_autorunner/core/templates/__init__.py +39 -0
  42. codex_autorunner/core/templates/git_mirror.py +234 -0
  43. codex_autorunner/core/templates/provenance.py +56 -0
  44. codex_autorunner/core/templates/scan_cache.py +120 -0
  45. codex_autorunner/core/ticket_linter_cli.py +17 -0
  46. codex_autorunner/core/ticket_manager_cli.py +154 -92
  47. codex_autorunner/core/time_utils.py +11 -0
  48. codex_autorunner/core/types.py +18 -0
  49. codex_autorunner/core/utils.py +34 -6
  50. codex_autorunner/flows/review/service.py +23 -25
  51. codex_autorunner/flows/ticket_flow/definition.py +43 -1
  52. codex_autorunner/integrations/agents/__init__.py +2 -0
  53. codex_autorunner/integrations/agents/backend_orchestrator.py +18 -0
  54. codex_autorunner/integrations/agents/codex_backend.py +19 -8
  55. codex_autorunner/integrations/agents/runner.py +3 -8
  56. codex_autorunner/integrations/agents/wiring.py +8 -0
  57. codex_autorunner/integrations/telegram/adapter.py +1 -1
  58. codex_autorunner/integrations/telegram/config.py +1 -1
  59. codex_autorunner/integrations/telegram/doctor.py +228 -6
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +346 -58
  63. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  64. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +202 -45
  65. codex_autorunner/integrations/telegram/handlers/commands_spec.py +18 -7
  66. codex_autorunner/integrations/telegram/handlers/messages.py +34 -3
  67. codex_autorunner/integrations/telegram/helpers.py +1 -3
  68. codex_autorunner/integrations/telegram/runtime.py +9 -4
  69. codex_autorunner/integrations/telegram/service.py +30 -0
  70. codex_autorunner/integrations/telegram/state.py +38 -0
  71. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +10 -4
  72. codex_autorunner/integrations/telegram/transport.py +10 -3
  73. codex_autorunner/integrations/templates/__init__.py +27 -0
  74. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  75. codex_autorunner/server.py +2 -2
  76. codex_autorunner/static/agentControls.js +21 -5
  77. codex_autorunner/static/app.js +115 -11
  78. codex_autorunner/static/archive.js +274 -81
  79. codex_autorunner/static/archiveApi.js +21 -0
  80. codex_autorunner/static/chatUploads.js +137 -0
  81. codex_autorunner/static/constants.js +1 -1
  82. codex_autorunner/static/docChatCore.js +185 -13
  83. codex_autorunner/static/fileChat.js +68 -40
  84. codex_autorunner/static/fileboxUi.js +159 -0
  85. codex_autorunner/static/hub.js +46 -81
  86. codex_autorunner/static/index.html +303 -24
  87. codex_autorunner/static/messages.js +82 -4
  88. codex_autorunner/static/notifications.js +288 -0
  89. codex_autorunner/static/pma.js +1167 -0
  90. codex_autorunner/static/settings.js +3 -0
  91. codex_autorunner/static/streamUtils.js +57 -0
  92. codex_autorunner/static/styles.css +9141 -6742
  93. codex_autorunner/static/templateReposSettings.js +225 -0
  94. codex_autorunner/static/terminalManager.js +22 -3
  95. codex_autorunner/static/ticketChatActions.js +165 -3
  96. codex_autorunner/static/ticketChatStream.js +17 -119
  97. codex_autorunner/static/ticketEditor.js +41 -13
  98. codex_autorunner/static/ticketTemplates.js +798 -0
  99. codex_autorunner/static/tickets.js +69 -19
  100. codex_autorunner/static/turnEvents.js +27 -0
  101. codex_autorunner/static/turnResume.js +33 -0
  102. codex_autorunner/static/utils.js +28 -0
  103. codex_autorunner/static/workspace.js +258 -44
  104. codex_autorunner/static/workspaceFileBrowser.js +6 -4
  105. codex_autorunner/surfaces/cli/cli.py +1465 -155
  106. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  107. codex_autorunner/surfaces/web/app.py +253 -49
  108. codex_autorunner/surfaces/web/routes/__init__.py +4 -0
  109. codex_autorunner/surfaces/web/routes/analytics.py +29 -22
  110. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  111. codex_autorunner/surfaces/web/routes/file_chat.py +297 -36
  112. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  113. codex_autorunner/surfaces/web/routes/flows.py +219 -29
  114. codex_autorunner/surfaces/web/routes/messages.py +70 -39
  115. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  116. codex_autorunner/surfaces/web/routes/repos.py +1 -1
  117. codex_autorunner/surfaces/web/routes/shared.py +0 -3
  118. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  119. codex_autorunner/surfaces/web/runner_manager.py +2 -2
  120. codex_autorunner/surfaces/web/schemas.py +81 -18
  121. codex_autorunner/tickets/agent_pool.py +27 -0
  122. codex_autorunner/tickets/files.py +33 -16
  123. codex_autorunner/tickets/lint.py +50 -0
  124. codex_autorunner/tickets/models.py +3 -0
  125. codex_autorunner/tickets/outbox.py +41 -5
  126. codex_autorunner/tickets/runner.py +350 -69
  127. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/METADATA +15 -19
  128. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/RECORD +132 -101
  129. codex_autorunner/core/adapter_utils.py +0 -21
  130. codex_autorunner/core/engine.py +0 -3302
  131. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/WHEEL +0 -0
  132. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/entry_points.txt +0 -0
  133. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/licenses/LICENSE +0 -0
  134. {codex_autorunner-1.1.0.dist-info → codex_autorunner-1.2.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,137 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ import { api, flash, resolvePath } from "./utils.js";
3
+ const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif"];
4
+ const IMAGE_MIME_EXT = {
5
+ "image/png": "png",
6
+ "image/jpeg": "jpg",
7
+ "image/gif": "gif",
8
+ "image/webp": "webp",
9
+ "image/heic": "heic",
10
+ "image/heif": "heif",
11
+ };
12
+ function escapeMarkdownLinkText(text) {
13
+ return text.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
14
+ }
15
+ function toAbsoluteUrl(path) {
16
+ const resolved = resolvePath(path);
17
+ try {
18
+ return new URL(resolved, window.location.origin).toString();
19
+ }
20
+ catch {
21
+ return resolved;
22
+ }
23
+ }
24
+ function isImageFile(file) {
25
+ if (file.type && file.type.startsWith("image/"))
26
+ return true;
27
+ const lower = (file.name || "").toLowerCase();
28
+ return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(`.${ext}`));
29
+ }
30
+ function normalizeFilename(file, index, used) {
31
+ let name = (file.name || "").trim();
32
+ if (!name) {
33
+ const ext = IMAGE_MIME_EXT[file.type] || "png";
34
+ name = `pasted-image-${Date.now()}-${index + 1}.${ext}`;
35
+ }
36
+ let candidate = name;
37
+ let suffix = 1;
38
+ while (used.has(candidate)) {
39
+ const dot = name.lastIndexOf(".");
40
+ if (dot > 0) {
41
+ const base = name.slice(0, dot);
42
+ const ext = name.slice(dot + 1);
43
+ candidate = `${base}-${suffix}.${ext}`;
44
+ }
45
+ else {
46
+ candidate = `${name}-${suffix}`;
47
+ }
48
+ suffix += 1;
49
+ }
50
+ used.add(candidate);
51
+ return candidate;
52
+ }
53
+ function extractImageFilesFromClipboard(event) {
54
+ const items = event.clipboardData?.items;
55
+ if (!items || !items.length)
56
+ return [];
57
+ const files = [];
58
+ for (const item of Array.from(items)) {
59
+ if (item.kind !== "file")
60
+ continue;
61
+ if (item.type && !item.type.startsWith("image/"))
62
+ continue;
63
+ const file = item.getAsFile();
64
+ if (file && isImageFile(file))
65
+ files.push(file);
66
+ }
67
+ return files;
68
+ }
69
+ async function uploadImages(basePath, box, files, pathPrefix) {
70
+ const used = new Set();
71
+ const form = new FormData();
72
+ const entries = [];
73
+ const prefix = basePath.replace(/\/$/, "");
74
+ const normalizedPathPrefix = pathPrefix ? pathPrefix.replace(/\/$/, "") : "";
75
+ files.forEach((file, index) => {
76
+ const name = normalizeFilename(file, index, used);
77
+ form.append(name, file, name);
78
+ const path = normalizedPathPrefix ? `${normalizedPathPrefix}/${box}/${name}` : undefined;
79
+ const relativeUrl = `${prefix}/${box}/${encodeURIComponent(name)}`;
80
+ entries.push({ name, url: toAbsoluteUrl(relativeUrl), path });
81
+ });
82
+ await api(`${prefix}/${box}`, { method: "POST", body: form });
83
+ return entries;
84
+ }
85
+ function insertTextAtCursor(textarea, text, options = {}) {
86
+ const value = textarea.value || "";
87
+ const start = Number.isInteger(textarea.selectionStart) ? textarea.selectionStart : value.length;
88
+ const end = Number.isInteger(textarea.selectionEnd) ? textarea.selectionEnd : value.length;
89
+ const prefix = value.slice(0, start);
90
+ const suffix = value.slice(end);
91
+ const separator = options.separator || "newline";
92
+ let insert = text;
93
+ if (separator === "newline") {
94
+ insert = `${prefix && !prefix.endsWith("\n") ? "\n" : ""}${insert}`;
95
+ }
96
+ else if (separator === "space") {
97
+ insert = `${prefix && !/\s$/.test(prefix) ? " " : ""}${insert}`;
98
+ }
99
+ textarea.value = `${prefix}${insert}${suffix}`;
100
+ const cursor = prefix.length + insert.length;
101
+ textarea.setSelectionRange(cursor, cursor);
102
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
103
+ }
104
+ export function initChatPasteUpload(options) {
105
+ const { textarea } = options;
106
+ if (!textarea)
107
+ return;
108
+ textarea.addEventListener("paste", async (event) => {
109
+ const files = extractImageFilesFromClipboard(event);
110
+ if (!files.length)
111
+ return;
112
+ event.preventDefault();
113
+ const box = options.box || "inbox";
114
+ const insertStyle = options.insertStyle || "markdown";
115
+ try {
116
+ const entries = await uploadImages(options.basePath, box, files, options.pathPrefix);
117
+ const lines = entries.flatMap((entry) => {
118
+ const label = escapeMarkdownLinkText(entry.name);
119
+ const linkLine = `[${label}](${entry.url})`;
120
+ if (insertStyle === "markdown")
121
+ return [linkLine];
122
+ const pathLine = entry.path || entry.name;
123
+ if (insertStyle === "path")
124
+ return [pathLine];
125
+ return entry.path ? [linkLine, entry.path] : [linkLine];
126
+ });
127
+ if (lines.length) {
128
+ insertTextAtCursor(textarea, lines.join("\n"), { separator: "newline" });
129
+ }
130
+ options.onUploaded?.(entries.map((entry) => ({ name: entry.name, url: entry.url })));
131
+ }
132
+ catch (err) {
133
+ const message = err.message || "Image upload failed";
134
+ flash(message, "error");
135
+ }
136
+ });
137
+ }
@@ -37,7 +37,7 @@ export const CONSTANTS = {
37
37
  },
38
38
  PROMPTS: {
39
39
  VOICE_TRANSCRIPT_DISCLAIMER: "Note: transcribed from user voice. If confusing or possibly inaccurate and you cannot infer the intention please clarify before proceeding.",
40
- CAR_CONTEXT_HINT: "Context: read .codex-autorunner/ABOUT_CAR.md for repo-specific rules.",
40
+ CAR_CONTEXT_HINT: "Context: This repo is managed by Codex Autorunner (CAR). Read `.codex-autorunner/ABOUT_CAR.md` (tickets, workspace docs, helper commands) before making workflow assumptions.",
41
41
  },
42
42
  KEYWORDS: {
43
43
  CAR_CONTEXT: [
@@ -2,6 +2,7 @@
2
2
  import { parseAppServerEvent } from "./agentEvents.js";
3
3
  import { summarizeEvents, renderCompactSummary, COMPACT_MAX_ACTIONS, COMPACT_MAX_TEXT_LENGTH } from "./eventSummarizer.js";
4
4
  import { saveChatHistory, loadChatHistory } from "./docChatStorage.js";
5
+ import { renderMarkdown } from "./messages.js";
5
6
  function getElements(prefix) {
6
7
  return {
7
8
  input: document.getElementById(`${prefix}-input`),
@@ -33,13 +34,14 @@ function addEvent(state, entry, limits) {
33
34
  });
34
35
  }
35
36
  }
36
- function buildMessage(role, content, isFinal) {
37
+ function buildMessage(role, content, isFinal, meta) {
37
38
  return {
38
39
  id: `${role}-${Date.now()}`,
39
40
  role,
40
41
  content,
41
42
  time: new Date().toISOString(),
42
43
  isFinal,
44
+ meta,
43
45
  };
44
46
  }
45
47
  export function createDocChat(config) {
@@ -52,11 +54,31 @@ export function createDocChat(config) {
52
54
  controller: null,
53
55
  draft: null,
54
56
  events: [],
57
+ totalEvents: 0,
55
58
  messages: [],
56
59
  eventItemIndex: {},
57
60
  eventsExpanded: false,
61
+ contextUsagePercent: null,
58
62
  };
59
63
  const elements = getElements(config.idPrefix);
64
+ function decorateFileLinks(root) {
65
+ const links = Array.from(root.querySelectorAll("a"));
66
+ for (const link of links) {
67
+ const href = link.getAttribute("href") || "";
68
+ if (!href)
69
+ continue;
70
+ // Only decorate PMA file links.
71
+ if (!href.includes("/hub/pma/files/"))
72
+ continue;
73
+ link.classList.add("pma-file-link");
74
+ link.setAttribute("download", "");
75
+ // Ensure downloads happen in-place (no new tab).
76
+ link.removeAttribute("target");
77
+ link.setAttribute("rel", "noopener");
78
+ if (!link.title)
79
+ link.title = "Download file";
80
+ }
81
+ }
60
82
  function saveHistory() {
61
83
  if (!config.storage || !state.target)
62
84
  return;
@@ -79,17 +101,21 @@ export function createDocChat(config) {
79
101
  state.messages.push(buildMessage("user", content, true));
80
102
  saveHistory();
81
103
  }
82
- function addAssistantMessage(content, isFinal = true) {
104
+ function addAssistantMessage(content, isFinal = true, meta) {
83
105
  if (!content)
84
106
  return;
85
107
  const last = state.messages[state.messages.length - 1];
86
- if (last && last.role === "assistant" && last.content === content)
108
+ if (last && last.role === "assistant" && last.content === content) {
109
+ if (meta)
110
+ last.meta = meta;
87
111
  return;
88
- state.messages.push(buildMessage("assistant", content, isFinal));
112
+ }
113
+ state.messages.push(buildMessage("assistant", content, isFinal, meta));
89
114
  saveHistory();
90
115
  }
91
116
  function clearEvents() {
92
117
  state.events = [];
118
+ state.totalEvents = 0;
93
119
  state.eventItemIndex = {};
94
120
  }
95
121
  function applyAppEvent(payload) {
@@ -111,6 +137,7 @@ export function createDocChat(config) {
111
137
  return;
112
138
  }
113
139
  addEvent(state, { ...event }, config.limits);
140
+ state.totalEvents += 1;
114
141
  if (itemId)
115
142
  state.eventItemIndex[itemId] = state.events.length - 1;
116
143
  }
@@ -118,18 +145,33 @@ export function createDocChat(config) {
118
145
  const { eventsMain, eventsList, eventsCount, eventsToggle } = elements;
119
146
  if (!eventsMain || !eventsList || !eventsCount)
120
147
  return;
148
+ // If inlineEvents is enabled, we don't render to the separate events container
149
+ if (config.inlineEvents) {
150
+ // Still need to calculate showEvents to hide the container properly
151
+ // but return early before modifying innerHTML
152
+ if (eventsMain)
153
+ eventsMain.classList.add("hidden");
154
+ return;
155
+ }
121
156
  const hasEvents = state.events.length > 0;
122
157
  const isRunning = state.status === "running";
123
- const showEvents = hasEvents || isRunning;
158
+ const showEvents = config.eventsOnlyWhileRunning ? isRunning : (hasEvents || isRunning);
124
159
  const compactMode = !!config.compactMode;
125
160
  const expanded = !!state.eventsExpanded;
126
161
  if (config.styling.eventsHiddenClass) {
127
162
  eventsMain.classList.toggle(config.styling.eventsHiddenClass, !showEvents);
128
163
  }
129
164
  else {
130
- eventsMain.classList.toggle("hidden", !showEvents);
165
+ // In inline mode, never show the main event container since we render inline
166
+ if (config.inlineEvents) {
167
+ eventsMain.classList.add("hidden");
168
+ }
169
+ else {
170
+ eventsMain.classList.toggle("hidden", !showEvents);
171
+ }
131
172
  }
132
- eventsCount.textContent = String(state.events.length);
173
+ const eventCount = state.totalEvents || state.events.length;
174
+ eventsCount.textContent = String(eventCount);
133
175
  if (!showEvents) {
134
176
  eventsList.innerHTML = "";
135
177
  return;
@@ -202,6 +244,7 @@ export function createDocChat(config) {
202
244
  const summary = summarizeEvents(state.events, {
203
245
  maxActions: config.compactOptions?.maxActions ?? COMPACT_MAX_ACTIONS,
204
246
  maxTextLength: config.compactOptions?.maxTextLength ?? COMPACT_MAX_TEXT_LENGTH,
247
+ contextUsagePercent: state.contextUsagePercent ?? undefined,
205
248
  });
206
249
  const text = state.events.length ? renderCompactSummary(summary) : "";
207
250
  const wrapper = document.createElement("pre");
@@ -240,17 +283,45 @@ export function createDocChat(config) {
240
283
  }
241
284
  wrapper.appendChild(roleLabel);
242
285
  const content = document.createElement("div");
243
- content.className = config.styling.messageContentClass;
244
- content.textContent = msg.content;
286
+ content.className = `${config.styling.messageContentClass} messages-markdown`;
287
+ // Use markdown rendering for assistant messages.
288
+ // For user messages, keep plain text unless the message includes PMA file links
289
+ // (used for "uploaded file" pills).
290
+ const shouldRenderMarkdown = msg.role === "assistant" ||
291
+ msg.content.includes("/hub/pma/files/") ||
292
+ msg.content.includes("/api/filebox/") ||
293
+ msg.content.includes("/hub/filebox/");
294
+ if (shouldRenderMarkdown) {
295
+ content.innerHTML = renderMarkdown(msg.content);
296
+ decorateFileLinks(content);
297
+ }
298
+ else {
299
+ content.textContent = msg.content;
300
+ }
245
301
  wrapper.appendChild(content);
246
302
  const meta = document.createElement("div");
247
303
  meta.className = config.styling.messageMetaClass;
248
304
  const time = msg.time ? new Date(msg.time) : new Date();
249
- meta.textContent = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
305
+ let metaText = time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
306
+ if (msg.meta) {
307
+ const parts = [];
308
+ if (msg.meta.steps)
309
+ parts.push(`${msg.meta.steps} steps`);
310
+ if (msg.meta.duration)
311
+ parts.push(`${msg.meta.duration.toFixed(1)}s`);
312
+ if (state.contextUsagePercent !== null && msg.isFinal) {
313
+ parts.push(`ctx left ${state.contextUsagePercent}%`);
314
+ }
315
+ if (parts.length)
316
+ metaText += ` · ${parts.join(" · ")}`;
317
+ }
318
+ meta.textContent = metaText;
250
319
  wrapper.appendChild(meta);
251
320
  messagesEl.appendChild(wrapper);
252
321
  });
253
- if (hasStream) {
322
+ // While running, show an inline "Thinking" bubble at the bottom where the
323
+ // final assistant message will appear (even if we don't have streamed text yet).
324
+ if (hasStream || state.status === "running") {
254
325
  const streaming = document.createElement("div");
255
326
  streaming.className = [
256
327
  config.styling.messagesClass,
@@ -265,12 +336,100 @@ export function createDocChat(config) {
265
336
  roleLabel.textContent = "Thinking";
266
337
  streaming.appendChild(roleLabel);
267
338
  const content = document.createElement("div");
268
- content.className = config.styling.messageContentClass;
269
- content.textContent = state.streamText;
339
+ content.className = `${config.styling.messageContentClass} messages-markdown`;
340
+ // If we have streamed text, show it. Otherwise show a compact "working" summary
341
+ // based on the most recent event/tool-call.
342
+ if (state.streamText) {
343
+ content.innerHTML = renderMarkdown(state.streamText);
344
+ decorateFileLinks(content);
345
+ }
346
+ else {
347
+ const stepCount = state.totalEvents || state.events.length;
348
+ const statusText = (state.statusText || "").trim();
349
+ const isNoiseEvent = (evt) => {
350
+ const title = (evt.title || "").toLowerCase();
351
+ const method = (evt.method || "").toLowerCase();
352
+ // Hide token/partial deltas; they are too granular for the UI.
353
+ if (title === "delta")
354
+ return true;
355
+ if (method.includes("delta"))
356
+ return true;
357
+ return false;
358
+ };
359
+ const meaningfulEvents = state.events.filter((evt) => !isNoiseEvent(evt));
360
+ const lastMeaningful = meaningfulEvents[meaningfulEvents.length - 1];
361
+ const headline = lastMeaningful
362
+ ? (lastMeaningful.title || lastMeaningful.summary || statusText || "Working...")
363
+ : (statusText || "Thinking...");
364
+ // Build DOM so we can attach a "Show details" toggle inside the Thinking bubble.
365
+ content.innerHTML = "";
366
+ const header = document.createElement("div");
367
+ header.className = "chat-thinking-inline";
368
+ const spinner = document.createElement("span");
369
+ spinner.className = "chat-thinking-spinner";
370
+ header.appendChild(spinner);
371
+ const headlineSpan = document.createElement("span");
372
+ headlineSpan.textContent = String(headline);
373
+ header.appendChild(headlineSpan);
374
+ if (stepCount > 0) {
375
+ const steps = document.createElement("span");
376
+ steps.className = "chat-thinking-steps";
377
+ steps.textContent = `(${stepCount} steps)`;
378
+ header.appendChild(steps);
379
+ if (state.contextUsagePercent !== null) {
380
+ const context = document.createElement("span");
381
+ context.className = "chat-thinking-steps";
382
+ context.textContent = ` · ctx left ${state.contextUsagePercent}%`;
383
+ header.appendChild(context);
384
+ }
385
+ // Only show the toggle if we have more than a couple steps.
386
+ if (meaningfulEvents.length > 2) {
387
+ const toggle = document.createElement("button");
388
+ toggle.type = "button";
389
+ toggle.className = "ghost sm chat-thinking-details-btn";
390
+ toggle.textContent = state.eventsExpanded ? "Hide details" : "Show details";
391
+ toggle.addEventListener("click", (e) => {
392
+ e.preventDefault();
393
+ state.eventsExpanded = !state.eventsExpanded;
394
+ renderMessages();
395
+ });
396
+ header.appendChild(toggle);
397
+ }
398
+ }
399
+ content.appendChild(header);
400
+ const maxRecent = state.eventsExpanded
401
+ ? Math.min(meaningfulEvents.length, config.limits.eventVisible || 20)
402
+ : 3;
403
+ const recentEvents = meaningfulEvents.slice(-maxRecent);
404
+ if (recentEvents.length) {
405
+ const list = document.createElement("ul");
406
+ list.className = "chat-thinking-steps-list";
407
+ for (const evt of recentEvents) {
408
+ const li = document.createElement("li");
409
+ const title = document.createElement("span");
410
+ title.className = "chat-thinking-step-title";
411
+ title.textContent = (evt.title || evt.kind || evt.method || "step").trim();
412
+ li.appendChild(title);
413
+ const summaryText = (evt.summary || "").trim();
414
+ if (summaryText) {
415
+ const summary = document.createElement("span");
416
+ summary.className = "chat-thinking-step-summary";
417
+ summary.textContent = ` — ${summaryText}`;
418
+ li.appendChild(summary);
419
+ }
420
+ list.appendChild(li);
421
+ }
422
+ content.appendChild(list);
423
+ }
424
+ }
270
425
  streaming.appendChild(content);
271
426
  messagesEl.appendChild(streaming);
272
427
  }
273
428
  messagesEl.scrollTop = messagesEl.scrollHeight;
429
+ // Also scroll the parent container if it exists
430
+ if (elements.streamEl) {
431
+ elements.streamEl.scrollTop = elements.streamEl.scrollHeight;
432
+ }
274
433
  }
275
434
  function render() {
276
435
  const { statusEl, errorEl, cancelBtn, newThreadBtn, streamEl, } = elements;
@@ -292,12 +451,25 @@ export function createDocChat(config) {
292
451
  newThreadBtn.classList.toggle("hidden", !hasHistory || state.status === "running");
293
452
  }
294
453
  if (streamEl) {
454
+ // In inline mode, we always want to show the stream element if there's any activity
455
+ // or history, because the "Thinking" state is rendered as a message in the history list
456
+ // (technically in the messagesEl container), but we need the parent container visible.
295
457
  const hasContent = state.events.length > 0 ||
296
458
  state.messages.length > 0 ||
297
459
  !!state.streamText ||
298
460
  state.status === "running";
299
461
  streamEl.classList.toggle("hidden", !hasContent);
462
+ // Auto-scroll to bottom when new content appears
463
+ streamEl.scrollTop = streamEl.scrollHeight;
300
464
  }
465
+ // Important: renderMessages handles the "Thinking" bubble creation
466
+ // when state.status === 'running' or we have a streamText.
467
+ // However, if we only have events but no streamText yet, we need to ensure
468
+ // renderMessages is called with a "virtual" stream state to trigger the bubble.
469
+ // We do this by checking if we are running.
470
+ // We need to pass a flag or rely on state.status in renderMessages?
471
+ // Actually renderMessages uses state.streamText.
472
+ // Let's force a "pending" indicator in renderMessages if running but no text.
301
473
  renderEvents();
302
474
  renderMessages();
303
475
  }
@@ -1,16 +1,8 @@
1
1
  // GENERATED FILE - do not edit directly. Source: static_src/
2
2
  import { resolvePath, getAuthToken, api } from "./utils.js";
3
- const decoder = new TextDecoder();
4
- function parseMaybeJson(data) {
5
- try {
6
- return JSON.parse(data);
7
- }
8
- catch {
9
- return data;
10
- }
11
- }
3
+ import { extractContextRemainingPercent, readEventStream, parseMaybeJson } from "./streamUtils.js";
12
4
  export async function sendFileChat(target, message, controller, handlers = {}, options = {}) {
13
- const endpoint = resolvePath("/api/file-chat");
5
+ const endpoint = resolvePath(options.basePath || "/api/file-chat");
14
6
  const headers = {
15
7
  "Content-Type": "application/json",
16
8
  };
@@ -22,6 +14,8 @@ export async function sendFileChat(target, message, controller, handlers = {}, o
22
14
  message,
23
15
  stream: true,
24
16
  };
17
+ if (options.clientTurnId)
18
+ payload.client_turn_id = options.clientTurnId;
25
19
  if (options.agent)
26
20
  payload.agent = options.agent;
27
21
  if (options.model)
@@ -58,36 +52,7 @@ export async function sendFileChat(target, message, controller, handlers = {}, o
58
52
  }
59
53
  }
60
54
  async function readFileChatStream(res, handlers) {
61
- if (!res.body)
62
- throw new Error("Streaming not supported in this browser");
63
- const reader = res.body.getReader();
64
- let buffer = "";
65
- for (;;) {
66
- const { value, done } = await reader.read();
67
- if (done)
68
- break;
69
- buffer += decoder.decode(value, { stream: true });
70
- const chunks = buffer.split("\n\n");
71
- buffer = chunks.pop() || "";
72
- for (const chunk of chunks) {
73
- if (!chunk.trim())
74
- continue;
75
- let event = "message";
76
- const dataLines = [];
77
- chunk.split("\n").forEach((line) => {
78
- if (line.startsWith("event:")) {
79
- event = line.slice(6).trim();
80
- }
81
- else if (line.startsWith("data:")) {
82
- dataLines.push(line.slice(5).trimStart());
83
- }
84
- });
85
- if (!dataLines.length)
86
- continue;
87
- const rawData = dataLines.join("\n");
88
- handleStreamEvent(event, rawData, handlers);
89
- }
90
- }
55
+ await readEventStream(res, (event, raw) => handleStreamEvent(event, raw, handlers));
91
56
  }
92
57
  function handleStreamEvent(event, rawData, handlers) {
93
58
  const parsed = parseMaybeJson(rawData);
@@ -104,6 +69,16 @@ function handleStreamEvent(event, rawData, handlers) {
104
69
  handlers.onToken?.(token);
105
70
  break;
106
71
  }
72
+ case "token_usage": {
73
+ if (typeof parsed === "object" && parsed !== null) {
74
+ const usage = parsed;
75
+ const percent = extractContextRemainingPercent(usage);
76
+ if (percent !== null) {
77
+ handlers.onTokenUsage?.(percent, usage);
78
+ }
79
+ }
80
+ break;
81
+ }
107
82
  case "update": {
108
83
  handlers.onUpdate?.(parsed);
109
84
  break;
@@ -180,3 +155,56 @@ export async function discardDraft(target) {
180
155
  export async function interruptFileChat(target) {
181
156
  await api("/api/file-chat/interrupt", { method: "POST", body: { target } });
182
157
  }
158
+ export function newClientTurnId(prefix = "filechat") {
159
+ try {
160
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto && typeof crypto.randomUUID === "function") {
161
+ return crypto.randomUUID();
162
+ }
163
+ }
164
+ catch {
165
+ // ignore
166
+ }
167
+ return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
168
+ }
169
+ export async function fetchActiveFileChat(clientTurnId, basePath = "/api/file-chat/active") {
170
+ const suffix = clientTurnId ? `?client_turn_id=${encodeURIComponent(clientTurnId)}` : "";
171
+ const path = `${basePath}${suffix}`;
172
+ try {
173
+ const res = (await api(path));
174
+ return res || {};
175
+ }
176
+ catch {
177
+ return {};
178
+ }
179
+ }
180
+ export function streamTurnEvents(meta, handlers = {}) {
181
+ if (!meta.threadId || !meta.turnId)
182
+ return null;
183
+ const ctrl = new AbortController();
184
+ const token = getAuthToken();
185
+ const headers = {};
186
+ if (token)
187
+ headers.Authorization = `Bearer ${token}`;
188
+ const url = resolvePath(`${meta.basePath || "/api/file-chat/turns"}/${encodeURIComponent(meta.turnId)}/events?thread_id=${encodeURIComponent(meta.threadId)}&agent=${encodeURIComponent(meta.agent || "codex")}`);
189
+ void (async () => {
190
+ try {
191
+ const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
192
+ if (!res.ok) {
193
+ handlers.onError?.("Failed to stream events");
194
+ return;
195
+ }
196
+ const contentType = res.headers.get("content-type") || "";
197
+ if (!contentType.includes("text/event-stream"))
198
+ return;
199
+ await readEventStream(res, (event, raw) => {
200
+ if (event === "app-server" || event === "event") {
201
+ handlers.onEvent?.(parseMaybeJson(raw));
202
+ }
203
+ });
204
+ }
205
+ catch (err) {
206
+ handlers.onError?.(err.message || "Event stream failed");
207
+ }
208
+ })();
209
+ return ctrl;
210
+ }