@pi-in-go/pigpen-pi-typesafe 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 (189) hide show
  1. package/CREDITS.md +14 -0
  2. package/LICENSE +22 -0
  3. package/README.md +45 -0
  4. package/extensions/pi-typesafe/branches_test.go +185 -0
  5. package/extensions/pi-typesafe/command.go +319 -0
  6. package/extensions/pi-typesafe/export_test.go +9 -0
  7. package/extensions/pi-typesafe/extension.go +188 -0
  8. package/extensions/pi-typesafe/extension_test.go +321 -0
  9. package/extensions/pi-typesafe/fakehost_test.go +548 -0
  10. package/extensions/pi-typesafe/format.go +191 -0
  11. package/extensions/pi-typesafe/format_test.go +75 -0
  12. package/extensions/pi-typesafe/go.mod +10 -0
  13. package/extensions/pi-typesafe/go.sum +2 -0
  14. package/extensions/pi-typesafe/go.work +11 -0
  15. package/extensions/pi-typesafe/harness_test.go +200 -0
  16. package/extensions/pi-typesafe/ownmodel_test.go +100 -0
  17. package/extensions/pi-typesafe/review_test.go +134 -0
  18. package/extensions/pi-typesafe/tool.go +193 -0
  19. package/extensions/pi-typesafe/twin_test.go +28 -0
  20. package/libs/pi-typesafe-api/CREDITS.md +14 -0
  21. package/libs/pi-typesafe-api/LICENSE +22 -0
  22. package/libs/pi-typesafe-api/README.md +30 -0
  23. package/libs/pi-typesafe-api/ask.go +62 -0
  24. package/libs/pi-typesafe-api/ask_test.go +76 -0
  25. package/libs/pi-typesafe-api/auth.go +249 -0
  26. package/libs/pi-typesafe-api/auth_test.go +131 -0
  27. package/libs/pi-typesafe-api/backends.go +336 -0
  28. package/libs/pi-typesafe-api/backends_test.go +404 -0
  29. package/libs/pi-typesafe-api/batch.go +202 -0
  30. package/libs/pi-typesafe-api/batch_test.go +202 -0
  31. package/libs/pi-typesafe-api/battery_test.go +41 -0
  32. package/libs/pi-typesafe-api/calibrate.go +354 -0
  33. package/libs/pi-typesafe-api/calibrate_test.go +186 -0
  34. package/libs/pi-typesafe-api/client.go +615 -0
  35. package/libs/pi-typesafe-api/client_test.go +490 -0
  36. package/libs/pi-typesafe-api/credentials.go +252 -0
  37. package/libs/pi-typesafe-api/credentials_test.go +216 -0
  38. package/libs/pi-typesafe-api/doc.go +14 -0
  39. package/libs/pi-typesafe-api/errors.go +143 -0
  40. package/libs/pi-typesafe-api/evaluation.go +86 -0
  41. package/libs/pi-typesafe-api/evaluation_schema.json +264 -0
  42. package/libs/pi-typesafe-api/gaps_test.go +77 -0
  43. package/libs/pi-typesafe-api/go.mod +9 -0
  44. package/libs/pi-typesafe-api/go.sum +2 -0
  45. package/libs/pi-typesafe-api/helpers_test.go +169 -0
  46. package/libs/pi-typesafe-api/hostmodel/hostmodel.go +87 -0
  47. package/libs/pi-typesafe-api/json.go +299 -0
  48. package/libs/pi-typesafe-api/json_test.go +92 -0
  49. package/libs/pi-typesafe-api/ownmodel_test.go +79 -0
  50. package/libs/pi-typesafe-api/package.json +40 -0
  51. package/libs/pi-typesafe-api/provenance.json +18 -0
  52. package/libs/pi-typesafe-api/review_test.go +23 -0
  53. package/libs/pi-typesafe-api/schema.go +473 -0
  54. package/libs/pi-typesafe-api/schema_test.go +262 -0
  55. package/libs/pi-typesafe-api/testdata/tools/typebox-messages.mts +5 -0
  56. package/libs/pi-typesafe-api/testdata/typebox-messages.json +285 -0
  57. package/libs/pi-typesafe-api/twin_test.go +28 -0
  58. package/libs/pi-typesafe-api/ui/fakehost_test.go +548 -0
  59. package/libs/pi-typesafe-api/ui/keyprompt.go +115 -0
  60. package/libs/pi-typesafe-api/ui/login.go +106 -0
  61. package/libs/pi-typesafe-api/ui/twin_test.go +28 -0
  62. package/libs/pi-typesafe-api/ui/ui_test.go +285 -0
  63. package/libs/pi-typesafe-api/usage.go +366 -0
  64. package/libs/pi-typesafe-api/usage_test.go +139 -0
  65. package/libs/typesafe/CONTRACT.md +125 -0
  66. package/libs/typesafe/CREDITS.md +37 -0
  67. package/libs/typesafe/LICENSE +23 -0
  68. package/libs/typesafe/README.md +19 -0
  69. package/libs/typesafe/go.mod +3 -0
  70. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  71. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  72. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  73. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  74. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  75. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  76. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  77. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  78. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  79. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  80. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  81. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  82. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  83. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  84. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  85. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  86. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  87. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  88. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  89. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  90. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  91. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  92. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  93. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  94. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  95. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  96. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  97. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  98. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  99. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  100. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  101. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  102. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  103. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  104. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  105. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  106. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  107. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  108. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  109. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  110. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  111. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  112. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  113. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  114. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  115. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  116. package/libs/typesafe/package.json +37 -0
  117. package/libs/typesafe/provenance.json +49 -0
  118. package/package.json +42 -0
  119. package/port/PORT.md +98 -0
  120. package/port/accepted-gaps.json +3 -0
  121. package/port/golden/enable-confirm.jsonl +11 -0
  122. package/port/golden/enable-decline.jsonl +20 -0
  123. package/port/golden/enable-missing-key.jsonl +4 -0
  124. package/port/golden/login-shadow.jsonl +4 -0
  125. package/port/golden/logout-env-key.jsonl +6 -0
  126. package/port/golden/playground-cancel.jsonl +4 -0
  127. package/port/golden/playground-invalid-json.jsonl +5 -0
  128. package/port/golden/playground-invalid-questions.jsonl +5 -0
  129. package/port/golden/status-env-key.jsonl +6 -0
  130. package/port/golden/status-no-key.jsonl +6 -0
  131. package/port/golden/tool-disabled.jsonl +18 -0
  132. package/port/golden/trailing-words.jsonl +10 -0
  133. package/port/library-mutations.py +44 -0
  134. package/port/mutations.json +302 -0
  135. package/port/oracle/.env.example +4 -0
  136. package/port/oracle/CHANGELOG.md +91 -0
  137. package/port/oracle/CONTRIBUTING.md +35 -0
  138. package/port/oracle/LICENSE +21 -0
  139. package/port/oracle/README.md +159 -0
  140. package/port/oracle/docs/api.md +143 -0
  141. package/port/oracle/docs/ci-cd.md +97 -0
  142. package/port/oracle/examples/decision-extension.ts +41 -0
  143. package/port/oracle/extensions/index.js +2 -0
  144. package/port/oracle/package.json +89 -0
  145. package/port/oracle/scripts/dev-pi.mjs +23 -0
  146. package/port/oracle/scripts/live-smoke.mjs +35 -0
  147. package/port/oracle/src/ask.ts +42 -0
  148. package/port/oracle/src/auth.ts +171 -0
  149. package/port/oracle/src/backends.ts +196 -0
  150. package/port/oracle/src/batch.ts +170 -0
  151. package/port/oracle/src/calibrate.ts +237 -0
  152. package/port/oracle/src/client.ts +310 -0
  153. package/port/oracle/src/credentials.ts +136 -0
  154. package/port/oracle/src/errors.ts +53 -0
  155. package/port/oracle/src/extension.ts +204 -0
  156. package/port/oracle/src/index.ts +31 -0
  157. package/port/oracle/src/key-prompt.ts +51 -0
  158. package/port/oracle/src/login.ts +60 -0
  159. package/port/oracle/src/schema.ts +158 -0
  160. package/port/oracle/src/ui.ts +4 -0
  161. package/port/oracle/src/usage.ts +258 -0
  162. package/port/oracle/tests/ask.test.ts +63 -0
  163. package/port/oracle/tests/auth.test.ts +141 -0
  164. package/port/oracle/tests/backends.test.ts +380 -0
  165. package/port/oracle/tests/batch.test.ts +156 -0
  166. package/port/oracle/tests/calibrate.test.ts +144 -0
  167. package/port/oracle/tests/client.test.ts +499 -0
  168. package/port/oracle/tests/credentials.test.ts +144 -0
  169. package/port/oracle/tests/extension.test.ts +276 -0
  170. package/port/oracle/tests/key-prompt.test.ts +47 -0
  171. package/port/oracle/tests/login.test.ts +101 -0
  172. package/port/oracle/tests/schema.test.ts +85 -0
  173. package/port/oracle/tests/usage.test.ts +106 -0
  174. package/port/oracle/tsconfig.build.json +10 -0
  175. package/port/oracle/tsconfig.json +14 -0
  176. package/port/scenarios/enable-confirm.json +5 -0
  177. package/port/scenarios/enable-decline.json +3 -0
  178. package/port/scenarios/enable-missing-key.json +2 -0
  179. package/port/scenarios/login-shadow.json +2 -0
  180. package/port/scenarios/logout-env-key.json +3 -0
  181. package/port/scenarios/playground-cancel.json +2 -0
  182. package/port/scenarios/playground-invalid-json.json +2 -0
  183. package/port/scenarios/playground-invalid-questions.json +2 -0
  184. package/port/scenarios/status-env-key.json +3 -0
  185. package/port/scenarios/status-no-key.json +3 -0
  186. package/port/scenarios/tool-disabled.json +2 -0
  187. package/port/scenarios/trailing-words.json +5 -0
  188. package/port/upstream-tests.json +160 -0
  189. package/provenance.json +18 -0
