pi-learning-agent 0.1.0

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 (187) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CONTRIBUTING.md +34 -0
  3. package/LICENSE +21 -0
  4. package/README.md +154 -0
  5. package/SECURITY.md +9 -0
  6. package/docs/architecture.md +75 -0
  7. package/docs/images/learning-workspace.png +0 -0
  8. package/docs/releasing.md +51 -0
  9. package/extension/commands.ts +181 -0
  10. package/extension/index.ts +50 -0
  11. package/extension/learning-session.ts +75 -0
  12. package/extension/runner/code-runner.ts +275 -0
  13. package/extension/server/interaction-broker.ts +345 -0
  14. package/extension/server/learning-server.ts +753 -0
  15. package/extension/server/sse-hub.ts +63 -0
  16. package/extension/state/learning-state.ts +458 -0
  17. package/extension/state/session-persistence.ts +72 -0
  18. package/extension/tools/ask-code.ts +97 -0
  19. package/extension/tools/ask-free-response.ts +88 -0
  20. package/extension/tools/ask-multi-choice.ts +109 -0
  21. package/extension/tools/ask-record-attempt.ts +50 -0
  22. package/extension/tools/ask-single-choice.ts +110 -0
  23. package/extension/tools/index.ts +39 -0
  24. package/extension/tools/tui-presenter.ts +609 -0
  25. package/extension/transcript-sync.ts +197 -0
  26. package/extension/tutor-prompt.ts +64 -0
  27. package/extension/utils/browser.ts +35 -0
  28. package/extension/utils/ids.ts +5 -0
  29. package/extension/utils/validation.ts +138 -0
  30. package/package.json +82 -0
  31. package/shared/learning-state.ts +62 -0
  32. package/shared/protocol.ts +236 -0
  33. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  34. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  35. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  36. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  37. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  38. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  39. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  40. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  41. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  42. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  43. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  44. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  45. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  46. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  47. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  48. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  49. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  50. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  51. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  52. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  53. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  54. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  55. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  56. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  57. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  58. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  59. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  60. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  61. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  62. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  63. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  64. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  65. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  66. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  67. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  68. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  69. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  70. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  71. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  72. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  73. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  74. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  75. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  76. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  77. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  78. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  79. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  80. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  81. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  82. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  83. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  84. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  85. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  86. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  87. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  88. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  89. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  90. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  91. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  92. package/web/dist/assets/abap-08VXUWAP.js +1 -0
  93. package/web/dist/assets/apex-BWPQTe0t.js +1 -0
  94. package/web/dist/assets/azcli-Bc_sGQ0U.js +1 -0
  95. package/web/dist/assets/bat-i0X4ZdIN.js +1 -0
  96. package/web/dist/assets/bicep-B5-_aFwp.js +2 -0
  97. package/web/dist/assets/cameligo-DMUM7wLl.js +1 -0
  98. package/web/dist/assets/clojure-Cm7r79vr.js +1 -0
  99. package/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
  100. package/web/dist/assets/coffee-Ba7i2nA0.js +1 -0
  101. package/web/dist/assets/cpp-C7h46wYY.js +1 -0
  102. package/web/dist/assets/csharp-BKxtCVv1.js +1 -0
  103. package/web/dist/assets/csp-bTuwJoIa.js +1 -0
  104. package/web/dist/assets/css-DIMkf-bt.js +3 -0
  105. package/web/dist/assets/css.worker-DRH2OcAG.js +93 -0
  106. package/web/dist/assets/cssMode-BpvqYhpN.js +1 -0
  107. package/web/dist/assets/cypher-CVaqCwHa.js +1 -0
  108. package/web/dist/assets/dart-onAF5SnQ.js +1 -0
  109. package/web/dist/assets/dockerfile-DZFCIeNp.js +1 -0
  110. package/web/dist/assets/ecl-D05T4iGw.js +1 -0
  111. package/web/dist/assets/editor.worker-D9zwrD0f.js +30 -0
  112. package/web/dist/assets/elixir-6RTg0lbw.js +1 -0
  113. package/web/dist/assets/flow9-C5_-GSwl.js +1 -0
  114. package/web/dist/assets/freemarker2-CmTivYCM.js +3 -0
  115. package/web/dist/assets/fsharp-C8Ef5oNN.js +1 -0
  116. package/web/dist/assets/go-C-y9NEjX.js +1 -0
  117. package/web/dist/assets/graphql-fmXr3nnJ.js +1 -0
  118. package/web/dist/assets/handlebars-ClhpojlY.js +1 -0
  119. package/web/dist/assets/hcl-CpzslTdj.js +1 -0
  120. package/web/dist/assets/html-CTqIxFH7.js +1 -0
  121. package/web/dist/assets/html.worker-DQLddT9h.js +506 -0
  122. package/web/dist/assets/htmlMode-BKCTQ4_a.js +1 -0
  123. package/web/dist/assets/index-6N_lnfyE.js +1243 -0
  124. package/web/dist/assets/index-B5x0MzHm.css +1 -0
  125. package/web/dist/assets/ini-sBoK_t0W.js +1 -0
  126. package/web/dist/assets/java-BEtHBSE6.js +1 -0
  127. package/web/dist/assets/javascript-Cp_q6aWP.js +1 -0
  128. package/web/dist/assets/json.worker-D_Dqrls5.js +62 -0
  129. package/web/dist/assets/jsonMode-CMDRrSMX.js +7 -0
  130. package/web/dist/assets/julia-Bri6UV-V.js +1 -0
  131. package/web/dist/assets/kotlin-BOotOW0E.js +1 -0
  132. package/web/dist/assets/less-B9JPFI3C.js +2 -0
  133. package/web/dist/assets/lexon-CfSJPG6W.js +1 -0
  134. package/web/dist/assets/liquid-BFK8j46k.js +1 -0
  135. package/web/dist/assets/lspLanguageFeatures-nT2b0EDJ.js +4 -0
  136. package/web/dist/assets/lua-CsQS60Ue.js +1 -0
  137. package/web/dist/assets/m3-D-oSqn_W.js +1 -0
  138. package/web/dist/assets/markdown-Cimd5fb3.js +1 -0
  139. package/web/dist/assets/mdx-cEJtnt-p.js +1 -0
  140. package/web/dist/assets/mips-CIPQ_RoX.js +1 -0
  141. package/web/dist/assets/msdax-DauUninz.js +1 -0
  142. package/web/dist/assets/mysql-SOo6toE5.js +1 -0
  143. package/web/dist/assets/objective-c-FvmIjYaQ.js +1 -0
  144. package/web/dist/assets/pascal-DrH0SRf2.js +1 -0
  145. package/web/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
  146. package/web/dist/assets/perl-oz_6vUea.js +1 -0
  147. package/web/dist/assets/pgsql-DTj74zXo.js +1 -0
  148. package/web/dist/assets/php-nr791fC2.js +1 -0
  149. package/web/dist/assets/pla-CopQ2nXW.js +1 -0
  150. package/web/dist/assets/postiats-43DmfD33.js +1 -0
  151. package/web/dist/assets/powerquery-D3hlyOfw.js +1 -0
  152. package/web/dist/assets/powershell-DmHpPYUd.js +1 -0
  153. package/web/dist/assets/protobuf-C531GsRP.js +2 -0
  154. package/web/dist/assets/pug-Z5eAx3Zn.js +1 -0
  155. package/web/dist/assets/python-bhI_n2_n.js +1 -0
  156. package/web/dist/assets/qsharp-DkqhCAOL.js +1 -0
  157. package/web/dist/assets/r-BwWrilGY.js +1 -0
  158. package/web/dist/assets/razor-D40-I8aA.js +1 -0
  159. package/web/dist/assets/redis-ClamHrr6.js +1 -0
  160. package/web/dist/assets/redshift-DT7zqm-g.js +1 -0
  161. package/web/dist/assets/restructuredtext-BYgofb2h.js +1 -0
  162. package/web/dist/assets/ruby-DezsRK8O.js +1 -0
  163. package/web/dist/assets/rust-DdL9SqIa.js +1 -0
  164. package/web/dist/assets/sb-CcwsVR0C.js +1 -0
  165. package/web/dist/assets/scala-DHpiXF5c.js +1 -0
  166. package/web/dist/assets/scheme-BeGwcela.js +1 -0
  167. package/web/dist/assets/scss-gp-XZpBa.js +3 -0
  168. package/web/dist/assets/shell-CC2rA5mh.js +1 -0
  169. package/web/dist/assets/solidity-BEEn4gHE.js +1 -0
  170. package/web/dist/assets/sophia-CRfGWb83.js +1 -0
  171. package/web/dist/assets/sparql-D_Lu-MrJ.js +1 -0
  172. package/web/dist/assets/sql-NEE52Syq.js +1 -0
  173. package/web/dist/assets/st-DbInun42.js +1 -0
  174. package/web/dist/assets/swift-Bxkupp3x.js +1 -0
  175. package/web/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
  176. package/web/dist/assets/tcl-DISqw1ZD.js +1 -0
  177. package/web/dist/assets/ts.worker-DkYoA6bF.js +67738 -0
  178. package/web/dist/assets/tsMode-C9OJ2Aji.js +11 -0
  179. package/web/dist/assets/twig-De2hgUGE.js +1 -0
  180. package/web/dist/assets/typescript-CZ5RbSdB.js +1 -0
  181. package/web/dist/assets/typespec-B8J7ngcE.js +1 -0
  182. package/web/dist/assets/vb-DV3o63ZY.js +1 -0
  183. package/web/dist/assets/wgsl-DpFanUEy.js +298 -0
  184. package/web/dist/assets/workers-qb_EZhxe.js +1 -0
  185. package/web/dist/assets/xml-BdDnAXLl.js +1 -0
  186. package/web/dist/assets/yaml-BSYHoVQy.js +1 -0
  187. package/web/dist/index.html +23 -0
