-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathAI_IDE_Support.ns
More file actions
4366 lines (4096 loc) · 200 KB
/
Copy pathAI_IDE_Support.ns
File metadata and controls
4366 lines (4096 loc) · 200 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
Newspeak3
'Root'
class AI_IDE_Support usingPlatform: platform ide: webIde = Object new (
(* IDE module providing AI integration. Sources AIAccess from the platform
and the chat UI classes from Hopscotch (where they now live as
top-level siblings, no longer a separate AIChatUI module). Defines
IDE-specific tools that allow an AI model to query the IDE via mirrors.
This module follows the standard IDE module pattern: it is parameterized
by the platform and the IDE, and is instantiated by HopscotchWebIDE.
Other IDE presenters can access AI functionality through this module,
e.g., to embed a ChatSubject in a method or class presenter. *)
(* IDE dependencies *)
|
private Root = webIde namespacing Root.
private systemScope = webIde namespacing systemScope.
private ClassMirror = platform mirrors ClassMirror.
private ObjectMirror = platform mirrors ObjectMirror.
private ActivationMirror = platform mirrors ActivationMirror.
private ClassDeclarationBuilder = platform mirrors ClassDeclarationBuilder.
private List = platform collections List.
private Map = platform collections Map.
private Set = platform collections Set.
private ide = webIde.
private js = platform js.
private JSString = platform js global at: 'String'.
private Document = webIde documents Document.
private Presenter = platform hopscotch Presenter.
private FileChooserFragment = platform hopscotch FileChooserFragment.
private HTMLFragment = platform hopscotch HTMLFragment.
(* AI capability comes from the platform: aiAccess is the non-UI
service module, aiColors is the chat-bubble palette grouped on
Hopscotch. *)
public aiAccess = platform hopscotch aiAccess.
private aiColors = platform hopscotch aiColors.
(* Re-export commonly needed classes for use by other IDE modules *)
public Tool = aiAccess Tool.
public Provider = aiAccess Provider.
public AnthropicProvider = aiAccess AnthropicProvider.
public GeminiProvider = aiAccess GeminiProvider.
public OpenAICompatibleProvider = aiAccess OpenAICompatibleProvider.
public Session = aiAccess Session.
private AIChatSetupSubject = platform hopscotch AIChatSetupSubject.
private AIChatSetupPresenter = platform hopscotch AIChatSetupPresenter.
private BaseChatSubject = platform hopscotch ChatSubject.
private BaseChatPresenter = platform hopscotch ChatPresenter.
private ChatStatusPresenter = platform hopscotch ChatStatusPresenter.
private ChatInputPresenter = platform hopscotch ChatInputPresenter.
(* --- AI focus state ---
Brain buttons in the IDE install a focus describing what the user is
currently asking the chat about. Three orthogonal pieces of state:
1. currentFocusBlock — a [ ^String ] block producing a description on
demand. Returned by the current_focus tool. A block (not a snapshot)
lets the description reflect live state — e.g. an in-progress
unaccepted edit, post-replacement updated source.
2. currentFocusValidator — a [ ^Boolean ] block that checks whether the
focused entity still exists. Run on every current_focus call. Returns
false (or raises) → focus is cleared. This catches all paths by which
an entity can disappear (explicit delete, add_or_replace_nested_class,
etc.) without each path needing to know about the focus.
3. focusKind / focusClassPath / focusSide / focusSelector — structured
identity used by isFocusedOnX:... matchers. Brain-button highlighting
compares against this rather than the Subject that installed the
focus. Reason: atomic install only forwards mixin objects, not
individual methods, so MethodMirror equality fails after a replace,
MirrorGroupSubject>>updateElements creates a fresh MethodSubject,
and any Subject-object-identity check fails. Structural matching
survives that. *)
currentFocusBlock
currentFocusValidator
(* focusKind: #class | #classHeader | #method | #lazySlot | #evaluator | nil *)
focusKind
(* focusClassPath: String, dotted path *)
focusClassPath
(* focusSide: 'instance' | 'class' | nil *)
focusSide
(* focusSelector: Symbol — method or lazy-slot name, or nil *)
focusSelector
(* focusSha_slot: String (full git oid) — present only when the focus is
on a historical version of a member (i.e. installed via
setHistoryFocusOnEntry:). Nil otherwise. The isFocusedOnHistory:sha:
predicate and the read-tool fan-out branch on this. *)
focusSha_slot
(* focusHistoricalSource_slot: String — the version's source, captured
at focus install time. Returned by get_member_source /
get_class_header when the request matches the historical focus. Set
together with focusSha_slot. *)
focusHistoricalSource_slot
(* focusEvaluatorMirror_slot: ObjectMirror | ActivationMirror — set when
focusKind = #evaluator. Used directly as the identity for matcher
comparison; also exposed via the 'focus' object-registry id so
evaluate(in_id: 'focus') routes to the focused evaluator's receiver. *)
focusEvaluatorMirror_slot
(* focusEvaluatorSubject_slot: the EvaluatorSubject (or subclass) that
installed the focus. We retain it so 'focus' evaluations can append
results to its evaluator's results list, making them visible in the
IDE evaluator pane just like the workspace case. *)
focusEvaluatorSubject_slot
(* The chat the brain button / inline region / toolbar currently *target*
— NOT "the only one". Multiple chat docs coexist live, each with its own
subject on its own `chatSubjectSlot`; this slot only records which one is
the current target. It moves only on deliberate selection (makeCurrentChat:,
the switcher) or new-chat creation (startChat) — never on render — so
rendering/transcluding other chat docs never steals the target (M1). *)
currentChatSubject_slot
(* AI-proposed code changes staged for atomic install. Keyed by
changeset id (e.g. "cs1"); the value is a List of change records
(Maps with kind, class_name, side, and either source or selector
plus old_source). Cleared on apply or discard. *)
proposedChangesets = Map new.
nextChangesetIdNum ::= 0.
(* AI object registry — Map[String, ObjectMirror] of objects the AI
can refer to by id. Pre-populated with the AI workspace under id
'workspace' the first time it is reached. Subsequent entries come
from evaluate's non-primitive results, each auto-named (r1, r2, ...)
or AI-named via the name: parameter. Debugger activations get
their own ids (a1, a2, ...). Deliberately NOT cleared on chat-clear:
results reachable here may not be reachable from the workspace's
result list (e.g. when the AI navigates and evaluates transitively
through results). *)
objectRegistry = Map new.
nextResultIdNum ::= 0.
nextActivationIdNum ::= 0.
(* Cached AI workspace ObjectSubject. We keep the Subject (not just
the mirror) so workspace evaluations can be routed through the
subject's EvaluatorSubject — its evaluate: appends the resulting
ThreadMirror to the workspace's results list, which is what the
workspace presenter renders. Without this, the AI workspace UI
stays blank no matter how many evaluations run. *)
aiWorkspaceSubject_slot
(* Map[String, TestingInProgressSubject] keyed by test configuration
class simpleName. Captured by run_tests so subsequent
get_test_results / run_failing_tests / run_erroring_tests / run_test
tools can read and mutate the same Tester. Selective re-runs
operate on this cached Tester in place; a fresh run_tests
overwrites the entry. *)
testersByConfigName = Map new.
(* The chatHolder currently displaying the shared AI chat, paired
with the page subject it sits on. The brain-click path closes the
previous chat region ONLY when the new click is on the same page;
if the user navigates A → click → B → click → back to A, A's chat
region stays as the user left it. Set together via
activeChatHolder:page:. *)
activeChatHolder_slot
activeChatHolderPage_slot
|
) (
public class IDEChatSetupSubject onModel: providerClass <Class> = AIChatSetupSubject onModel: providerClass (
(* IDE-specific chat setup. Adds providerName + docName fields. startChat is
overridden: instead of constructing an in-memory ChatSubject, it persists
the API key (under the per-provider storage key derived from providerName),
creates a ChatDocument in Root with the configured provider + model, makes
that doc the active chat doc (in localStorage), caches its chat subject
as the shared session for ephemeral brain-button chats, and returns a
DocumentSubject for navigation so the user lands on the new doc.
storageKey is overridden so it derives from providerName rather than from
the onModel: providerClass — letting one setup subject manage keys for
every supported provider as the user toggles the dropdown. *)
|
public docName <String>
public providerName <String> ::= 'anthropic'.
public baseUrl <String> ::= ''.
|
(* The parent's initializer already tried to load apiKey via storageKey,
but our overridden storageKey reads providerName which wasn't yet
initialized — the parent's on:Error swallowed and left apiKey empty.
Reload now that providerName is set. *)
reloadApiKey.
reloadBaseUrl.
docName:: defaultDocNameForProvider: providerName.
) (
storageKey ^<String> = (
^'ns_ai_apikey_' , (providerClassFor: providerName) name
)
public reloadBaseUrl = (
(* Refresh baseUrl from localStorage (or fall back to the provider's
default). Called from the initializer and on every provider
switch so the form reflects the current provider's URL config. *)
baseUrl:: baseUrlForProvider: providerName
)
public createProvider = (
(* Override the base AIChatSetupSubject>>createProvider to dispatch
on providerName: openai-compat needs the 3-arg factory carrying
baseUrl; others use the inherited 2-arg shape. The base impl
reads `model` which is the original onModel: providerClass and
never updates on dropdown switch, so we explicitly resolve via
providerClass (which itself routes through providerClassFor:). *)
| klass |
klass:: providerClass.
providerName = 'openai-compat' ifTrue: [
^klass apiKey: apiKey model: modelName baseUrl: baseUrl
].
^klass apiKey: apiKey model: modelName
)
public providerClass = (
(* Override the base AIChatSetupSubject's `model`-based providerClass
so the live provider class always tracks the dropdown's
providerName. The base subject stores the class in `model` and
never updates it; in the IDE flow the dropdown only mutates
providerName, so we resolve via providerClassFor: every access.
Lets the base AIChatSetupPresenter modelField (which reads
subject providerClass) work without an IDE-specific override. *)
^providerClassFor: providerName
)
public reloadApiKey = (
| key <String | UndefinedObject> |
key:: [ js localStorage getItem: storageKey ] on: Exception do: [:e | nil ].
key isNil ifTrue: [ key:: '' ].
apiKey:: key
)
public createSession: provider <Provider> ^<Session> = (
| session <Session> |
session:: Session provider: provider tools: (toolsForProvider: provider).
session systemPrompt: newspeakSystemPrompt.
^session
)
public createPresenter = (
^IDEChatSetupPresenter onSubject: self
)
public defaultDocNameForProvider: pname <String> ^<String> = (
(* Generate a default chat document name for the given provider that
doesn't clash with any existing Root entry. Pattern is
<ProviderLabel>Chat for the first one, then <ProviderLabel>Chat2,
<ProviderLabel>Chat3, and so on. Called at construction time to
seed the docName slot, and again on provider switch to keep the
default in sync with the chosen provider. *)
| base candidate n |
base:: (providerDisplayLabelFor: pname) , 'Chat'.
(Root includesKey: base asSymbol) ifFalse: [ ^base ].
n:: 2.
[
candidate:: base , n printString.
(Root includesKey: candidate asSymbol) ifFalse: [ ^candidate ].
n:: n + 1
] repeat
)
providerDisplayLabelFor: pname <String> ^<String> = (
(* Title-case label used to seed the default chat doc name. New
providers should add a case here as they land; unknown keys fall
back to the key itself. *)
pname = 'anthropic' ifTrue: [ ^'Anthropic' ].
pname = 'gemini' ifTrue: [ ^'Gemini' ].
pname = 'openai-compat' ifTrue: [ ^'Local' ].
^pname
)
public startChat = (
(* Persist key, create the ChatDocument in Root under docName, install
it as the active chat doc (localStorage + currentChatSubject_slot),
and return a DocumentSubject so navigation lands on the new doc.
Returns nil on validation failure with errorMessage set. *)
| doc <Document> |
apiKey isEmpty ifTrue: [
errorMessage:: 'Please enter an API key.'.
^nil
].
docName isEmpty ifTrue: [
errorMessage:: 'Please enter a chat name.'.
^nil
].
(Root includesKey: docName asSymbol) ifTrue: [
errorMessage:: 'An entry named "' , docName , '" already exists in Root.'.
^nil
].
[ js localStorage setItem: storageKey to: apiKey ] on: Exception do: [:e | ].
providerName = 'openai-compat' ifTrue: [
[
js localStorage setItem: (baseUrlStorageKeyFor: providerName) to: baseUrl
] on: Exception do: [:e | ]
].
[ js localStorage setItem: 'ns_ai_active_chat_doc' to: docName ] on: Exception
do: [:e | ].
doc:: createChatNamed: docName provider: providerName model: modelName.
Root at: docName asSymbol put: doc.
currentChatSubject_slot:: doc chatSubject.
ide browsing updateCurrentDisplay.
^ide documents DocumentSubject onModel: doc
)
public newChatSubjectFor: session <Session> ^<IDEChatSubject> = (
(* Legacy path: not used now that startChat creates a ChatDocument and
caches its chatSubject directly. Kept as a defensive fallback if any
caller bypasses startChat. *)
| chat <IDEChatSubject> |
chat:: IDEChatSubject onModel: session.
currentChatSubject_slot:: chat.
^chat
)
public isKindOfIDEChatSetupSubject ^<Boolean> = (
^true
)
isMyKind: other ^<Boolean> = (
^other isKindOfIDEChatSetupSubject
)
) : ()
public class ChatFocus onDoc: doc <Document | UndefinedObject> = Object new (
(* Per-chat brain-button focus (M2a). Each chat (session) owns one; the
current_focus tool built for that session reads THIS focus, so concurrent
sessions never share focus. docLabel names the host chat document and is
constant for the chat; block/validator are the brain-button snapshot,
captured by captureFocusInto: when a brain opens/targets this chat. *)
|
block
validator
chatDoc_slot = doc.
docLabel = doc isNil ifTrue: [ '' ] ifFalse: [ 'Active chat document: ' , doc name , '. ' ].
|
) (
public chatDoc ^<Document | UndefinedObject> = (
(* The chat document this focus/session belongs to (nil for doc-less
sessions). Lets a per-session tool route to ITS chat — e.g. propose_changes
enqueues the changeset onto the proposing chat, not the global current (M2b). *)
^chatDoc_slot
)
public installBlock: b validator: v = (
block:: b.
validator:: v
)
public clear = (
block:: nil.
validator:: nil
)
public description ^<String> = (
^docLabel , focusPart
)
focusPart ^<String> = (
(* Mirrors the old module-global computeFocusOnly: validate the focused
entity still exists, then render; drop a stale focus. *)
block isNil ifTrue: [ ^'(no focus set)' ].
validator isNil ifFalse: [
([ validator value ] on: Error do: [:e | false ]) ifFalse: [
clear.
^'(no focus set)'
]
].
^[ block value ] on: Error
do: [:e | '(focus block raised: ' , e messageText , ')' ]
)
) : ()
public class IDEChatSubject onModel: session <Session> = BaseChatSubject onModel: session (
(* IDE-aware chat subject; returns an IDE presenter so Inspect Presenter works.
Also carries the inline-chat side of the changeset-attachment machinery:
pendingChangesetIds ids staged by propose_changes during the
in-flight turn, drained by markComplete
onto the last assistant message index.
changesetIdsByMessageIndex Map[Integer, List[String]] — which
proposals belong to which assistant
message in displayMessages.
changesetSubjectsById per-id ChangesetSubject cache so the
presenter survives across renders without
hitting the runaway-compute bug.
IDEChatDocumentSubject inherits these slots but uses ChatDocument's
own pendingChangesetIds / appendChangesetAmpletFor: pipeline; the
inherited fields stay empty in that flow. *)
|
public pendingChangesetIds <List[String]> ::= List new.
public changesetIdsByMessageIndex <Map[Integer, List[String]]> ::= Map new.
public changesetSubjectsById <Map[String, Object]> ::= Map new.
|
) (
public createPresenter = (
^IDEChatPresenter onSubject: self
)
public isKindOfIDEChatSubject ^<Boolean> = (
^true
)
isMyKind: other ^<Boolean> = (
^other isKindOfIDEChatSubject
)
public attachedChangesetIdsAt: messageIndex <Integer> ^<List[String]> = (
^changesetIdsByMessageIndex at: messageIndex ifAbsent: [ List new ]
)
public forgetCachedChangesetSubjectFor: id <String> = (
changesetSubjectsById removeKey: id ifAbsent: [ ]
)
public changesetSubjectFor: id <String> ^<Object | UndefinedObject> = (
(* Pure model accessor: return the cached ChangesetSubject for
this proposal id, building it on first use, or nil if the
underlying records are gone (applied / discarded / never
staged). The caller (IDEChatPresenter>>messagesArea) wraps a
non-nil result in a ChangesetPresenter and renders a
"no longer pending" label for the nil case — presentation
stays on the presenter side.
Caching is required: prepareForLiveViewRefresh: nukes the
Ampleforth fragment cache on every refresh and the inline
presenter rebuilds messagesArea on every uiGeneration bump,
so without a stable subject the first render's synchronous
compute would fire refreshBlock → updateGUI → another render
→ fresh subject → ... runaway. *)
| records |
records:: proposedChangesets at: id ifAbsent: [ nil ].
records isNil ifTrue: [
changesetSubjectsById removeKey: id ifAbsent: [ ].
^nil
].
^changesetSubjectsById at: id
ifAbsent: [
| strategy s subj |
subj:: self.
strategy:: AIInstallStrategy onRecords: records
changesetId: id
onApplied: [:csId <String> :recCount <Integer> |
(* M3: notify other open chats that the codebase changed. *)
broadcastChangeAppliedBy: subj records: records
]
onCanceled: [
subj forgetCachedChangesetSubjectFor: id.
ide browsing updateCurrentDisplay
]
onComputed: [ ].
s:: ChangesetSubject onModel: strategy.
s autoExpand: true.
changesetSubjectsById at: id put: s.
s
]
)
public markComplete = (
(* Drain pendingChangesetIds onto the last assistant message
index, so the inline ChatPresenter's messagesArea can render
a ChangesetPresenter inline under that turn. For doc-backed
subjects the same ids are *also* queued on the chat doc
(enqueuePendingChangesetId: pushes to both queues), so the
doc's amplet pipeline keeps working in parallel. *)
super markComplete.
pendingChangesetIds isEmpty ifFalse: [
| lastIdx bucket fresh |
lastIdx:: self lastAssistantMessageIndex.
lastIdx isNil ifFalse: [
bucket:: changesetIdsByMessageIndex at: lastIdx
ifAbsent: [
| b |
b:: List new.
changesetIdsByMessageIndex at: lastIdx put: b.
b
].
pendingChangesetIds do: [:id | bucket add: id ]
].
(* Clear pending — replace with a fresh List rather than
removeAll, since List's API for in-place truncation varies
across runtimes. *)
fresh:: List new.
pendingChangesetIds:: fresh
]
)
lastAssistantMessageIndex ^<Integer | UndefinedObject> = (
| msgs i |
msgs:: session displayMessages.
i:: msgs size.
[ i > 0 ] whileTrue: [
((msgs at: i) at: 1) = 'assistant' ifTrue: [ ^i ].
i:: i - 1
].
^nil
)
public clearHistory = (
super clearHistory.
changesetIdsByMessageIndex:: Map new.
changesetSubjectsById:: Map new.
pendingChangesetIds:: List new
)
) : ()
public class IDEChatSetupPresenter onSubject: s <IDEChatSetupSubject> = AIChatSetupPresenter onSubject: s (
(* IDE-flavoured setup presenter. Adds a provider dropdown, a chat-name
field (the Root entry the ChatDocument will be created under), and
overrides Inspect Presenter to route through the IDE's namespacing. *)
|
docNameEditor
baseUrlEditor
|
) (
definition = (
| rows |
rows:: List new.
rows add: helpSection.
rows add: (row: {(label: 'AI Chat Setup') bold. filler. helpButton. dropDownMenu: [
actionsMenu
]. }).
rows add: mediumBlank.
rows add: (label: 'Configure your AI provider and chat document. API keys are stored per provider in your browser''s localStorage; the chat document lives in your IDE namespace.').
rows add: mediumBlank.
rows add: (row: {(label: 'Provider: ') bold. smallBlank. providerPicker. }).
rows add: mediumBlank.
subject providerName = 'openai-compat' ifTrue: [
rows add: (row: {(label: 'Base URL: ') bold. elastic: baseUrlField. }).
rows add: (label: 'Local servers must allow CORS for this origin — e.g. for Ollama set OLLAMA_ORIGINS=* before launching.') italic.
rows add: mediumBlank
].
rows add: (row: {(label: 'API Key: ') bold. elastic: apiKeyField. }).
rows add: mediumBlank.
rows add: (row: {(label: 'Model: ') bold. elastic: modelField. }).
rows add: mediumBlank.
rows add: (row: {(label: 'Chat name: ') bold. elastic: docNameField. }).
rows add: mediumBlank.
rows add: errorArea.
rows add: (row: {filler. button: 'Start Chat' action: [ respondToStart ]. }).
^column: rows
)
baseUrlField = (
(* CodeMirror text editor bound to subject baseUrl. Only rendered
when providerName = 'openai-compat'. The acceptResponse mirrors
apiKeyField's pattern: pressing Enter commits the typed URL AND
triggers updateGUI: so modelField re-evaluates against the new
URL's cache (which usually misses, surfacing the freeform field
for a never-tried URL). *)
baseUrlEditor:: codeMirror: subject baseUrl.
baseUrlEditor changeResponse: [:frag |
subject baseUrl: frag textBeingAccepted
].
baseUrlEditor acceptResponse: [:frag |
(* leaveEditState must run BEFORE updateGUI: so the next render
sees the editor already out of edit state — otherwise the JS
visual keeps the green/red Accept/Cancel buttons visible.
Same idiom as ChatInputPresenter>>respondToSend. *)
subject baseUrl: frag textBeingAccepted.
frag leaveEditState.
updateGUI: [ ]
].
^baseUrlEditor
)
providerPicker = (
(* Provider selector. Picking a provider swaps in its default model
name, reloads the stored API key for it, and resyncs the visible
form state — see respondToProviderSwitch:defaultModel: for why
the resync is non-trivial. *)
^row: {(label: subject providerName) bold. smallBlank. dropDownMenu: [
menuWithLabelsAndActions: {{'Anthropic'. [
respondToProviderSwitch: 'anthropic' defaultModel: 'claude-sonnet-4-6'
]. }. {'Gemini'. [
respondToProviderSwitch: 'gemini' defaultModel: 'gemini-3.5-flash'
]. }. {'Local (OpenAI-compatible)'. [
respondToProviderSwitch: 'openai-compat' defaultModel: ''
]. }. }
]. }
)
respondToProviderSwitch: providerName <String> defaultModel: defaultModel <String> = (
(* Switch provider and resync the visible form so the API-key
editor, model editor, and toggle expansion all reflect the
newly-chosen provider rather than the previous one.
Plain updateGUI: alone isn't enough: ToggleComposer's
updateVisualsFromSameKind: preserves the old isExpanded across
re-renders, and CodeMirrorFragment's preserves the old editor's
text. We explicitly mutate the existing presenter slots
(keyToggle, keyEditor, modelEditor, keyRevealed) so the diff
inherits the new state on re-render. *)
updateGUI: [
subject providerName: providerName.
subject modelName: defaultModel.
subject docName: (subject defaultDocNameForProvider: providerName).
subject reloadApiKey.
subject reloadBaseUrl.
keyRevealed:: subject apiKey isEmpty.
keyToggle isNil ifFalse: [
keyRevealed ifTrue: [ keyToggle expand ] ifFalse: [ keyToggle collapse ]
].
keyEditor isNil ifFalse: [ keyEditor text: subject apiKey ].
modelEditor isNil ifFalse: [ modelEditor text: subject modelName ].
docNameEditor isNil ifFalse: [ docNameEditor text: subject docName ].
baseUrlEditor isNil ifFalse: [ baseUrlEditor text: subject baseUrl ]
]
)
docNameField = (
docNameEditor:: codeMirror: subject docName.
docNameEditor changeResponse: [:frag |
subject docName: frag textBeingAccepted
].
^docNameEditor
)
respondToStart = (
subject docName: docNameEditor textBeingAccepted.
super respondToStart
)
respondToInspectPresenter = (
enterSubject: (ide browsing ObjectSubject onModel: (ObjectMirror reflecting: self))
)
public isKindOfIDEChatSetupPresenter ^<Boolean> = (
^true
)
isMyKind: other ^<Boolean> = (
^other isKindOfIDEChatSetupPresenter
)
) : ()
public class IDEChatPresenter onSubject: s <IDEChatSubject> = BaseChatPresenter onSubject: s (
(* Inline (non-document) chat presenter. Used for embedded chats (brain
button → ephemeral region under the current page). ChatDocument-backed
chats render through DocumentPresenter instead.
onSwitchHandler: optional [:Document] block. When set (by a brain region that
embeds this presenter), the chat switcher retargets THAT region's chat locally
instead of moving the global current — so switching one brain region does not
switch every other one. Re-injected on each region render, so it need not
survive this presenter's own regeneration. *)
| onSwitchHandler_slot |
) (
public onSwitchHandler: aBlock <[:Document]> = (
onSwitchHandler_slot:: aBlock
)
respondToInspectPresenter = (
enterSubject: (ide browsing ObjectSubject onModel: (ObjectMirror reflecting: self))
)
public isKindOfIDEChatPresenter ^<Boolean> = (
^true
)
isMyKind: other ^<Boolean> = (
^other isKindOfIDEChatPresenter
)
messagesArea = (
(* Override of BaseChatPresenter>>messagesArea that interleaves
ChangesetPresenters under any assistant message they were
attached to (via subject changesetIdsByMessageIndex, populated
by IDEChatSubject>>markComplete from propose_changes calls
made during the turn). The base method renders only the
{role, text} pairs; we keep its messageFragment: for the row
itself and add a ChangesetPresenter as a sibling in the column
for each attached id whose proposal is still pending. When the
proposal has been applied or discarded, subject returns nil and
we render a small italic banner instead — keeping presentation
concerns out of the subject. *)
| fragments msgs i |
fragments:: List new.
msgs:: subject messages.
i:: 1.
[ i <= msgs size ] whileTrue: [
| attachedIds |
fragments add: (messageFragment: (msgs at: i)).
attachedIds:: subject attachedChangesetIdsAt: i.
attachedIds do: [:csId |
| cs |
cs:: subject changesetSubjectFor: csId.
fragments add: smallBlank.
fragments add: (cs isNil ifTrue: [
(label: 'Changeset ' , csId , ' is no longer pending.') italic
]
ifFalse: [ ChangesetPresenter onSubject: cs ])
].
fragments add: smallBlank.
i:: i + 1
].
subject errorMessage isNil ifFalse: [
fragments add: ((label: 'Error: ' , subject errorMessage) color: aiColors errorText)
].
fragments isEmpty ifTrue: [
fragments add: (label: 'Send a message to start a conversation.') italic
].
^column: fragments
)
headingBar = (
(* Override BaseChatPresenter>>headingBar to add a New-chat button and a
chat switcher, so a new chat (optionally with a different provider or
model) can be started and any existing chat selected directly from a
regular chat presenter — not only from a ChatDocument's chatControls.
The Clear / help / actions affordances from the base bar are kept. *)
^row: {(label: 'Chat') bold. filler. button: 'New Chat'
action: [ openNewChatSetup ]. smallBlank. chatSwitcher. smallBlank. button: 'Clear'
action: [ updateGUI: [ subject clearHistory ] ]. helpButton. dropDownMenu: [
actionsMenu
]. }
)
chatSwitcher = (
(* Dropdown of every ChatDocument in Root. Selecting one switches THIS
chat region in place to that chat — it does NOT navigate to the chat's
document view. The inline region always renders ensureSharedChat, so
making the selected chat current and refreshing re-renders this region
on its conversation. The previously shown chat keeps its Session, so
reselecting it resumes its history. *)
^row: {label: 'Chats:'. smallBlank. dropDownMenu: [
menuWithLabelsAndActions: (chatDocumentsInRoot collect: [:doc |
{doc name. [ respondToSwitchToChat: doc ]. }
])
]. }
)
respondToSwitchToChat: doc <Document> = (
(* Switch this chat region to `doc`. If embedded in a brain region
(onSwitchHandler set), retarget only THAT region's chat — local, no effect
on other regions or the global current. Otherwise (standalone) fall back to
moving the global current and refreshing this presenter, as before. No
navigation either way — the user stays on the current page. *)
onSwitchHandler_slot isNil
ifFalse: [ ^onSwitchHandler_slot value: doc ].
makeCurrentChat: doc.
updateGUI: [ ]
)
) : ()
public class ChatControlsPresenter onSubject: doc <Document> = Presenter onSubject: doc (
(* Renders a chat document's control row (New Chat + chat switcher). Lives in
AI_IDE_Support so the rendering is single-sourced across every chat document;
a chat doc's `chatControls` amplet just forwards here (^ide aiSupport
chatControlsFor: self). Selecting a chat navigates to that chat's document
view — under the clone-template design each chat IS its own document, so
switching = showing that document. *)
) (
definition = (
^row: {button: 'New Chat' action: [ openNewChatSetup ]. mediumBlank. label: 'Chats:'. smallBlank. dropDownMenu: [
menuWithLabelsAndActions: chatSwitchMenuActions
]. filler. }
)
public isKindOfChatControlsPresenter ^<Boolean> = (
^true
)
isMyKind: other ^<Boolean> = (
^other isKindOfChatControlsPresenter
)
) : ()
public class IDEChatDocumentSubject onChatDocument: doc <Document> session: s <Session> focus: f <ChatFocus> = IDEChatSubject onModel: s (
(* Chat subject bound to a ChatDocument: instead of (or in addition to)
the conversation living in the inline ChatPresenter view, each turn is
appended as an HTML section into the document's contents and the live
view is refreshed. The Session under the hood is still the source of
truth for messages — the document sections are a *projection* of what
displayMessages returns. We track lastTurnIndexBeforeSend so that, on
markComplete, we only render the assistant turns that arrived during
this round-trip rather than re-rendering history. *)
|
public document <Document> = doc.
lastTurnIndexBeforeSend <Integer>
chatFocus_slot <ChatFocus> = f.
|
) (
public isKindOfIDEChatDocumentSubject ^<Boolean> = (
^true
)
public focus ^<ChatFocus> = (
(* This chat's per-session brain-button focus (M2a). Read by its
current_focus tool; written by captureFocusInto: on a brain click. *)
^chatFocus_slot
)
public sendText: text <String> ^<Alien[Promise] | UndefinedObject> = (
| promise <Alien[Promise] | UndefinedObject> |
text isEmpty ifTrue: [ ^nil ].
document appendUserSection: text.
lastTurnIndexBeforeSend:: session displayMessages size.
(* super sendText: flips the chat subject's waiting flag, but it does
so AFTER our appendUserSection: above has already published. So we
re-publish here via a no-op mutation so the live view picks up the
thinking indicator alongside the user section. *)
promise:: super sendText: text.
document mutateContentsTo: document contents.
^promise
)
appendNewAssistantTurnsAfter: startIdx <Integer> = (
(* Render only the assistant turns added since startIdx. Tool-use
rounds can produce multiple assistant messages per send (one per
model→tool→model cycle); we want each as its own bubble. *)
| msgs <List> i <Integer> |
msgs:: session displayMessages.
i:: startIdx + 1.
[ i <= msgs size ] whileTrue: [
| msg <Array> role <String> text <String> |
msg:: msgs at: i.
role:: msg at: 1.
text:: msg at: 2.
role = 'assistant' ifTrue: [ document appendAssistantSection: text ].
i:: i + 1
]
)
public markComplete = (
super markComplete.
appendNewAssistantTurnsAfter: lastTurnIndexBeforeSend.
(* Drain any changeset ids proposed during this turn. Each id
becomes a ChangesetPresenter amplet appended after the last
assistant section, inline with the conversation transcript. *)
[ document pendingChangesetIds isEmpty ] whileFalse: [
| id |
id:: document pendingChangesetIds removeFirst.
document appendChangesetAmpletFor: id
].
(* super flipped waiting back to false. Re-publish so the thinking
indicator is replaced with the model picker. *)
document mutateContentsTo: document contents.
ide browsing updateCurrentDisplay
)
public markError: msg <String> = (
super markError: msg.
document appendErrorSection: msg.
ide browsing updateCurrentDisplay
)
public sendText: text <String> attachments: docs <List> ^<Alien[Promise] | UndefinedObject> = (
(* Like sendText: but forwards document attachments down the chain.
docs is a List of { filename <String>. content <String> } tuples.
Mirrors the structure of sendText: exactly. *)
| promise <Alien[Promise] | UndefinedObject> |
text isEmpty ifTrue: [ ^nil ].
document appendUserSection: text.
lastTurnIndexBeforeSend:: session displayMessages size.
promise:: super sendText: text attachments: docs.
(* Re-publish via a no-op mutation so the live view picks up the
thinking indicator from the waiting flag super just set. *)
document mutateContentsTo: document contents.
^promise
)
) : ()
public class AIInstallStrategy onRecords: recs <List> changesetId: csid <String> onApplied: appliedBlk <[:String :Integer | Object]> onCanceled: canceledBlk <[]> onComputed: computedBlk <[]> = ApplyStrategy new (
(* Strategy used by ChangesetSubject in the AI chat flow. When the
user clicks "Apply" on a ChangesetPresenter rendered from a
proposed AI changeset, this strategy installs the underlying
records via the atomic installer and removes the entry from
proposedChangesets. Three caller-supplied blocks let the host
(ChatDocument or inline IDEChatSubject) react:
appliedBlock fires after a successful install with
(changesetId, recordCount); host can post a
confirmation turn / banner.
canceledBlock fires when the user clicks Cancel; host forgets
any cached presenter and forces a refresh.
computedBlock fires after the synchronous compute simulates
the changeset and the presenter's refreshBlock
has been invoked. Doc-backed hosts use this to
fire contentsChanged on the chat doc — without
it, refreshBlock = updateGUI: only rebuilds
Hopscotch presenter substances and the doc's
live view (which observes contentsChanged) keeps
showing the cached "Computing changeset..." label
until the user manually refreshes. Inline hosts
pass an empty block because their presenter does
re-render on updateGUI:.
Lexical access to the enclosing AI_IDE_Support module gives the
strategy direct call sites for applyChangesetRecords:,
proposedChangesets, and simulateRecordsAsChangeset:. *)
|
private records <List> = recs.
private changesetId <String> = csid.
private appliedBlock <[:String :Integer | Object]> = appliedBlk.
private canceledBlock <[]> = canceledBlk.
private computedBlock <[]> = computedBlk.
|
) (
public actionLabel ^<String> = (
^'Apply'
)
public requiresMessage ^<Boolean> = (
^false
)
public liveNamespace = (
^Root
)
public title ^<String> = (
^'Apply changeset ' , changesetId
)
public requiresAuthor ^<Boolean> = (
^false
)
public requiresApply ^<Boolean> = (
^true
)
public validate: input <Map> onError: errBlock <[:String | Object]> ^<Boolean> = (
^true
)
public onCancel = (
(* Discard the proposal. Drop the staged records from
proposedChangesets, then let the caller forget any cached
presenter and force a host-side refresh so the amplet flips
to the "no longer pending" banner. *)
proposedChangesets removeKey: changesetId ifAbsent: [ ].
canceledBlock value
)
public apply: changeset <Map> withInput: input <Map> ^<Alien> = (
(* Install the records, remove from proposedChangesets, fire the
appliedBlock for host-specific bookkeeping (e.g. doc-backed
chats append a confirmation turn; inline chats no-op and let
the post-submit banner inside the presenter speak for itself),
then resolve. Errors are caught and pushed through
Promise.reject — raising across the alien bridge would corrupt
the live view. *)
| deferred resolveFn rejectFn |
deferred:: aiAccess JSPromise withResolvers.
resolveFn:: deferred at: 'resolve'.
rejectFn:: deferred at: 'reject'.
[
applyChangesetRecords: records.
proposedChangesets removeKey: changesetId ifAbsent: [ ].
appliedBlock value: changesetId value: records size.
resolveFn call: js undefined with: changesetId
] on: Exception
do: [:e |
| msg |
msg:: [ e messageText ] on: Exception do: [:e2 | nil ].
msg isNil ifTrue: [ msg:: e printString ].
rejectFn call: js undefined with: msg
].
^deferred at: 'promise'
)
public computeChangesetOnComplete: doneBlock <[:Map | Object]> onError: errBlock <[:String | Object]> = (
(* Defer the synchronous compute via setTimeout(0). The
ChangesetSubject's computeAsync: hook delivers the result by
calling refreshBlock, which fires updateGUI: — bumping
uiGeneration and triggering a full re-render. If we ran
synchronously here we'd be inside the current render's
processChildren / currentFragment visual call, and the nested
re-render would re-enter that path, sometimes leaving the chat
doc with a half-rendered amplet (the model picker and input
editor amplets that follow this one silently fail to attach
their visuals). The repo strategies don't see this because
their git operations resolve asynchronously, so refreshBlock
only fires after the current frame unwinds; this setTimeout(0)
matches that timing for the pure-synchronous AI flow. *)
js global setTimeout: [
[
doneBlock value: (simulateRecordsAsChangeset: records).
computedBlock value
] on: Exception
do: [:e |
| msg |
msg:: [ e messageText ] on: Exception do: [:e2 | nil ].
msg isNil ifTrue: [ msg:: e printString ].
errBlock value: msg
].
nil
]
with: 0
)
) : ()
lazy public repositories = ide repositories.
lazy public ApplyStrategy = repositories ApplyStrategy.
lazy public ChangesetSubject = repositories ChangesetSubject.
lazy public ChangesetPresenter = repositories ChangesetPresenter.
lazy public StaleChangesetPresenter = repositories StaleChangesetPresenter.
lazy public StaleChangesetSubject = repositories StaleChangesetSubject.
ensureModelMachineryGap: container <Object> beforeThinking: thinking <Object> ^<Object> = (
(* Find the block-level gap that sits between the chat content and
the model machinery, creating one in-place if the doc was
built before initialChatDocumentContents: started emitting one.
Returned element is used as the insertion anchor by
insertTurnSection: and appendChangesetAmpletFor: so every new
row lands above the gap and the model picker / input editor
stay on their own line below it. *)
| gaps gap jsDoc parent |
gaps:: container getElementsByClassName: 'modelMachineryGap'.
(gaps at: 'length') > 0 ifTrue: [ ^gaps at: 0 ].
jsDoc:: js global at: 'document'.
gap:: jsDoc createElement: 'div'.
gap setAttribute: 'class' to: 'modelMachineryGap'.
gap setAttribute: 'style'
to: 'display:block; min-height:3em; padding:0.5em; margin:0.5em 0;'.
(* No contenteditable override — inherits "true" from the
self_ampleforth wrapper so the user can type freeform notes