@john-ezra/openralph 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.ts ADDED
@@ -0,0 +1,436 @@
1
+ import type { TuiDialogStack, TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
2
+ import { validateOptions } from "./args.ts"
3
+ import { buildDesignUserPrompt, DESIGN_SYSTEM_PROMPT } from "./design.ts"
4
+ import type { CommandOutputEvent } from "./exec.ts"
5
+ import { runOpenRalphLauncher, type RunLauncherInput } from "./launcher.ts"
6
+
7
+ const MAX_OUTPUT_CHARS = 40_000
8
+ const OUTPUT_DIALOG_LINES = 36
9
+ const OUTPUT_DIALOG_REFRESH_MS = 1000
10
+ const MAX_DIALOG_LINE_LENGTH = 220
11
+
12
+ interface TuiRunState {
13
+ controller: AbortController
14
+ phase: "plan" | "build"
15
+ rawArgs: string
16
+ startedAt: number
17
+ status: string
18
+ output: string
19
+ summary?: string
20
+ dialog?: TuiDialogStack
21
+ refreshTimer?: ReturnType<typeof setTimeout>
22
+ suppressDialogClose?: boolean
23
+ }
24
+
25
+ type RalphMode = "design" | "plan" | "build"
26
+ type RunLauncher = typeof runOpenRalphLauncher
27
+
28
+ export function createTuiModule(runLauncher: RunLauncher = runOpenRalphLauncher): TuiPluginModule {
29
+ const tui = (async (api, rawOptions, _meta) => {
30
+ const options = validateOptions(rawOptions)
31
+ let active: TuiRunState | undefined
32
+ let lastRun: TuiRunState | undefined
33
+
34
+ const unregister = api.keymap.registerLayer({
35
+ commands: [
36
+ {
37
+ namespace: "palette",
38
+ name: "openralph",
39
+ title: "OpenRalph: Choose Phase",
40
+ category: "OpenRalph",
41
+ slashName: "ralph",
42
+ run: () => showModeSelect(api, undefined, options, runLauncher, () => active, (next) => {
43
+ active = next
44
+ }, (next) => {
45
+ lastRun = next
46
+ }),
47
+ },
48
+ ],
49
+ bindings: [],
50
+ })
51
+
52
+ api.lifecycle.onDispose(() => {
53
+ active?.controller.abort()
54
+ if (active) clearRefreshTimer(active)
55
+ if (lastRun && lastRun !== active) clearRefreshTimer(lastRun)
56
+ unregister()
57
+ })
58
+ }) satisfies TuiPlugin
59
+
60
+ return { id: "openralph", tui }
61
+ }
62
+
63
+ export default createTuiModule()
64
+
65
+ function showModeSelect(
66
+ api: Parameters<TuiPlugin>[0],
67
+ dialog: TuiDialogStack | undefined,
68
+ options: ReturnType<typeof validateOptions>,
69
+ runLauncher: RunLauncher,
70
+ getActive: () => TuiRunState | undefined,
71
+ setActive: (run: TuiRunState | undefined) => void,
72
+ setLastRun: (run: TuiRunState) => void,
73
+ ): void {
74
+ const stack = dialog ?? api.ui.dialog
75
+ stack.replace(() =>
76
+ api.ui.DialogSelect<RalphMode>({
77
+ title: "OpenRalph",
78
+ placeholder: "Select a Ralph phase",
79
+ options: [
80
+ {
81
+ title: "Design",
82
+ value: "design",
83
+ description: "Ideate and write planning-ready specs",
84
+ },
85
+ {
86
+ title: "Plan",
87
+ value: "plan",
88
+ description: "Create or refine IMPLEMENTATION_PLAN.md from specs",
89
+ },
90
+ {
91
+ title: "Build",
92
+ value: "build",
93
+ description: "Implement planned work one task and commit at a time",
94
+ },
95
+ ],
96
+ onSelect: (option) => {
97
+ stack.clear()
98
+ if (option.value === "design") {
99
+ promptForDesign(api, stack, options, getActive)
100
+ return
101
+ }
102
+ promptForArgs(api, stack, option.value, options, runLauncher, getActive, setActive, setLastRun)
103
+ },
104
+ }),
105
+ )
106
+ }
107
+
108
+ function promptForDesign(
109
+ api: Parameters<TuiPlugin>[0],
110
+ dialog: TuiDialogStack | undefined,
111
+ options: ReturnType<typeof validateOptions>,
112
+ getActive: () => TuiRunState | undefined,
113
+ ): void {
114
+ if (getActive()) {
115
+ api.ui.toast({ variant: "warning", title: "OpenRalph", message: "An OpenRalph run is already active." })
116
+ return
117
+ }
118
+
119
+ const stack = dialog ?? api.ui.dialog
120
+ stack.replace(() =>
121
+ api.ui.DialogPrompt({
122
+ title: "OpenRalph: Design",
123
+ placeholder: "Feature, workflow, bug, or product change (optional)",
124
+ onConfirm: (value) => {
125
+ stack.clear()
126
+ void startDesignTurn(api, value, options)
127
+ },
128
+ onCancel: () => stack.clear(),
129
+ }),
130
+ )
131
+ }
132
+
133
+ async function startDesignTurn(
134
+ api: Parameters<TuiPlugin>[0],
135
+ initialIdea: string,
136
+ options: ReturnType<typeof validateOptions>,
137
+ ): Promise<void> {
138
+ try {
139
+ const sessionID = await ensureDesignSession(api)
140
+ await api.client.session.prompt(
141
+ {
142
+ sessionID,
143
+ directory: projectDirectory(api),
144
+ system: DESIGN_SYSTEM_PROMPT,
145
+ parts: [{ type: "text", text: buildDesignUserPrompt(initialIdea) }],
146
+ ...(options.defineModel ? { model: parseProviderModel(options.defineModel) } : {}),
147
+ },
148
+ { throwOnError: true },
149
+ )
150
+ } catch (error) {
151
+ api.ui.toast({
152
+ variant: "error",
153
+ title: "OpenRalph Design failed",
154
+ message: trimMessage(formatError(error)),
155
+ duration: 12000,
156
+ })
157
+ }
158
+ }
159
+
160
+ async function ensureDesignSession(api: Parameters<TuiPlugin>[0]): Promise<string> {
161
+ const sessionID = currentSessionID(api)
162
+ if (sessionID) return sessionID
163
+
164
+ const result = await api.client.session.create(
165
+ {
166
+ directory: projectDirectory(api),
167
+ title: "Ralph Design",
168
+ metadata: { openralph: "design" },
169
+ },
170
+ { throwOnError: true },
171
+ )
172
+ api.route.navigate("session", { sessionID: result.data.id })
173
+ return result.data.id
174
+ }
175
+
176
+ function currentSessionID(api: Parameters<TuiPlugin>[0]): string | undefined {
177
+ const route = api.route.current
178
+ if (route.name !== "session") return undefined
179
+ const sessionID = route.params?.sessionID
180
+ return typeof sessionID === "string" ? sessionID : undefined
181
+ }
182
+
183
+ function projectDirectory(api: Parameters<TuiPlugin>[0]): string {
184
+ return api.state.path.worktree || api.state.path.directory
185
+ }
186
+
187
+ function parseProviderModel(model: string): { providerID: string; modelID: string } {
188
+ const separator = model.indexOf("/")
189
+ if (separator <= 0 || separator === model.length - 1) {
190
+ throw new Error("defineModel must use provider/model format for OpenRalph Design")
191
+ }
192
+
193
+ return {
194
+ providerID: model.slice(0, separator),
195
+ modelID: model.slice(separator + 1),
196
+ }
197
+ }
198
+
199
+ function promptForArgs(
200
+ api: Parameters<TuiPlugin>[0],
201
+ dialog: TuiDialogStack | undefined,
202
+ phase: "plan" | "build",
203
+ options: ReturnType<typeof validateOptions>,
204
+ runLauncher: RunLauncher,
205
+ getActive: () => TuiRunState | undefined,
206
+ setActive: (run: TuiRunState | undefined) => void,
207
+ setLastRun: (run: TuiRunState) => void,
208
+ ): void {
209
+ const stack = dialog ?? api.ui.dialog
210
+ stack.replace(() =>
211
+ api.ui.DialogPrompt({
212
+ title: phase === "plan" ? "OpenRalph: Plan" : "OpenRalph: Build",
213
+ placeholder: argsPromptPlaceholder(phase),
214
+ onConfirm: (value) => {
215
+ stack.clear()
216
+ setTimeout(() => {
217
+ void startTuiRun(api, phase, value, options, runLauncher, getActive, setActive, setLastRun)
218
+ }, 0)
219
+ },
220
+ onCancel: () => stack.clear(),
221
+ }),
222
+ )
223
+ }
224
+
225
+ function argsPromptPlaceholder(phase: "plan" | "build"): string {
226
+ return phase === "plan"
227
+ ? "[number] [--model provider/model] [--no-docker]"
228
+ : "[number] [--model provider/model] [--push] [--no-docker]"
229
+ }
230
+
231
+ async function startTuiRun(
232
+ api: Parameters<TuiPlugin>[0],
233
+ phase: "plan" | "build",
234
+ rawArgs: string,
235
+ options: ReturnType<typeof validateOptions>,
236
+ runLauncher: RunLauncher,
237
+ getActive: () => TuiRunState | undefined,
238
+ setActive: (run: TuiRunState | undefined) => void,
239
+ setLastRun: (run: TuiRunState) => void,
240
+ ): Promise<void> {
241
+ if (getActive()) {
242
+ api.ui.toast({ variant: "warning", title: "OpenRalph", message: "An OpenRalph run is already active." })
243
+ return
244
+ }
245
+
246
+ const controller = new AbortController()
247
+ const run: TuiRunState = {
248
+ controller,
249
+ phase,
250
+ rawArgs,
251
+ startedAt: Date.now(),
252
+ status: "starting",
253
+ output: "",
254
+ }
255
+ setActive(run)
256
+ setLastRun(run)
257
+ showOutputDialog(api, undefined, run)
258
+
259
+ try {
260
+ const input: RunLauncherInput = {
261
+ phase,
262
+ rawArgs,
263
+ cwd: api.state.path.worktree || api.state.path.directory,
264
+ options,
265
+ streamOutput: false,
266
+ captureOutput: true,
267
+ onOutput: (event) => recordOutput(api, run, event),
268
+ signal: controller.signal,
269
+ }
270
+ const result = await runLauncher(input)
271
+ run.status = result.status
272
+ run.summary = result.summary
273
+ refreshOutputDialog(api, run)
274
+ } catch (error) {
275
+ const stopped = controller.signal.aborted
276
+ run.status = stopped ? "stopped" : "failed"
277
+ run.summary = formatError(error)
278
+ refreshOutputDialog(api, run)
279
+ } finally {
280
+ clearRefreshTimer(run)
281
+ if (getActive() === run) setActive(undefined)
282
+ }
283
+ }
284
+
285
+ function recordOutput(api: Parameters<TuiPlugin>[0], run: TuiRunState, event: CommandOutputEvent): void {
286
+ if (run.status === "starting") run.status = "running"
287
+ run.output += event.chunk
288
+ if (run.output.length > MAX_OUTPUT_CHARS) run.output = run.output.slice(-MAX_OUTPUT_CHARS)
289
+ scheduleOutputDialogRefresh(api, run)
290
+ }
291
+
292
+ function showOutputDialog(api: Parameters<TuiPlugin>[0], dialog: TuiDialogStack | undefined, run: TuiRunState): void {
293
+ const stack = dialog ?? api.ui.dialog
294
+ run.dialog = stack
295
+ refreshOutputDialog(api, run)
296
+ scheduleOutputDialogRefresh(api, run)
297
+ }
298
+
299
+ function scheduleOutputDialogRefresh(api: Parameters<TuiPlugin>[0], run: TuiRunState): void {
300
+ if (!run.dialog || run.refreshTimer || !isActiveRunStatus(run.status)) return
301
+ run.refreshTimer = setTimeout(() => {
302
+ run.refreshTimer = undefined
303
+ refreshOutputDialog(api, run)
304
+ scheduleOutputDialogRefresh(api, run)
305
+ }, OUTPUT_DIALOG_REFRESH_MS)
306
+ }
307
+
308
+ function refreshOutputDialog(api: Parameters<TuiPlugin>[0], run: TuiRunState): void {
309
+ if (!run.dialog) return
310
+ const stack = run.dialog
311
+ const closeViewer = () => {
312
+ closeOutputDialog(run, stack)
313
+ stack.clear()
314
+ }
315
+ run.suppressDialogClose = true
316
+ const onClose = () => {
317
+ if (run.suppressDialogClose) return
318
+ closeOutputDialog(run, stack)
319
+ }
320
+ if (canStopRun(run)) {
321
+ stack.replace(
322
+ () =>
323
+ api.ui.DialogConfirm({
324
+ title: `OpenRalph ${run.phase} output`,
325
+ message: `${formatOutputDialogMessage(run)}\n\nConfirm closes this viewer. Cancel stops the active Ralph loop.`,
326
+ onConfirm: closeViewer,
327
+ onCancel: () => stopFromOutputDialog(api, run, stack),
328
+ }),
329
+ onClose,
330
+ )
331
+ } else {
332
+ stack.replace(
333
+ () =>
334
+ api.ui.DialogAlert({
335
+ title: `OpenRalph ${run.phase} output`,
336
+ message: formatOutputDialogMessage(run),
337
+ onConfirm: closeViewer,
338
+ }),
339
+ onClose,
340
+ )
341
+ }
342
+ queueMicrotask(() => {
343
+ if (run.dialog === stack) run.suppressDialogClose = false
344
+ })
345
+ }
346
+
347
+ function stopFromOutputDialog(api: Parameters<TuiPlugin>[0], run: TuiRunState, stack: TuiDialogStack): void {
348
+ requestStopRun(api, run, { refresh: false })
349
+ closeOutputDialog(run, stack)
350
+ stack.clear()
351
+ }
352
+
353
+ function requestStopRun(
354
+ api: Parameters<TuiPlugin>[0],
355
+ run: TuiRunState,
356
+ options: { refresh: boolean },
357
+ ): void {
358
+ if (!run.controller.signal.aborted) {
359
+ run.status = "stop requested"
360
+ run.controller.abort()
361
+ }
362
+ if (options.refresh) refreshOutputDialog(api, run)
363
+ api.ui.toast({ variant: "warning", title: "OpenRalph", message: "Stop requested for the active run." })
364
+ }
365
+
366
+ function closeOutputDialog(run: TuiRunState, stack: TuiDialogStack): void {
367
+ if (run.dialog === stack) run.dialog = undefined
368
+ run.suppressDialogClose = false
369
+ clearRefreshTimer(run)
370
+ }
371
+
372
+ function clearRefreshTimer(run: TuiRunState): void {
373
+ if (!run.refreshTimer) return
374
+ clearTimeout(run.refreshTimer)
375
+ run.refreshTimer = undefined
376
+ }
377
+
378
+ function formatOutputDialogMessage(run: TuiRunState): string {
379
+ const lines = [
380
+ `Status: ${run.status}`,
381
+ `Args: ${run.rawArgs.trim() || "(default)"}`,
382
+ `Elapsed: ${formatElapsed(Date.now() - run.startedAt)}`,
383
+ "",
384
+ "Recent output:",
385
+ recentOutput(run),
386
+ ]
387
+
388
+ if (run.summary) {
389
+ lines.push("", "Summary:", trimMessage(run.summary))
390
+ }
391
+
392
+ return lines.join("\n")
393
+ }
394
+
395
+ function recentOutput(run: TuiRunState): string {
396
+ const output = sanitizeOutput(run.output).trimEnd()
397
+ if (!output) return "Waiting for Docker/opencode output..."
398
+
399
+ return output
400
+ .split(/\n/)
401
+ .slice(-OUTPUT_DIALOG_LINES)
402
+ .map(trimDialogLine)
403
+ .join("\n")
404
+ }
405
+
406
+ function sanitizeOutput(value: string): string {
407
+ return value.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "").replace(/\r/g, "\n")
408
+ }
409
+
410
+ function trimDialogLine(value: string): string {
411
+ if (value.length <= MAX_DIALOG_LINE_LENGTH) return value
412
+ return `${value.slice(0, MAX_DIALOG_LINE_LENGTH - 3)}...`
413
+ }
414
+
415
+ function formatElapsed(milliseconds: number): string {
416
+ const seconds = Math.max(0, Math.floor(milliseconds / 1000))
417
+ if (seconds < 60) return `${seconds}s`
418
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
419
+ }
420
+
421
+ function isActiveRunStatus(status: string): boolean {
422
+ return status === "starting" || status === "running" || status === "stop requested"
423
+ }
424
+
425
+ function canStopRun(run: TuiRunState): boolean {
426
+ return (run.status === "starting" || run.status === "running") && !run.controller.signal.aborted
427
+ }
428
+
429
+ function trimMessage(value: string): string {
430
+ const lines = value.trim().split(/\r?\n/)
431
+ return lines.slice(Math.max(0, lines.length - 8)).join("\n")
432
+ }
433
+
434
+ function formatError(error: unknown): string {
435
+ return error instanceof Error ? error.message : String(error)
436
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "types": ["bun"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["src/**/*.ts", "tests/**/*.ts"]
14
+ }