@@ -0,0 +1,609 @@
1
+ import {
2
+ CustomEditor,
3
+ getSelectListTheme,
4
+ type ExtensionContext
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { InteractionBroker } from "../server/interaction-broker.js";
8
+ import type {
9
+ CodeExerciseInteraction,
10
+ CodeExerciseResolvedAnswer,
11
+ FreeResponseInteraction,
12
+ FreeResponseResolvedAnswer,
13
+ LearningInteraction,
14
+ MultiChoiceInteraction,
15
+ MultiChoiceResolvedAnswer,
16
+ ResolvedAnswer,
17
+ SingleChoiceInteraction,
18
+ SingleChoiceResolvedAnswer,
19
+ SkippedResolvedAnswer
20
+ } from "../../shared/protocol.js";
21
+
22
+ /** Single/multi choice answers may end as a structured skip (spec 7.5). */
23
+ export type ChoiceResolvedAnswer =
24
+ | SingleChoiceResolvedAnswer
25
+ | SkippedResolvedAnswer;
26
+
27
+ export type MultiChoiceResolvedAnswerOrSkip =
28
+ | MultiChoiceResolvedAnswer
29
+ | SkippedResolvedAnswer;
30
+
31
+ export interface TuiLearningPresenter {
32
+ presentSingleChoice(
33
+ interaction: SingleChoiceInteraction,
34
+ signal: AbortSignal | undefined,
35
+ ctx: ExtensionContext
36
+ ): Promise<ChoiceResolvedAnswer>;
37
+ presentMultiChoice(
38
+ interaction: MultiChoiceInteraction,
39
+ signal: AbortSignal | undefined,
40
+ ctx: ExtensionContext
41
+ ): Promise<MultiChoiceResolvedAnswerOrSkip>;
42
+ presentFreeResponse(
43
+ interaction: FreeResponseInteraction,
44
+ signal: AbortSignal | undefined,
45
+ ctx: ExtensionContext
46
+ ): Promise<FreeResponseResolvedAnswer>;
47
+ presentCode(
48
+ interaction: CodeExerciseInteraction,
49
+ signal: AbortSignal | undefined,
50
+ ctx: ExtensionContext
51
+ ): Promise<CodeExerciseResolvedAnswer>;
52
+ }
53
+
54
+ export function createTuiPresenter(now: () => number = Date.now): TuiLearningPresenter {
55
+ return {
56
+ presentSingleChoice: (interaction, signal, ctx) =>
57
+ presentSingleChoiceInTui(interaction, signal, ctx, now),
58
+ presentMultiChoice: (interaction, signal, ctx) =>
59
+ presentMultiChoiceInTui(interaction, signal, ctx, now),
60
+ presentFreeResponse: (interaction, signal, ctx) =>
61
+ presentFreeResponseInTui(interaction, signal, ctx, now),
62
+ presentCode: (interaction, signal, ctx) =>
63
+ presentCodeInTui(interaction, signal, ctx, now)
64
+ };
65
+ }
66
+
67
+ export function createBrokerBackedTuiPresenter(
68
+ broker: InteractionBroker,
69
+ now: () => number = Date.now
70
+ ): TuiLearningPresenter {
71
+ const tui = createTuiPresenter(now);
72
+ return {
73
+ presentSingleChoice: (interaction, signal, ctx) =>
74
+ presentThroughBroker(broker, interaction, signal, (combinedSignal) =>
75
+ tui.presentSingleChoice(interaction, combinedSignal, ctx)
76
+ ),
77
+ presentMultiChoice: (interaction, signal, ctx) =>
78
+ presentThroughBroker(broker, interaction, signal, (combinedSignal) =>
79
+ tui.presentMultiChoice(interaction, combinedSignal, ctx)
80
+ ),
81
+ presentFreeResponse: (interaction, signal, ctx) =>
82
+ presentThroughBroker(broker, interaction, signal, (combinedSignal) =>
83
+ tui.presentFreeResponse(interaction, combinedSignal, ctx)
84
+ ),
85
+ presentCode: (interaction, signal, ctx) =>
86
+ presentThroughBroker(broker, interaction, signal, (combinedSignal) =>
87
+ tui.presentCode(interaction, combinedSignal, ctx)
88
+ )
89
+ };
90
+ }
91
+
92
+ /**
93
+ * uiMode auto (spec 28): with a web client the interaction is presented to
94
+ * the broker only and waits for the browser to submit via the server; the
95
+ * TUI is not touched. Without a web client, fully reuses
96
+ * createBrokerBackedTuiPresenter (broker + TUI race).
97
+ */
98
+ export function createModeAwarePresenter(
99
+ broker: InteractionBroker,
100
+ hasWebClient: () => boolean,
101
+ now: () => number = Date.now
102
+ ): TuiLearningPresenter {
103
+ const fallback = createBrokerBackedTuiPresenter(broker, now);
104
+ return {
105
+ presentSingleChoice: (interaction, signal, ctx) =>
106
+ hasWebClient()
107
+ ? presentViaBrokerWithFallback(
108
+ broker,
109
+ hasWebClient,
110
+ interaction,
111
+ signal,
112
+ () => fallback.presentSingleChoice(interaction, signal, ctx)
113
+ )
114
+ : fallback.presentSingleChoice(interaction, signal, ctx),
115
+ presentMultiChoice: (interaction, signal, ctx) =>
116
+ hasWebClient()
117
+ ? presentViaBrokerWithFallback(
118
+ broker,
119
+ hasWebClient,
120
+ interaction,
121
+ signal,
122
+ () => fallback.presentMultiChoice(interaction, signal, ctx)
123
+ )
124
+ : fallback.presentMultiChoice(interaction, signal, ctx),
125
+ presentFreeResponse: (interaction, signal, ctx) =>
126
+ hasWebClient()
127
+ ? presentViaBrokerWithFallback(
128
+ broker,
129
+ hasWebClient,
130
+ interaction,
131
+ signal,
132
+ () => fallback.presentFreeResponse(interaction, signal, ctx)
133
+ )
134
+ : fallback.presentFreeResponse(interaction, signal, ctx),
135
+ presentCode: (interaction, signal, ctx) =>
136
+ hasWebClient()
137
+ ? presentViaBrokerWithFallback(
138
+ broker,
139
+ hasWebClient,
140
+ interaction,
141
+ signal,
142
+ () => fallback.presentCode(interaction, signal, ctx)
143
+ )
144
+ : fallback.presentCode(interaction, signal, ctx)
145
+ };
146
+ }
147
+
148
+ const WEB_DISCONNECT_POLL_MS = 250;
149
+
150
+ function presentViaBroker(
151
+ broker: InteractionBroker,
152
+ interaction: SingleChoiceInteraction,
153
+ signal: AbortSignal | undefined
154
+ ): Promise<ChoiceResolvedAnswer>;
155
+ function presentViaBroker(
156
+ broker: InteractionBroker,
157
+ interaction: MultiChoiceInteraction,
158
+ signal: AbortSignal | undefined
159
+ ): Promise<MultiChoiceResolvedAnswerOrSkip>;
160
+ function presentViaBroker(
161
+ broker: InteractionBroker,
162
+ interaction: FreeResponseInteraction,
163
+ signal: AbortSignal | undefined
164
+ ): Promise<FreeResponseResolvedAnswer>;
165
+ function presentViaBroker(
166
+ broker: InteractionBroker,
167
+ interaction: CodeExerciseInteraction,
168
+ signal: AbortSignal | undefined
169
+ ): Promise<CodeExerciseResolvedAnswer>;
170
+ function presentViaBroker(
171
+ broker: InteractionBroker,
172
+ interaction: LearningInteraction,
173
+ signal: AbortSignal | undefined
174
+ ): Promise<ResolvedAnswer> {
175
+ // Web-only path: the browser submits through the server; broker.present
176
+ // rejects on tool abort, so the tool surfaces the cancellation normally.
177
+ return broker.present(interaction, signal);
178
+ }
179
+
180
+ function presentViaBrokerWithFallback(
181
+ broker: InteractionBroker,
182
+ hasWebClient: () => boolean,
183
+ interaction: SingleChoiceInteraction,
184
+ signal: AbortSignal | undefined,
185
+ fallback: () => Promise<ChoiceResolvedAnswer>
186
+ ): Promise<ChoiceResolvedAnswer>;
187
+ function presentViaBrokerWithFallback(
188
+ broker: InteractionBroker,
189
+ hasWebClient: () => boolean,
190
+ interaction: MultiChoiceInteraction,
191
+ signal: AbortSignal | undefined,
192
+ fallback: () => Promise<MultiChoiceResolvedAnswerOrSkip>
193
+ ): Promise<MultiChoiceResolvedAnswerOrSkip>;
194
+ function presentViaBrokerWithFallback(
195
+ broker: InteractionBroker,
196
+ hasWebClient: () => boolean,
197
+ interaction: FreeResponseInteraction,
198
+ signal: AbortSignal | undefined,
199
+ fallback: () => Promise<FreeResponseResolvedAnswer>
200
+ ): Promise<FreeResponseResolvedAnswer>;
201
+ function presentViaBrokerWithFallback(
202
+ broker: InteractionBroker,
203
+ hasWebClient: () => boolean,
204
+ interaction: CodeExerciseInteraction,
205
+ signal: AbortSignal | undefined,
206
+ fallback: () => Promise<CodeExerciseResolvedAnswer>
207
+ ): Promise<CodeExerciseResolvedAnswer>;
208
+ function presentViaBrokerWithFallback(
209
+ broker: InteractionBroker,
210
+ hasWebClient: () => boolean,
211
+ interaction: LearningInteraction,
212
+ signal: AbortSignal | undefined,
213
+ fallback: () => Promise<ResolvedAnswer>
214
+ ): Promise<ResolvedAnswer> {
215
+ return new Promise<ResolvedAnswer>((resolve, reject) => {
216
+ let timer: ReturnType<typeof setTimeout> | undefined;
217
+ let switchingToTui = false;
218
+ let settled = false;
219
+ const cleanup = () => {
220
+ if (timer !== undefined) {
221
+ clearTimeout(timer);
222
+ timer = undefined;
223
+ }
224
+ };
225
+ const resolveOnce = (answer: ResolvedAnswer) => {
226
+ if (settled) {
227
+ return;
228
+ }
229
+ settled = true;
230
+ cleanup();
231
+ resolve(answer);
232
+ };
233
+ const rejectOnce = (error: unknown) => {
234
+ if (settled) {
235
+ return;
236
+ }
237
+ settled = true;
238
+ cleanup();
239
+ reject(error);
240
+ };
241
+
242
+ void broker.present(interaction, signal).then(
243
+ (answer) => {
244
+ if (!switchingToTui) {
245
+ resolveOnce(answer);
246
+ }
247
+ },
248
+ (error: unknown) => {
249
+ // broker.cancel("web_disconnected") is expected during the handoff;
250
+ // the fallback presenter now owns the interaction result.
251
+ if (!switchingToTui) {
252
+ rejectOnce(error);
253
+ }
254
+ }
255
+ );
256
+
257
+ const poll = () => {
258
+ if (settled || switchingToTui) {
259
+ return;
260
+ }
261
+ if (!hasWebClient()) {
262
+ const stillPending = broker
263
+ .getPending()
264
+ .some((pending) => pending.id === interaction.id);
265
+ if (!stillPending) {
266
+ return;
267
+ }
268
+ switchingToTui = true;
269
+ // Reject the Web wait before opening the TUI so only one broker entry
270
+ // can own this interaction at a time.
271
+ broker.cancel(interaction.id, "web_disconnected");
272
+ void fallback().then(resolveOnce, rejectOnce);
273
+ return;
274
+ }
275
+ timer = setTimeout(poll, WEB_DISCONNECT_POLL_MS);
276
+ };
277
+ timer = setTimeout(poll, WEB_DISCONNECT_POLL_MS);
278
+ });
279
+ }
280
+
281
+ export async function presentSingleChoiceInTui(
282
+ interaction: SingleChoiceInteraction,
283
+ signal: AbortSignal | undefined,
284
+ ctx: ExtensionContext,
285
+ now: () => number
286
+ ): Promise<ChoiceResolvedAnswer> {
287
+ requireUi(ctx, "Single-choice learning");
288
+ throwIfAborted(interaction.id, signal);
289
+
290
+ const choices = interaction.options.map(
291
+ (option, index) => `${index + 1}. ${option.label}`
292
+ );
293
+ const skipItem = "跳过此题";
294
+ const menu = interaction.allowSkip ? [...choices, skipItem] : choices;
295
+ const selected = await ctx.ui.select(
296
+ interaction.title === undefined
297
+ ? interaction.question
298
+ : `${interaction.title}\n${interaction.question}`,
299
+ menu,
300
+ signal === undefined ? undefined : { signal }
301
+ );
302
+ throwIfAborted(interaction.id, signal);
303
+ if (selected === undefined) {
304
+ throw new Error(`Learner cancelled interaction ${interaction.id}.`);
305
+ }
306
+ if (selected === skipItem) {
307
+ return {
308
+ interactionId: interaction.id,
309
+ type: interaction.type,
310
+ skipped: true,
311
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
312
+ };
313
+ }
314
+
315
+ const option = interaction.options[choices.indexOf(selected)];
316
+ if (option === undefined) {
317
+ throw new Error(`TUI returned an unknown choice for ${interaction.id}.`);
318
+ }
319
+
320
+ return {
321
+ interactionId: interaction.id,
322
+ type: interaction.type,
323
+ answer: { optionId: option.id },
324
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Multi-select via repeated ctx.ui.select dialogs: pi-tui's SelectList has no
330
+ * multiSelect mode and pi-tui ships no checkbox component, so each option is
331
+ * picked in its own dialog; a trailing “done” entry finishes the loop.
332
+ * Selecting an already-picked option toggles it off. Esc cancels.
333
+ */
334
+ export async function presentMultiChoiceInTui(
335
+ interaction: MultiChoiceInteraction,
336
+ signal: AbortSignal | undefined,
337
+ ctx: ExtensionContext,
338
+ now: () => number
339
+ ): Promise<MultiChoiceResolvedAnswerOrSkip> {
340
+ requireUi(ctx, "Multi-choice learning");
341
+ throwIfAborted(interaction.id, signal);
342
+
343
+ const baseTitle =
344
+ interaction.title === undefined
345
+ ? interaction.question
346
+ : `${interaction.title}\n${interaction.question}`;
347
+ const choices = interaction.options.map(
348
+ (option, index) => `${index + 1}. ${option.label}`
349
+ );
350
+ const doneItem = "✔ 完成";
351
+ const skipItem = "跳过此题";
352
+ const menu = interaction.allowSkip
353
+ ? [...choices, doneItem, skipItem]
354
+ : [...choices, doneItem];
355
+ const selected = new Set<string>();
356
+
357
+ for (;;) {
358
+ const prompt =
359
+ selected.size === 0
360
+ ? `${baseTitle}\n(可能有多个正确答案,可多次选择)`
361
+ : `${baseTitle}\n(已选 ${selected.size} 项,可继续选择,选“✔ 完成”结束)`;
362
+ const pick = await ctx.ui.select(
363
+ prompt,
364
+ menu,
365
+ signal === undefined ? undefined : { signal }
366
+ );
367
+ throwIfAborted(interaction.id, signal);
368
+ if (pick === undefined) {
369
+ throw new Error(`Learner cancelled interaction ${interaction.id}.`);
370
+ }
371
+ if (pick === skipItem) {
372
+ return {
373
+ interactionId: interaction.id,
374
+ type: interaction.type,
375
+ skipped: true,
376
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
377
+ };
378
+ }
379
+ if (pick === doneItem) {
380
+ if (selected.size === 0) {
381
+ ctx.ui.notify("至少选择一个选项后再完成。", "warning");
382
+ continue;
383
+ }
384
+ break;
385
+ }
386
+ const option = interaction.options[choices.indexOf(pick)];
387
+ if (option === undefined) {
388
+ throw new Error(`TUI returned an unknown choice for ${interaction.id}.`);
389
+ }
390
+ if (selected.has(option.id)) {
391
+ selected.delete(option.id);
392
+ } else {
393
+ selected.add(option.id);
394
+ }
395
+ }
396
+
397
+ return {
398
+ interactionId: interaction.id,
399
+ type: interaction.type,
400
+ answer: { optionIds: [...selected] },
401
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
402
+ };
403
+ }
404
+
405
+ export async function presentFreeResponseInTui(
406
+ interaction: FreeResponseInteraction,
407
+ signal: AbortSignal | undefined,
408
+ ctx: ExtensionContext,
409
+ now: () => number
410
+ ): Promise<FreeResponseResolvedAnswer> {
411
+ requireUi(ctx, "Free-response learning");
412
+ throwIfAborted(interaction.id, signal);
413
+
414
+ const text = interaction.multiline
415
+ ? await presentMultilineEditor(ctx, interaction.question, "", signal)
416
+ : await ctx.ui.input(
417
+ interaction.question,
418
+ interaction.placeholder,
419
+ signal === undefined ? undefined : { signal }
420
+ );
421
+ throwIfAborted(interaction.id, signal);
422
+ if (text === undefined) {
423
+ throw new Error(`Learner cancelled interaction ${interaction.id}.`);
424
+ }
425
+
426
+ return {
427
+ interactionId: interaction.id,
428
+ type: interaction.type,
429
+ answer: { text },
430
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
431
+ };
432
+ }
433
+
434
+ export async function presentCodeInTui(
435
+ interaction: CodeExerciseInteraction,
436
+ signal: AbortSignal | undefined,
437
+ ctx: ExtensionContext,
438
+ now: () => number
439
+ ): Promise<CodeExerciseResolvedAnswer> {
440
+ requireUi(ctx, "Code learning");
441
+ throwIfAborted(interaction.id, signal);
442
+
443
+ const code = await presentMultilineEditor(
444
+ ctx,
445
+ interaction.title === undefined
446
+ ? interaction.instructions
447
+ : `${interaction.title}\n${interaction.instructions}`,
448
+ interaction.starterCode,
449
+ signal
450
+ );
451
+ throwIfAborted(interaction.id, signal);
452
+ if (code === undefined) {
453
+ throw new Error(`Learner cancelled interaction ${interaction.id}.`);
454
+ }
455
+
456
+ return {
457
+ interactionId: interaction.id,
458
+ type: interaction.type,
459
+ answer: { language: interaction.language, code },
460
+ responseTimeMs: elapsedSince(interaction.createdAt, now)
461
+ };
462
+ }
463
+
464
+ function presentThroughBroker(
465
+ broker: InteractionBroker,
466
+ interaction: SingleChoiceInteraction,
467
+ signal: AbortSignal | undefined,
468
+ collect: (signal: AbortSignal) => Promise<ChoiceResolvedAnswer>
469
+ ): Promise<ChoiceResolvedAnswer>;
470
+ function presentThroughBroker(
471
+ broker: InteractionBroker,
472
+ interaction: MultiChoiceInteraction,
473
+ signal: AbortSignal | undefined,
474
+ collect: (signal: AbortSignal) => Promise<MultiChoiceResolvedAnswerOrSkip>
475
+ ): Promise<MultiChoiceResolvedAnswerOrSkip>;
476
+ function presentThroughBroker(
477
+ broker: InteractionBroker,
478
+ interaction: FreeResponseInteraction,
479
+ signal: AbortSignal | undefined,
480
+ collect: (signal: AbortSignal) => Promise<FreeResponseResolvedAnswer>
481
+ ): Promise<FreeResponseResolvedAnswer>;
482
+ function presentThroughBroker(
483
+ broker: InteractionBroker,
484
+ interaction: CodeExerciseInteraction,
485
+ signal: AbortSignal | undefined,
486
+ collect: (signal: AbortSignal) => Promise<CodeExerciseResolvedAnswer>
487
+ ): Promise<CodeExerciseResolvedAnswer>;
488
+ async function presentThroughBroker(
489
+ broker: InteractionBroker,
490
+ interaction: LearningInteraction,
491
+ signal: AbortSignal | undefined,
492
+ collect: (signal: AbortSignal) => Promise<ResolvedAnswer>
493
+ ): Promise<ResolvedAnswer> {
494
+ const localAbort = new AbortController();
495
+ const onToolAbort = () => localAbort.abort();
496
+ if (signal?.aborted) {
497
+ localAbort.abort();
498
+ } else {
499
+ signal?.addEventListener("abort", onToolAbort, { once: true });
500
+ }
501
+
502
+ const pending = broker.present(interaction, localAbort.signal);
503
+ void pending.catch(() => {
504
+ localAbort.abort();
505
+ });
506
+
507
+ try {
508
+ const outcome = await Promise.race([
509
+ collect(localAbort.signal).then((answer) => ({
510
+ source: "tui" as const,
511
+ answer
512
+ })),
513
+ pending.then((answer) => ({ source: "broker" as const, answer }))
514
+ ]);
515
+ if (outcome.source === "broker") {
516
+ localAbort.abort();
517
+ return outcome.answer;
518
+ }
519
+
520
+ // The TUI resolved the interaction: feed the result back through the
521
+ // broker so the web client sees the same resolved/cancelled events. A
522
+ // skipped TUI answer goes through broker.skip, not submit.
523
+ const submission = "skipped" in outcome.answer
524
+ ? broker.skip(interaction.id)
525
+ : broker.submit({
526
+ interactionId: interaction.id,
527
+ answer: outcome.answer.answer,
528
+ clientTimestamp: Date.now()
529
+ });
530
+ if (!submission.ok) {
531
+ throw new Error(submission.message);
532
+ }
533
+
534
+ return await pending;
535
+ } catch (error) {
536
+ broker.cancel(interaction.id, "tui_interaction_failed");
537
+ await pending.catch(() => undefined);
538
+ throw error;
539
+ } finally {
540
+ signal?.removeEventListener("abort", onToolAbort);
541
+ }
542
+ }
543
+
544
+ function requireUi(ctx: ExtensionContext, activity: string): void {
545
+ if (!ctx.hasUI) {
546
+ throw new Error(`${activity} requires an interactive Pi UI.`);
547
+ }
548
+ }
549
+
550
+ async function presentMultilineEditor(
551
+ ctx: ExtensionContext,
552
+ title: string,
553
+ prefill: string,
554
+ signal: AbortSignal | undefined
555
+ ): Promise<string | undefined> {
556
+ if (ctx.mode !== "tui") {
557
+ return ctx.ui.editor(title, prefill);
558
+ }
559
+
560
+ ctx.ui.notify(title, "info");
561
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
562
+ const editor = new CustomEditor(
563
+ tui,
564
+ {
565
+ borderColor: (text) => theme.fg("accent", text),
566
+ selectList: getSelectListTheme()
567
+ },
568
+ keybindings,
569
+ { paddingX: 1 }
570
+ ) as CustomEditor & { dispose?: () => void };
571
+ editor.setText(prefill);
572
+
573
+ let settled = false;
574
+ const finish = (value: string | undefined) => {
575
+ if (settled) {
576
+ return;
577
+ }
578
+ settled = true;
579
+ signal?.removeEventListener("abort", onAbort);
580
+ done(value);
581
+ };
582
+ const onAbort = () => finish(undefined);
583
+
584
+ editor.onSubmit = (text) => finish(text);
585
+ editor.onEscape = () => finish(undefined);
586
+ editor.dispose = () => {
587
+ signal?.removeEventListener("abort", onAbort);
588
+ };
589
+ signal?.addEventListener("abort", onAbort, { once: true });
590
+ if (signal?.aborted) {
591
+ queueMicrotask(onAbort);
592
+ }
593
+
594
+ return editor;
595
+ });
596
+ }
597
+
598
+ function throwIfAborted(
599
+ interactionId: string,
600
+ signal: AbortSignal | undefined
601
+ ): void {
602
+ if (signal?.aborted) {
603
+ throw new Error(`Learning interaction ${interactionId} was aborted.`);
604
+ }
605
+ }
606
+
607
+ function elapsedSince(createdAt: number, now: () => number): number {
608
+ return Math.max(0, now() - createdAt);
609
+ }