@@ -0,0 +1,548 @@
1
+ package ui_test
2
+
3
+ // Layer-1 test harness for a Go extension port: a fake PiG host that speaks the
4
+ // documented extension wire protocol (`pig docs show extension-api`) over an
5
+ // in-memory pipe and drives the real SDK through the public
6
+ // `Extension.RunWithConn`. The tests therefore cross the same boundary a real
7
+ // host does and use no PiG-internal package.
8
+ //
9
+ // Copy this file into the port as fakehost_test.go and replace ui with the
10
+ // port's package name (the verification script checks the copy against this
11
+ // template). Add port-specific helpers in a different file.
12
+ //
13
+ // The host answers every host call through Host.OnCall. The default answers
14
+ // {} to anything, which is enough for notifications; return a result map (or
15
+ // set the error message) for calls the port depends on, such as "exec",
16
+ // "ui.select", "isIdle", "getSessionFile".
17
+ //
18
+ // Tool renderers (PiG 0.4.1 and later, D89): ToolRenderers counts the
19
+ // extension's pi.registerToolRenderer resolvers (Go: Extension.ToolRenderer),
20
+ // ResolveToolRenderers asks them for one tool, and RenderTool draws a tool
21
+ // card with the renderers they returned (or with a tool's own renderers).
22
+
23
+ import (
24
+ "encoding/binary"
25
+ "encoding/json"
26
+ "fmt"
27
+ "io"
28
+ "net"
29
+ "sync"
30
+ "testing"
31
+ "time"
32
+
33
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
34
+ )
35
+
36
+ // HostCall is one call the extension made to the host.
37
+ type HostCall struct {
38
+ Method string
39
+ Args map[string]any
40
+ }
41
+
42
+ // HostOptions configures StartHost.
43
+ type HostOptions struct {
44
+ Mode string // "tui" (default), "rpc", "json" or "print"
45
+ HasUI *bool // default: true for tui and rpc, false otherwise
46
+ Cwd string
47
+ // OnCall answers a host call. Return (result, "") for success or
48
+ // (nil, message) for a host error. Nil means "answer {} to everything".
49
+ OnCall func(method string, args map[string]any) (result map[string]any, errMessage string)
50
+ // OnCallValue is OnCall for a host answer that is not an object: `sessionRead` answers an array
51
+ // (getBranch) or a string (getCwd). It takes precedence over OnCall when set.
52
+ OnCallValue func(method string, args map[string]any) (result any, errMessage string)
53
+ // ModelStream scripts the host's answer to ctx.ModelRegistry().Stream/Complete: it receives the
54
+ // model and the request and returns the events to deliver (ModelText, ModelError, or your own
55
+ // text_delta events ending in a done or error event). The host sends `started`, the events, and
56
+ // then the call result, in the order the SDK requires.
57
+ ModelStream func(model, request map[string]any) []map[string]any
58
+ }
59
+
60
+ // ModelText is a model stream that answers text and stops normally.
61
+ func ModelText(text string) []map[string]any {
62
+ message := map[string]any{"role": "assistant", "content": []any{map[string]any{"type": "text", "text": text}}, "stopReason": "stop"}
63
+ return []map[string]any{{"type": "done", "reason": "stop", "message": message}}
64
+ }
65
+
66
+ // ModelError is a model stream that fails with message.
67
+ func ModelError(message string) []map[string]any {
68
+ msg := map[string]any{"role": "assistant", "content": []any{}, "stopReason": "error", "errorMessage": message}
69
+ return []map[string]any{{"type": "error", "reason": "error", "error": msg}}
70
+ }
71
+
72
+ // Host is the fake PiG host.
73
+ type Host struct {
74
+ t *testing.T
75
+ nc net.Conn
76
+ opts HostOptions
77
+ runDone chan error
78
+ writeMu sync.Mutex
79
+ mu sync.Mutex
80
+ calls []HostCall
81
+ pending map[string]chan json.RawMessage
82
+ failures map[string]string
83
+ handlers map[string]int
84
+ tools map[string]bool
85
+ cmds map[string]bool
86
+ nextID int
87
+ // resolvers is the extension's tool renderer resolver count; invalidated
88
+ // lists the cards whose renderers called context.invalidate().
89
+ resolvers int
90
+ invalidated []string
91
+ }
92
+
93
+ type frame struct {
94
+ Type string `json:"type"`
95
+ ID string `json:"id,omitempty"`
96
+ Register *struct {
97
+ Handlers []struct {
98
+ Event string `json:"event"`
99
+ HandlerID int `json:"handler_id"`
100
+ } `json:"handlers"`
101
+ Tools []struct {
102
+ Name string `json:"name"`
103
+ } `json:"tools"`
104
+ Commands []struct {
105
+ Name string `json:"name"`
106
+ } `json:"commands"`
107
+ ToolRenderers int `json:"tool_renderers"`
108
+ } `json:"register,omitempty"`
109
+ Response *struct {
110
+ Result json.RawMessage `json:"result"`
111
+ Error *struct {
112
+ Message string `json:"message"`
113
+ } `json:"error"`
114
+ } `json:"response,omitempty"`
115
+ Call *struct {
116
+ Method string `json:"method"`
117
+ Args json.RawMessage `json:"args"`
118
+ } `json:"call,omitempty"`
119
+ Notify *struct {
120
+ Method string `json:"method"`
121
+ Args json.RawMessage `json:"args"`
122
+ } `json:"notify,omitempty"`
123
+ }
124
+
125
+ // StartHost runs ext against a fake host and returns once the extension has registered.
126
+ func StartHost(t *testing.T, ext *sdk.Extension, opts HostOptions) *Host {
127
+ t.Helper()
128
+ if opts.Mode == "" {
129
+ opts.Mode = "tui"
130
+ }
131
+ hasUI := opts.Mode == "tui" || opts.Mode == "rpc"
132
+ if opts.HasUI != nil {
133
+ hasUI = *opts.HasUI
134
+ }
135
+ if opts.Cwd == "" {
136
+ opts.Cwd = t.TempDir()
137
+ }
138
+ hostSide, extSide := net.Pipe()
139
+ h := &Host{
140
+ t: t, nc: hostSide, opts: opts, runDone: make(chan error, 1),
141
+ pending: map[string]chan json.RawMessage{}, failures: map[string]string{},
142
+ handlers: map[string]int{}, tools: map[string]bool{}, cmds: map[string]bool{},
143
+ }
144
+ go func() { h.runDone <- ext.RunWithConn(extSide) }()
145
+ first := h.read()
146
+ // A factory that subscribes to the event bus (pi.events.on) calls the host before it registers.
147
+ for first.Type == "call" && first.Call != nil {
148
+ h.answer(first)
149
+ first = h.read()
150
+ }
151
+ if first.Type != "register" || first.Register == nil {
152
+ t.Fatalf("expected register, got %+v", first)
153
+ }
154
+ for _, hd := range first.Register.Handlers {
155
+ h.handlers[hd.Event] = hd.HandlerID
156
+ }
157
+ for _, tl := range first.Register.Tools {
158
+ h.tools[tl.Name] = true
159
+ }
160
+ for _, c := range first.Register.Commands {
161
+ h.cmds[c.Name] = true
162
+ }
163
+ h.resolvers = first.Register.ToolRenderers
164
+ state, _ := json.Marshal(map[string]any{"hasUI": hasUI})
165
+ h.write(map[string]any{"type": "ready", "ready": map[string]any{
166
+ "session_name": "", "cwd": opts.Cwd, "mode": opts.Mode, "width": 80, "state": json.RawMessage(state),
167
+ }})
168
+ go h.serve()
169
+ t.Cleanup(h.stop)
170
+ return h
171
+ }
172
+
173
+ // Registered reports whether the extension registered a handler for event.
174
+ func (h *Host) Registered(event string) bool {
175
+ h.mu.Lock()
176
+ defer h.mu.Unlock()
177
+ _, ok := h.handlers[event]
178
+ return ok
179
+ }
180
+
181
+ // Calls returns every host call so far, in arrival order.
182
+ func (h *Host) Calls() []HostCall {
183
+ h.mu.Lock()
184
+ defer h.mu.Unlock()
185
+ return append([]HostCall(nil), h.calls...)
186
+ }
187
+
188
+ // CallsTo returns the calls of one method.
189
+ func (h *Host) CallsTo(method string) []HostCall {
190
+ var out []HostCall
191
+ for _, c := range h.Calls() {
192
+ if c.Method == method {
193
+ out = append(out, c)
194
+ }
195
+ }
196
+ return out
197
+ }
198
+
199
+ func (h *Host) read() frame {
200
+ var hdr [4]byte
201
+ if _, err := io.ReadFull(h.nc, hdr[:]); err != nil {
202
+ h.t.Fatalf("read header: %v", err)
203
+ }
204
+ data := make([]byte, binary.BigEndian.Uint32(hdr[:]))
205
+ if _, err := io.ReadFull(h.nc, data); err != nil {
206
+ h.t.Fatalf("read frame: %v", err)
207
+ }
208
+ var f frame
209
+ if err := json.Unmarshal(data, &f); err != nil {
210
+ h.t.Fatalf("decode frame %s: %v", data, err)
211
+ }
212
+ return f
213
+ }
214
+
215
+ func (h *Host) write(v any) {
216
+ data, err := json.Marshal(v)
217
+ if err != nil {
218
+ h.t.Errorf("marshal: %v", err)
219
+ return
220
+ }
221
+ var hdr [4]byte
222
+ binary.BigEndian.PutUint32(hdr[:], uint32(len(data)))
223
+ h.writeMu.Lock()
224
+ defer h.writeMu.Unlock()
225
+ if _, err := h.nc.Write(hdr[:]); err != nil {
226
+ return
227
+ }
228
+ _, _ = h.nc.Write(data)
229
+ }
230
+
231
+ // serve answers host calls and routes responses to Fire and Request.
232
+ func (h *Host) serve() {
233
+ for {
234
+ var hdr [4]byte
235
+ if _, err := io.ReadFull(h.nc, hdr[:]); err != nil {
236
+ return
237
+ }
238
+ data := make([]byte, binary.BigEndian.Uint32(hdr[:]))
239
+ if _, err := io.ReadFull(h.nc, data); err != nil {
240
+ return
241
+ }
242
+ var f frame
243
+ if json.Unmarshal(data, &f) != nil {
244
+ continue
245
+ }
246
+ switch f.Type {
247
+ case "call":
248
+ if f.Call != nil {
249
+ h.answer(f)
250
+ }
251
+ case "notify":
252
+ // Applied on the read loop, so a notify the extension sent before a response
253
+ // is visible once Command, Tool or RenderTool returns.
254
+ if f.Notify != nil {
255
+ h.notified(f.Notify.Method, f.Notify.Args)
256
+ }
257
+ case "response":
258
+ h.mu.Lock()
259
+ ch := h.pending[f.ID]
260
+ delete(h.pending, f.ID)
261
+ h.mu.Unlock()
262
+ if ch == nil || f.Response == nil {
263
+ continue
264
+ }
265
+ if f.Response.Error != nil {
266
+ h.mu.Lock()
267
+ h.failures[f.ID] = f.Response.Error.Message
268
+ h.mu.Unlock()
269
+ }
270
+ ch <- f.Response.Result
271
+ }
272
+ }
273
+ }
274
+
275
+ func (h *Host) answer(f frame) {
276
+ args := map[string]any{}
277
+ _ = json.Unmarshal(f.Call.Args, &args)
278
+ // Record before answering so a test that reads Calls after the handler
279
+ // returned always sees the call.
280
+ h.mu.Lock()
281
+ h.calls = append(h.calls, HostCall{Method: f.Call.Method, Args: args})
282
+ h.mu.Unlock()
283
+ if f.Call.Method == "modelStream" && h.opts.ModelStream != nil {
284
+ go h.answerModelStream(f, args)
285
+ return
286
+ }
287
+ // Answer off the read loop so a slow OnCall cannot block response routing.
288
+ go func() {
289
+ var result any = map[string]any{}
290
+ errMessage := ""
291
+ switch {
292
+ case h.opts.OnCallValue != nil:
293
+ result, errMessage = h.opts.OnCallValue(f.Call.Method, args)
294
+ case h.opts.OnCall != nil:
295
+ var m map[string]any
296
+ m, errMessage = h.opts.OnCall(f.Call.Method, args)
297
+ result = m
298
+ if m == nil {
299
+ result = nil
300
+ }
301
+ }
302
+ if errMessage != "" {
303
+ h.write(map[string]any{"type": "call_result", "id": f.ID, "call_result": map[string]any{"error": map[string]any{"message": errMessage}}})
304
+ return
305
+ }
306
+ if result == nil {
307
+ result = map[string]any{}
308
+ }
309
+ h.write(map[string]any{"type": "call_result", "id": f.ID, "call_result": map[string]any{"result": result}})
310
+ }()
311
+ }
312
+
313
+ // answerModelStream delivers a scripted model stream the way the host does: `started`, every event as
314
+ // a model_stream_event notification, and only then the call result.
315
+ func (h *Host) answerModelStream(f frame, args map[string]any) {
316
+ model, _ := args["model"].(map[string]any)
317
+ request, _ := args["request"].(map[string]any)
318
+ id := args["streamId"]
319
+ notify := func(payload map[string]any) {
320
+ payload["streamId"] = id
321
+ h.write(map[string]any{"type": "notify", "notify": map[string]any{"method": "model_stream_event", "args": payload}})
322
+ }
323
+ notify(map[string]any{"started": true})
324
+ for _, event := range h.opts.ModelStream(model, request) {
325
+ notify(map[string]any{"event": event})
326
+ }
327
+ h.write(map[string]any{"type": "call_result", "id": f.ID, "call_result": map[string]any{"result": nil}})
328
+ }
329
+
330
+ // notified applies a notification from the extension: a tool renderer
331
+ // resolver registered after loading (`tool_renderers`, the new count) or a
332
+ // renderer's context.invalidate() (`tool_render_invalidate`, the card).
333
+ func (h *Host) notified(method string, args json.RawMessage) {
334
+ h.mu.Lock()
335
+ defer h.mu.Unlock()
336
+ switch method {
337
+ case "tool_renderers":
338
+ var p struct {
339
+ Count int `json:"count"`
340
+ }
341
+ if json.Unmarshal(args, &p) == nil {
342
+ h.resolvers = p.Count
343
+ }
344
+ case "tool_render_invalidate":
345
+ var p struct {
346
+ Card string `json:"card"`
347
+ }
348
+ if json.Unmarshal(args, &p) == nil {
349
+ h.invalidated = append(h.invalidated, p.Card)
350
+ }
351
+ }
352
+ }
353
+
354
+ // CustomResult is the host's answer to a ui.custom call: return it from OnCall when the
355
+ // extension opened a component and should get result back (a component's own input and
356
+ // rendering are tested directly on the component; here only what crosses the host boundary).
357
+ func CustomResult(result map[string]any) map[string]any {
358
+ return map[string]any{"ok": true, "result": result}
359
+ }
360
+
361
+ // Fire delivers one event the way PiG's host does (waiting for the handler)
362
+ // and returns the handler's result. An event the extension did not register
363
+ // for is not delivered and returns nil. A handler error fails the test.
364
+ func (h *Host) Fire(event string, data map[string]any) json.RawMessage {
365
+ h.t.Helper()
366
+ h.mu.Lock()
367
+ id, ok := h.handlers[event]
368
+ h.mu.Unlock()
369
+ if !ok {
370
+ return nil
371
+ }
372
+ if data == nil {
373
+ data = map[string]any{}
374
+ }
375
+ data["type"] = event
376
+ args, _ := json.Marshal(data)
377
+ result, failure := h.roundTrip(map[string]any{"method": "event", "event": event, "handler_id": id, "args": json.RawMessage(args)})
378
+ if failure != "" {
379
+ h.t.Errorf("%s handler failed: %s", event, failure)
380
+ }
381
+ return result
382
+ }
383
+
384
+ // Command runs a registered slash command and returns its error text ("" on success).
385
+ func (h *Host) Command(name, args string) string {
386
+ h.t.Helper()
387
+ if !h.cmds[name] {
388
+ h.t.Fatalf("command %q is not registered", name)
389
+ }
390
+ // The SDK reads the command name from the request's "tool" and the argument text as a JSON string.
391
+ argv, _ := json.Marshal(args)
392
+ _, failure := h.roundTrip(map[string]any{"method": "command", "tool": name, "args": json.RawMessage(argv)})
393
+ return failure
394
+ }
395
+
396
+ // Tool executes a registered tool and returns its raw result and error text.
397
+ func (h *Host) Tool(name string, params map[string]any) (json.RawMessage, string) {
398
+ h.t.Helper()
399
+ if !h.tools[name] {
400
+ h.t.Fatalf("tool %q is not registered", name)
401
+ }
402
+ argv, _ := json.Marshal(params)
403
+ return h.roundTrip(map[string]any{"method": "tool_call", "tool": name, "tool_call_id": "call-1", "args": json.RawMessage(argv)})
404
+ }
405
+
406
+ // ToolRenderers returns how many tool renderer resolvers the extension has
407
+ // registered: the count in its registration, then each later registration's.
408
+ func (h *Host) ToolRenderers() int {
409
+ h.mu.Lock()
410
+ defer h.mu.Unlock()
411
+ return h.resolvers
412
+ }
413
+
414
+ // ToolRenderersDecl describes renderers by what they draw: upstream
415
+ // renderShell ("self" or empty) and whether renderCall and renderResult exist.
416
+ type ToolRenderersDecl struct {
417
+ RenderShell string `json:"render_shell,omitempty"`
418
+ RendersCall bool `json:"renders_call,omitempty"`
419
+ RendersResult bool `json:"renders_result,omitempty"`
420
+ }
421
+
422
+ // ResolvedToolRenderers is the extension's answer to ResolveToolRenderers. Use
423
+ // is "next" (its resolvers returned next()), "none" (no renderers) or "own"
424
+ // (renderers of the extension, described by the embedded fields; RenderTool
425
+ // draws them when given Renderers).
426
+ type ResolvedToolRenderers struct {
427
+ Use string `json:"use"`
428
+ ToolRenderersDecl
429
+ Renderers string `json:"renderers,omitempty"`
430
+ }
431
+
432
+ // ResolveToolRenderers asks the extension's resolvers which renderers draw
433
+ // calls to tool, as the host does before it draws a tool card. next describes
434
+ // what next() returns to them (the renderers the remaining resolvers, then the
435
+ // registered tool, would use), or nil for none. It returns the answer and the
436
+ // error text of a resolver that failed.
437
+ func (h *Host) ResolveToolRenderers(tool string, next *ToolRenderersDecl) (ResolvedToolRenderers, string) {
438
+ h.t.Helper()
439
+ argv, _ := json.Marshal(map[string]any{"tool": tool, "next": next})
440
+ raw, failure := h.roundTrip(map[string]any{"method": "resolve_tool_renderers", "tool": tool, "args": json.RawMessage(argv)})
441
+ var got ResolvedToolRenderers
442
+ if failure == "" {
443
+ if err := json.Unmarshal(raw, &got); err != nil {
444
+ h.t.Fatalf("resolve_tool_renderers answer %s: %v", raw, err)
445
+ }
446
+ }
447
+ return got, failure
448
+ }
449
+
450
+ // ToolRender is one render of a tool card (upstream renderCall or
451
+ // renderResult). Renderers is ResolvedToolRenderers.Renderers, or empty for
452
+ // the tool's own renderers. Renderer state is kept per Card and shared by its
453
+ // call and result renderers.
454
+ type ToolRender struct {
455
+ Card string
456
+ Renderers string
457
+ Phase string // "call" (the default) or "result"
458
+ Args map[string]any // the tool call's arguments
459
+ Result map[string]any // phase "result": {"content": [...blocks], "details": ...}
460
+ Options map[string]any // phase "result": "expanded", "isPartial"
461
+ Context map[string]any // "toolCallId", "cwd", "executionStarted", "argsComplete", "isPartial", "expanded", "showImages", "isError"
462
+ Width int // default 80
463
+ }
464
+
465
+ // RenderTool draws a tool card with the extension's renderers and returns
466
+ // their lines, or the error text when the renderer failed (the host then draws
467
+ // upstream's fallback).
468
+ func (h *Host) RenderTool(tool string, r ToolRender) ([]string, string) {
469
+ h.t.Helper()
470
+ if r.Phase == "" {
471
+ r.Phase = "call"
472
+ }
473
+ if r.Width == 0 {
474
+ r.Width = 80
475
+ }
476
+ if r.Args == nil {
477
+ r.Args = map[string]any{}
478
+ }
479
+ if r.Context == nil {
480
+ r.Context = map[string]any{}
481
+ }
482
+ payload := map[string]any{"card": r.Card, "phase": r.Phase, "rerender": true, "args": r.Args, "context": r.Context, "width": r.Width}
483
+ if r.Renderers != "" {
484
+ payload["renderers"] = r.Renderers
485
+ }
486
+ if r.Result != nil {
487
+ payload["result"] = r.Result
488
+ }
489
+ if r.Options != nil {
490
+ payload["options"] = r.Options
491
+ }
492
+ argv, _ := json.Marshal(payload)
493
+ raw, failure := h.roundTrip(map[string]any{"method": "render_tool", "tool": tool, "args": json.RawMessage(argv)})
494
+ var got struct {
495
+ Lines []string `json:"lines"`
496
+ }
497
+ if failure == "" {
498
+ if err := json.Unmarshal(raw, &got); err != nil {
499
+ h.t.Fatalf("render_tool answer %s: %v", raw, err)
500
+ }
501
+ }
502
+ return got.Lines, failure
503
+ }
504
+
505
+ // Invalidated returns the cards whose renderers called context.invalidate(),
506
+ // in order.
507
+ func (h *Host) Invalidated() []string {
508
+ h.mu.Lock()
509
+ defer h.mu.Unlock()
510
+ return append([]string(nil), h.invalidated...)
511
+ }
512
+
513
+ // ReleaseToolCard tells the extension that a tool card no longer exists, so it
514
+ // drops the card's renderer state.
515
+ func (h *Host) ReleaseToolCard(card string) {
516
+ h.write(map[string]any{"type": "notify", "notify": map[string]any{"method": "tool_render_release", "args": map[string]any{"card": card}}})
517
+ }
518
+
519
+ func (h *Host) roundTrip(request map[string]any) (json.RawMessage, string) {
520
+ h.t.Helper()
521
+ h.mu.Lock()
522
+ h.nextID++
523
+ id := fmt.Sprintf("r%d", h.nextID)
524
+ ch := make(chan json.RawMessage, 1)
525
+ h.pending[id] = ch
526
+ h.mu.Unlock()
527
+ h.write(map[string]any{"type": "request", "id": id, "request": request})
528
+ select {
529
+ case result := <-ch:
530
+ h.mu.Lock()
531
+ failure := h.failures[id]
532
+ h.mu.Unlock()
533
+ return result, failure
534
+ case <-time.After(10 * time.Second):
535
+ h.t.Fatalf("no response to %v within 10s", request["method"])
536
+ return nil, ""
537
+ }
538
+ }
539
+
540
+ func (h *Host) stop() {
541
+ h.write(map[string]any{"type": "shutdown", "shutdown": map[string]any{"reason": "test"}})
542
+ select {
543
+ case <-h.runDone:
544
+ case <-time.After(10 * time.Second):
545
+ h.t.Errorf("extension did not stop after shutdown")
546
+ }
547
+ _ = h.nc.Close()
548
+ }
@@ -0,0 +1,115 @@
1
+ // Package ui holds the interactive helpers of the typed API: a hidden-input key prompt, the login that
2
+ // verifies and stores a TypeSafe key, and EnsureAPIKey. They need PiG's TUI, so call them only from extension
3
+ // command handlers.
4
+ package ui
5
+
6
+ import (
7
+ "strings"
8
+ "unicode/utf8"
9
+
10
+ sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
11
+ )
12
+
13
+ const (
14
+ bold = "\x1b[1m"
15
+ dim = "\x1b[2m"
16
+ reset = "\x1b[0m"
17
+ reverseOn = "\x1b[7m"
18
+ reverseOf = "\x1b[27m"
19
+ pasteOpen = "\x1b[200~"
20
+ pasteEnd = "\x1b[201~"
21
+ )
22
+
23
+ // KeyPrompt is a single-line input that renders bullets instead of the typed value. It is an sdk.RemoteComponent:
24
+ // Enter submits the value, Escape cancels.
25
+ type KeyPrompt struct {
26
+ value []rune
27
+ Focused bool
28
+ title string
29
+ hint string
30
+ }
31
+
32
+ // NewKeyPrompt returns the prompt for a TypeSafe key.
33
+ func NewKeyPrompt() *KeyPrompt {
34
+ return &KeyPrompt{
35
+ Focused: true,
36
+ title: "TypeSafe API key",
37
+ hint: "Paste the key from console.typesafe.ai › API Keys. Input is hidden. Enter saves, Esc cancels.",
38
+ }
39
+ }
40
+
41
+ var _ sdk.RemoteComponent = (*KeyPrompt)(nil)
42
+
43
+ // Render draws the title, the hint and the masked input at width.
44
+ func (p *KeyPrompt) Render(width int) []string {
45
+ lines := []string{" " + bold + p.title + reset}
46
+ for _, l := range wrap(p.hint, max(1, width-2)) {
47
+ lines = append(lines, " "+dim+l+reset)
48
+ }
49
+ bullets := strings.Repeat("•", min(len(p.value), max(0, width-2)))
50
+ cursor := ""
51
+ if p.Focused {
52
+ cursor = reverseOn + " " + reverseOf
53
+ }
54
+ return append(lines, bullets+cursor)
55
+ }
56
+
57
+ // HandleInput reads one chunk of terminal input: typed or pasted text, backspace, Enter and Escape.
58
+ func (p *KeyPrompt) HandleInput(data string) (sdk.RemoteComponentResult, error) {
59
+ switch data {
60
+ case "\r", "\n":
61
+ return sdk.RemoteComponentResult{Done: true, Value: string(p.value)}, nil
62
+ case "\x1b":
63
+ return sdk.RemoteComponentResult{Done: true, Value: nil}, nil
64
+ case "\x7f", "\b":
65
+ if len(p.value) > 0 {
66
+ p.value = p.value[:len(p.value)-1]
67
+ }
68
+ return sdk.RemoteComponentResult{}, nil
69
+ }
70
+ data = strings.TrimSuffix(strings.TrimPrefix(data, pasteOpen), pasteEnd)
71
+ if strings.HasPrefix(data, "\x1b") {
72
+ return sdk.RemoteComponentResult{}, nil // another escape sequence: ignore
73
+ }
74
+ for len(data) > 0 {
75
+ r, n := utf8.DecodeRuneInString(data)
76
+ data = data[n:]
77
+ if r >= 0x20 && r != 0x7f {
78
+ p.value = append(p.value, r)
79
+ }
80
+ }
81
+ return sdk.RemoteComponentResult{}, nil
82
+ }
83
+
84
+ func wrap(text string, width int) []string {
85
+ var lines []string
86
+ line := ""
87
+ for _, word := range strings.Fields(text) {
88
+ switch {
89
+ case line == "":
90
+ line = word
91
+ case utf8.RuneCountInString(line)+1+utf8.RuneCountInString(word) <= width:
92
+ line += " " + word
93
+ default:
94
+ lines = append(lines, line)
95
+ line = word
96
+ }
97
+ }
98
+ if line != "" {
99
+ lines = append(lines, line)
100
+ }
101
+ return lines
102
+ }
103
+
104
+ // PromptForAPIKey asks for a key with hidden input; where custom components are unavailable or fail to open it falls back to
105
+ // PiG's plain input dialog (visible while typing). ok is false when the user cancels.
106
+ func PromptForAPIKey(ctx sdk.Context) (key string, ok bool, err error) {
107
+ result, err := ctx.Custom(NewKeyPrompt(), sdk.RemoteOverlayOptions{Title: "TypeSafe API key"})
108
+ if err == nil {
109
+ value, isString := result.(string)
110
+ return value, isString, nil
111
+ }
112
+ // Any failure to open the component (the host has no custom components, as in RPC mode) falls back to the
113
+ // plain dialog; a failure of that dialog is the error reported.
114
+ return ctx.Input("TypeSafe API key (visible while typing)", "Paste the key, then press Enter")
115
+ }