@orxataguy/tyr 1.0.35 → 1.0.36

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.
package/bin/tyr.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.35",
3
+ "version": "1.0.36",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -292,7 +292,11 @@ interface FilePatchBlock {
292
292
  }
293
293
 
294
294
  const FILE_BLOCK_REGEX = />>>FILE:\s*(.+?)\s*\n([\s\S]*?)\n<<<END/g;
295
- const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
295
+ // The `\n?` before `=======` (rather than a mandatory `\n`) matters for brand-new files: the
296
+ // model is told to leave the SEARCH section EMPTY, and it naturally writes "SEARCH\n======="
297
+ // with no blank line in between rather than "SEARCH\n\n=======" — a mandatory `\n` there made
298
+ // every new-file creation silently produce zero matches (no blocks, "no changes generated").
299
+ const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
296
300
 
297
301
  /**
298
302
  * @class AIContextManager
@@ -576,13 +576,28 @@ export class AIVendorManager {
576
576
  };
577
577
  }
578
578
 
579
+ /** True for OpenAI's reasoning-capable model families ('o' models: o1, o3... and the gpt-5
580
+ * line), which share two API quirks handled below: they reject the legacy `max_tokens` body
581
+ * field (Use `max_completion_tokens` instead — see buildRequest) and, when reasoning is on,
582
+ * reject a custom `temperature`. */
583
+ private isOpenAIReasoningModel(model: string): boolean {
584
+ return /^(o\d|gpt-5)/i.test(model);
585
+ }
586
+
579
587
  /** OpenAI's `reasoning_effort` only exists on the 'o' reasoning model family and only accepts
580
588
  * a fixed vocabulary (no 'none') — mapped from our vendor-agnostic ThinkingEffort via
581
589
  * OPENAI_REASONING_EFFORTS/_ENV. Those models also reject a custom `temperature`, so it's
582
- * omitted whenever reasoning_effort is set. */
583
- private openaiReasoningFields(config: VendorConfig): { reasoning_effort?: string; temperature?: number } {
584
- const isReasoningModel = /^o\d/.test(config.model);
585
- if (!isReasoningModel || config.thinking === 'none') return { temperature: config.temperature };
590
+ * omitted whenever reasoning_effort is set.
591
+ *
592
+ * `reasoning_effort` combined with function `tools` on /v1/chat/completions is itself
593
+ * rejected with a 400 ("...are not supported... Please use /v1/responses instead") on at
594
+ * least the gpt-5 nano tier — so it's omitted whenever tools are present, trading explicit
595
+ * effort control for the request actually working; the model still reasons, just without an
596
+ * explicit tier hint. */
597
+ private openaiReasoningFields(config: VendorConfig, hasTools: boolean): { reasoning_effort?: string; temperature?: number } {
598
+ if (!this.isOpenAIReasoningModel(config.model) || config.thinking === 'none' || hasTools) {
599
+ return { temperature: config.temperature };
600
+ }
586
601
 
587
602
  return { reasoning_effort: resolveOpenAIReasoningEffort(config.thinking) };
588
603
  }
@@ -622,7 +637,12 @@ export class AIVendorManager {
622
637
  }
623
638
 
624
639
  case 'openai': {
625
- const reasoningFields = this.openaiReasoningFields(config);
640
+ const reasoningFields = this.openaiReasoningFields(config, !!vendorTools);
641
+ // Reasoning-family models (o1/o3/gpt-5...) reject the legacy `max_tokens` field
642
+ // with a 400 and require `max_completion_tokens` instead.
643
+ const tokenField = this.isOpenAIReasoningModel(config.model)
644
+ ? { max_completion_tokens: config.maxTokens }
645
+ : { max_tokens: config.maxTokens };
626
646
  return {
627
647
  url: 'https://api.openai.com/v1/chat/completions',
628
648
  headers: {
@@ -635,7 +655,7 @@ export class AIVendorManager {
635
655
  ...(system ? [{ role: 'system', content: system }] : []),
636
656
  ...this.buildOpenAIMessages(turns),
637
657
  ],
638
- max_tokens: config.maxTokens,
658
+ ...tokenField,
639
659
  stream,
640
660
  ...reasoningFields,
641
661
  ...(stream ? { stream_options: { include_usage: true } } : {}),
@@ -53,6 +53,13 @@ export interface ChatMessageContext {
53
53
  /** Full conversation so far, including the message that triggered this call. */
54
54
  history: ChatMessage[];
55
55
  dir: string;
56
+ /** Id of the session this message belongs to — pass it to addToContext()/removeFromContext()
57
+ * /getContext() to read or grow the "context of interest" file set while producing a reply. */
58
+ sessionId: string;
59
+ /** Snapshot (sorted, relative to `dir`) of the context-of-interest file set at the moment the
60
+ * handler was invoked. Mutations made via addToContext() during the handler are NOT reflected
61
+ * here — call getContext(sessionId) again for the live set. */
62
+ context: string[];
56
63
  }
57
64
 
58
65
  /** Function that produces the assistant's reply text for a user message. Registered via
@@ -65,7 +72,8 @@ export type ChatEventName =
65
72
  | 'message:send'
66
73
  | 'message:response'
67
74
  | 'message:error'
68
- | 'file:select';
75
+ | 'file:select'
76
+ | 'context:change';
69
77
 
70
78
  interface InternalSession {
71
79
  id: string;
@@ -77,6 +85,11 @@ interface InternalSession {
77
85
  attachments: Map<string, ChatAttachment>;
78
86
  splitRatio: number;
79
87
  title: string;
88
+ /** "Context of interest" — relative (forward-slash) paths of files the conversation is
89
+ * currently grounded on: added by a double-click in the file browser, or programmatically
90
+ * via addToContext() (e.g. a command detecting a file mention, or the AI itself asking for
91
+ * one through a tool). Highlighted in the file browser (see /api/context). */
92
+ contextFiles: Set<string>;
80
93
  }
81
94
 
82
95
  const DEFAULT_PORT = 4646;
@@ -118,6 +131,14 @@ function mimeFromExt(ext: string): string {
118
131
  *
119
132
  * Attached images are written to a per-session temp directory (via os.tmpdir()) that is removed
120
133
  * when the session is stopped or the process exits (SIGINT/SIGTERM/exit are all hooked).
134
+ *
135
+ * Each session also tracks a "context of interest" file set (addToContext() / removeFromContext()
136
+ * / toggleContext() / getContext()) — files the file browser highlights and that ChatMessageContext
137
+ * exposes to the onMessage handler, so a caller can ground replies on specific files without the
138
+ * model having to re-discover them every turn. The manager only tracks *which* paths are of
139
+ * interest and validates them (must exist, must be a file, must stay inside `dir`); reading their
140
+ * content and deciding what counts as "mentioned" is left to the onMessage handler, same division
141
+ * of responsibility as AI content generation itself.
121
142
  */
122
143
  export class ChatManager {
123
144
  private fsManager: FileSystemManager;
@@ -178,6 +199,110 @@ export class ChatManager {
178
199
  this.emitter.off(event, listener);
179
200
  }
180
201
 
202
+ private normalizeRelPath(relPath: string): string {
203
+ return (relPath || '')
204
+ .replace(/\\/g, '/')
205
+ .replace(/^(\.\/)+/, '')
206
+ .replace(/^\/+/, '')
207
+ .replace(/\/+$/, '');
208
+ }
209
+
210
+ /**
211
+ * @method getContext
212
+ * @description Returns the session's current "context of interest" — relative paths, sorted.
213
+ * @param {string} sessionId - The id of the session (see ChatMessageContext.sessionId).
214
+ * @returns {string[]} Sorted relative paths.
215
+ * @example
216
+ * const files = chat.getContext(sessionId);
217
+ */
218
+ public getContext(sessionId: string): string[] {
219
+ const session = this.sessions.get(sessionId);
220
+ if (!session) return [];
221
+ return [...session.contextFiles].sort();
222
+ }
223
+
224
+ /**
225
+ * @method addToContext
226
+ * @description Adds one or more files to the session's context of interest. Each path is
227
+ * validated (must resolve inside the session's directory, must exist, must be a file) and
228
+ * silently skipped if it fails — invalid input never throws, since callers (mention detection,
229
+ * an AI tool call) work off untrusted text. Fires 'context:change' once if anything was added.
230
+ * @param {string} sessionId - The id of the session.
231
+ * @param {string[]} relPaths - Paths relative to the session's directory.
232
+ * @returns {Promise<string[]>} The subset of paths that were actually added (newly, validly).
233
+ * @example
234
+ * const added = await chat.addToContext(sessionId, ['src/index.ts']);
235
+ */
236
+ public async addToContext(sessionId: string, relPaths: string[]): Promise<string[]> {
237
+ const session = this.sessions.get(sessionId);
238
+ if (!session) return [];
239
+
240
+ const added: string[] = [];
241
+ for (const raw of relPaths) {
242
+ const normalized = this.normalizeRelPath(raw);
243
+ if (!normalized || session.contextFiles.has(normalized)) continue;
244
+
245
+ let abs: string;
246
+ try {
247
+ abs = this.resolveSafe(session, normalized);
248
+ } catch {
249
+ continue;
250
+ }
251
+ if (!fsSync.existsSync(abs) || !fsSync.statSync(abs).isFile()) continue;
252
+
253
+ session.contextFiles.add(normalized);
254
+ added.push(normalized);
255
+ }
256
+
257
+ if (added.length > 0) {
258
+ await this.emitSafe('context:change', { sessionId, dir: session.dir, files: this.getContext(sessionId) });
259
+ }
260
+ return added;
261
+ }
262
+
263
+ /**
264
+ * @method removeFromContext
265
+ * @description Removes a single file from the session's context of interest, if present.
266
+ * Fires 'context:change' when it actually removes something.
267
+ * @param {string} sessionId - The id of the session.
268
+ * @param {string} relPath - Path relative to the session's directory.
269
+ * @example
270
+ * await chat.removeFromContext(sessionId, 'src/index.ts');
271
+ */
272
+ public async removeFromContext(sessionId: string, relPath: string): Promise<void> {
273
+ const session = this.sessions.get(sessionId);
274
+ if (!session) return;
275
+
276
+ const normalized = this.normalizeRelPath(relPath);
277
+ if (session.contextFiles.delete(normalized)) {
278
+ await this.emitSafe('context:change', { sessionId, dir: session.dir, files: this.getContext(sessionId) });
279
+ }
280
+ }
281
+
282
+ /**
283
+ * @method toggleContext
284
+ * @description Adds `relPath` to the context of interest if absent, removes it if present —
285
+ * the file browser's double-click behaviour. Same validation as addToContext() on the add path.
286
+ * @param {string} sessionId - The id of the session.
287
+ * @param {string} relPath - Path relative to the session's directory.
288
+ * @returns {Promise<{ added: boolean; files: string[] }>} Whether the path ended up added, and the resulting set.
289
+ * @example
290
+ * const { added } = await chat.toggleContext(sessionId, 'src/index.ts');
291
+ */
292
+ public async toggleContext(sessionId: string, relPath: string): Promise<{ added: boolean; files: string[] }> {
293
+ const session = this.sessions.get(sessionId);
294
+ if (!session) return { added: false, files: [] };
295
+
296
+ const normalized = this.normalizeRelPath(relPath);
297
+ if (session.contextFiles.has(normalized)) {
298
+ await this.removeFromContext(sessionId, normalized);
299
+ return { added: false, files: this.getContext(sessionId) };
300
+ }
301
+
302
+ const added = await this.addToContext(sessionId, [normalized]);
303
+ return { added: added.length > 0, files: this.getContext(sessionId) };
304
+ }
305
+
181
306
  private async emitSafe(event: ChatEventName, payload: any): Promise<void> {
182
307
  for (const listener of this.emitter.listeners(event)) {
183
308
  try {
@@ -244,6 +369,7 @@ export class ChatManager {
244
369
  attachments: new Map(),
245
370
  splitRatio,
246
371
  title,
372
+ contextFiles: new Set(),
247
373
  };
248
374
 
249
375
  const server = http.createServer((req, res) => {
@@ -380,6 +506,20 @@ export class ChatManager {
380
506
  return;
381
507
  }
382
508
 
509
+ if (pathname === '/api/context' && req.method === 'GET') {
510
+ this.sendJson(res, 200, { files: this.getContext(session.id) });
511
+ return;
512
+ }
513
+
514
+ if (pathname === '/api/context/toggle' && req.method === 'POST') {
515
+ const body = await this.readJsonBody(req);
516
+ const relPath = typeof body?.path === 'string' ? body.path : '';
517
+ if (!relPath) throw new TyrError('Missing path', null, 'Send { path } to /api/context/toggle.');
518
+ const result = await this.toggleContext(session.id, relPath);
519
+ this.sendJson(res, 200, result);
520
+ return;
521
+ }
522
+
383
523
  if (pathname === '/api/tree' && req.method === 'GET') {
384
524
  const rel = url.searchParams.get('path') ?? '';
385
525
  const entries = await this.listChildren(session, rel);
@@ -417,7 +557,7 @@ export class ChatManager {
417
557
  if (pathname === '/api/message' && req.method === 'POST') {
418
558
  const body = await this.readJsonBody(req);
419
559
  const message = await this.handleMessage(session, body);
420
- this.sendJson(res, 200, { message });
560
+ this.sendJson(res, 200, { message, context: this.getContext(session.id) });
421
561
  return;
422
562
  }
423
563
 
@@ -555,7 +695,13 @@ export class ChatManager {
555
695
 
556
696
  try {
557
697
  const handler = this.messageHandler ?? this.defaultHandler;
558
- const replyText = await handler({ message: userMessage, history: session.history, dir: session.dir });
698
+ const replyText = await handler({
699
+ message: userMessage,
700
+ history: session.history,
701
+ dir: session.dir,
702
+ sessionId: session.id,
703
+ context: this.getContext(session.id),
704
+ });
559
705
 
560
706
  const assistantMessage: ChatMessage = {
561
707
  id: crypto.randomUUID(),
@@ -599,10 +745,22 @@ export class ChatManager {
599
745
  #divider:hover { background: #4db8ff; }
600
746
  header { padding: 12px 16px; border-bottom: 1px solid #333; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
601
747
  #messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
602
- .msg { max-width: 85%; padding: 10px 14px; border-radius: 10px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; font-size: 14px; }
748
+ .msg { max-width: 85%; padding: 10px 14px; border-radius: 10px; line-height: 1.45; word-break: break-word; font-size: 14px; }
603
749
  .msg.user { align-self: flex-end; background: #2563eb22; border: 1px solid #2563eb55; }
604
750
  .msg.assistant { align-self: flex-start; background: #2d2d2d; border: 1px solid #3a3a3a; }
605
751
  .msg.error { align-self: flex-start; background: #4d1f1f; border: 1px solid #7a2b2b; color: #ffb4b4; }
752
+ .msg > *:first-child { margin-top: 0; }
753
+ .msg > *:last-child { margin-bottom: 0; }
754
+ .msg p { margin: 0 0 8px 0; }
755
+ .msg h1, .msg h2, .msg h3, .msg h4, .msg h5, .msg h6 { margin: 10px 0 6px 0; line-height: 1.3; }
756
+ .msg ul, .msg ol { margin: 4px 0 8px 22px; padding: 0; }
757
+ .msg li { margin: 2px 0; }
758
+ .msg pre { background: #111; padding: 8px 10px; border-radius: 6px; overflow-x: auto; margin: 6px 0; }
759
+ .msg code { background: #00000055; padding: 1px 4px; border-radius: 4px; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.9em; }
760
+ .msg pre code { background: none; padding: 0; }
761
+ .msg blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid #555; color: #aaa; }
762
+ .msg hr { border: none; border-top: 1px solid #444; margin: 10px 0; }
763
+ .msg a { color: #4db8ff; }
606
764
  .attachments { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
607
765
  .attachments img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid #444; }
608
766
  #compose { border-top: 1px solid #333; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
@@ -620,6 +778,9 @@ export class ChatManager {
620
778
  #tree { flex: 1; overflow-y: auto; padding: 8px; }
621
779
  .tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; }
622
780
  .tree-row:hover { background: #262626; }
781
+ .tree-row.in-context { background: #4db8ff26; border: 1px solid #4db8ff88; }
782
+ .tree-row.in-context:hover { background: #4db8ff3a; }
783
+ .tree-row.in-context::after { content: '📌'; margin-left: 4px; opacity: 0.85; }
623
784
  .tree-children { margin-left: 16px; display: none; }
624
785
  .tree-children.open { display: block; }
625
786
  #preview { border-top: 1px solid #333; max-height: 45%; overflow: auto; padding: 12px; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12.5px; }
@@ -652,7 +813,7 @@ export class ChatManager {
652
813
  <script>
653
814
  const BOOTSTRAP = ${bootstrap};
654
815
  (function () {
655
- const state = { pending: [], treeCache: {} };
816
+ const state = { pending: [], treeCache: {}, context: new Set() };
656
817
 
657
818
  const app = document.getElementById('app');
658
819
  const messagesEl = document.getElementById('messages');
@@ -689,10 +850,130 @@ const BOOTSTRAP = ${bootstrap};
689
850
  });
690
851
  }
691
852
 
853
+ // Minimal, dependency-free Markdown renderer. The whole input is HTML-escaped FIRST, and every
854
+ // tag below is introduced by this code afterwards — untrusted text (from the model or the user)
855
+ // is never interpreted as raw HTML, only as Markdown syntax on top of already-safe text.
856
+ var BACKTICK = '\\u0060';
857
+
858
+ function isSafeUrl(url) {
859
+ if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return true;
860
+ return /^(https?|mailto):/i.test(url);
861
+ }
862
+
863
+ function renderInline(text) {
864
+ const codeRe = new RegExp(BACKTICK + '([^' + BACKTICK + ']+)' + BACKTICK, 'g');
865
+ text = text.replace(codeRe, function (m, code) { return '<code>' + code + '</code>'; });
866
+ text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
867
+ text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
868
+ text = text.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
869
+ text = text.replace(/(^|[^\\w])_([^_]+)_(?!\\w)/g, '$1<em>$2</em>');
870
+ text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, function (m, label, url) {
871
+ const safeUrl = isSafeUrl(url) ? url : '#';
872
+ return '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
873
+ });
874
+ return text;
875
+ }
876
+
877
+ function renderMarkdown(raw) {
878
+ const escaped = escapeHtml(raw || '');
879
+ const lines = escaped.split('\\n');
880
+ const fence = BACKTICK + BACKTICK + BACKTICK;
881
+ const out = [];
882
+ let paragraph = [];
883
+
884
+ function flushParagraph() {
885
+ if (paragraph.length > 0) {
886
+ out.push('<p>' + paragraph.map(renderInline).join('<br>') + '</p>');
887
+ paragraph = [];
888
+ }
889
+ }
890
+
891
+ let i = 0;
892
+ while (i < lines.length) {
893
+ const line = lines[i];
894
+
895
+ if (line.indexOf(fence) === 0) {
896
+ flushParagraph();
897
+ const lang = line.slice(fence.length).trim().replace(/[^a-zA-Z0-9_-]/g, '');
898
+ const codeLines = [];
899
+ i++;
900
+ while (i < lines.length && lines[i].indexOf(fence) !== 0) {
901
+ codeLines.push(lines[i]);
902
+ i++;
903
+ }
904
+ i++;
905
+ const langClass = lang ? ' class="language-' + lang + '"' : '';
906
+ out.push('<pre><code' + langClass + '>' + codeLines.join('\\n') + '</code></pre>');
907
+ continue;
908
+ }
909
+
910
+ const headerMatch = line.match(/^(#{1,6})\\s+(.*)$/);
911
+ if (headerMatch) {
912
+ flushParagraph();
913
+ const level = headerMatch[1].length;
914
+ out.push('<h' + level + '>' + renderInline(headerMatch[2]) + '</h' + level + '>');
915
+ i++;
916
+ continue;
917
+ }
918
+
919
+ if (/^(-{3,}|\\*{3,})\\s*$/.test(line)) {
920
+ flushParagraph();
921
+ out.push('<hr>');
922
+ i++;
923
+ continue;
924
+ }
925
+
926
+ if (/^&gt;\\s?/.test(line)) {
927
+ flushParagraph();
928
+ const quoteLines = [];
929
+ while (i < lines.length && /^&gt;\\s?/.test(lines[i])) {
930
+ quoteLines.push(lines[i].replace(/^&gt;\\s?/, ''));
931
+ i++;
932
+ }
933
+ out.push('<blockquote><p>' + quoteLines.map(renderInline).join('<br>') + '</p></blockquote>');
934
+ continue;
935
+ }
936
+
937
+ if (/^[-*+]\\s+/.test(line)) {
938
+ flushParagraph();
939
+ const items = [];
940
+ while (i < lines.length && /^[-*+]\\s+/.test(lines[i])) {
941
+ items.push('<li>' + renderInline(lines[i].replace(/^[-*+]\\s+/, '')) + '</li>');
942
+ i++;
943
+ }
944
+ out.push('<ul>' + items.join('') + '</ul>');
945
+ continue;
946
+ }
947
+
948
+ if (/^\\d+\\.\\s+/.test(line)) {
949
+ flushParagraph();
950
+ const oitems = [];
951
+ while (i < lines.length && /^\\d+\\.\\s+/.test(lines[i])) {
952
+ oitems.push('<li>' + renderInline(lines[i].replace(/^\\d+\\.\\s+/, '')) + '</li>');
953
+ i++;
954
+ }
955
+ out.push('<ol>' + oitems.join('') + '</ol>');
956
+ continue;
957
+ }
958
+
959
+ if (line.trim() === '') {
960
+ flushParagraph();
961
+ i++;
962
+ continue;
963
+ }
964
+
965
+ paragraph.push(line);
966
+ i++;
967
+ }
968
+
969
+ flushParagraph();
970
+ return out.join('');
971
+ }
972
+
692
973
  function renderMessage(msg) {
693
974
  const div = document.createElement('div');
694
975
  div.className = 'msg ' + (msg.role === 'user' ? 'user' : (msg.role === 'error' ? 'error' : 'assistant'));
695
- div.innerHTML = escapeHtml(msg.text || '').replace(/\\n/g, '<br>');
976
+ div.innerHTML = renderMarkdown(msg.text || '');
696
977
  if (msg.attachments && msg.attachments.length) {
697
978
  const wrap = document.createElement('div');
698
979
  wrap.className = 'attachments';
@@ -787,6 +1068,10 @@ const BOOTSTRAP = ${bootstrap};
787
1068
  }).then(function (result) {
788
1069
  if (!result.ok) throw new Error((result.data && result.data.error) || 'Request failed');
789
1070
  renderMessage(result.data.message);
1071
+ if (result.data.context) {
1072
+ state.context = new Set(result.data.context);
1073
+ applyContextHighlight();
1074
+ }
790
1075
  }).catch(function (e) {
791
1076
  renderMessage({ role: 'error', text: 'Error: ' + e.message, attachments: [] });
792
1077
  }).then(function () {
@@ -810,10 +1095,36 @@ const BOOTSTRAP = ${bootstrap};
810
1095
  });
811
1096
  }
812
1097
 
1098
+ function loadContext() {
1099
+ return fetch('/api/context').then(function (r) { return r.json(); }).then(function (data) {
1100
+ state.context = new Set(data.files || []);
1101
+ });
1102
+ }
1103
+
1104
+ function applyContextHighlight() {
1105
+ const rows = treeEl.querySelectorAll('.tree-row[data-path]');
1106
+ rows.forEach(function (row) {
1107
+ row.classList.toggle('in-context', state.context.has(row.getAttribute('data-path')));
1108
+ });
1109
+ }
1110
+
1111
+ function toggleContext(relPath) {
1112
+ fetch('/api/context/toggle', {
1113
+ method: 'POST',
1114
+ headers: { 'Content-Type': 'application/json' },
1115
+ body: JSON.stringify({ path: relPath }),
1116
+ }).then(function (r) { return r.json(); }).then(function (data) {
1117
+ state.context = new Set(data.files || []);
1118
+ applyContextHighlight();
1119
+ });
1120
+ }
1121
+
813
1122
  function buildNode(entry, container) {
814
1123
  const row = document.createElement('div');
815
1124
  row.className = 'tree-row';
1125
+ row.dataset.path = entry.path;
816
1126
  row.textContent = folderIcon(entry.isDir) + ' ' + entry.name;
1127
+ if (!entry.isDir && state.context.has(entry.path)) row.classList.add('in-context');
817
1128
  container.appendChild(row);
818
1129
 
819
1130
  if (entry.isDir) {
@@ -832,6 +1143,10 @@ const BOOTSTRAP = ${bootstrap};
832
1143
  });
833
1144
  } else {
834
1145
  row.addEventListener('click', function () { openFile(entry.path); });
1146
+ row.addEventListener('dblclick', function (e) {
1147
+ e.preventDefault();
1148
+ toggleContext(entry.path);
1149
+ });
835
1150
  }
836
1151
  }
837
1152
 
@@ -868,7 +1183,7 @@ const BOOTSTRAP = ${bootstrap};
868
1183
  }
869
1184
 
870
1185
  loadHistory();
871
- loadTree();
1186
+ loadContext().then(loadTree);
872
1187
  input.focus();
873
1188
  })();
874
1189
  </script>