@patze/code-cli 0.16.2 → 0.17.2

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 (50) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/VERSION +1 -1
  3. package/dist/backend/run-stream-client.d.ts +1 -0
  4. package/dist/backend/run-stream-client.d.ts.map +1 -1
  5. package/dist/backend/run-stream-client.js +17 -0
  6. package/dist/backend/run-stream-client.js.map +1 -1
  7. package/dist/cli/interactive/agent-execute-turn.d.ts +1 -0
  8. package/dist/cli/interactive/agent-execute-turn.d.ts.map +1 -1
  9. package/dist/cli/interactive/agent-execute-turn.js +24 -1
  10. package/dist/cli/interactive/agent-execute-turn.js.map +1 -1
  11. package/dist/cli/interactive/agent-turn.d.ts +1 -0
  12. package/dist/cli/interactive/agent-turn.d.ts.map +1 -1
  13. package/dist/cli/interactive/agent-turn.js +1 -0
  14. package/dist/cli/interactive/agent-turn.js.map +1 -1
  15. package/dist/cli/interactive/composer-keys.d.ts +11 -0
  16. package/dist/cli/interactive/composer-keys.d.ts.map +1 -0
  17. package/dist/cli/interactive/composer-keys.js +19 -0
  18. package/dist/cli/interactive/composer-keys.js.map +1 -0
  19. package/dist/cli/interactive/header.d.ts.map +1 -1
  20. package/dist/cli/interactive/header.js +0 -1
  21. package/dist/cli/interactive/header.js.map +1 -1
  22. package/dist/cli/interactive/line-editor.d.ts +23 -0
  23. package/dist/cli/interactive/line-editor.d.ts.map +1 -1
  24. package/dist/cli/interactive/line-editor.js +95 -24
  25. package/dist/cli/interactive/line-editor.js.map +1 -1
  26. package/dist/cli/interactive/session-controller.d.ts +4 -1
  27. package/dist/cli/interactive/session-controller.d.ts.map +1 -1
  28. package/dist/cli/interactive/session-controller.js +2 -1
  29. package/dist/cli/interactive/session-controller.js.map +1 -1
  30. package/dist/cli/interactive/shell.d.ts.map +1 -1
  31. package/dist/cli/interactive/shell.js +0 -1
  32. package/dist/cli/interactive/shell.js.map +1 -1
  33. package/dist/cli/interactive/slash-dispatch.d.ts +1 -0
  34. package/dist/cli/interactive/slash-dispatch.d.ts.map +1 -1
  35. package/dist/cli/interactive/slash-dispatch.js +1 -0
  36. package/dist/cli/interactive/slash-dispatch.js.map +1 -1
  37. package/dist/cli/interactive/transcript-upsert.d.ts +12 -0
  38. package/dist/cli/interactive/transcript-upsert.d.ts.map +1 -0
  39. package/dist/cli/interactive/transcript-upsert.js +33 -0
  40. package/dist/cli/interactive/transcript-upsert.js.map +1 -0
  41. package/opentui/package.json +18 -0
  42. package/opentui/scripts/assert-parse.mjs +21 -0
  43. package/opentui/src/App.tsx +806 -0
  44. package/opentui/src/line-parse.ts +54 -0
  45. package/opentui/src/main.tsx +45 -0
  46. package/opentui/src/patze-dist.ts +11 -0
  47. package/opentui/src/transcript-render.ts +218 -0
  48. package/opentui/src/tui-sink.ts +78 -0
  49. package/opentui/tsconfig.json +13 -0
  50. package/package.json +6 -2
@@ -0,0 +1,806 @@
1
+ import {
2
+ InputRenderable,
3
+ TextAttributes,
4
+ type KeyEvent,
5
+ type SelectOption,
6
+ } from "@opentui/core"
7
+ import {
8
+ extend,
9
+ useKeyboard,
10
+ useRenderer,
11
+ useTerminalDimensions,
12
+ } from "@opentui/react"
13
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"
14
+
15
+ import { parseTranscriptLine } from "./line-parse.js"
16
+ import { importPatzeDist } from "./patze-dist.js"
17
+ import {
18
+ buildTranscriptLines,
19
+ type TranscriptEntry,
20
+ type TranscriptEntryKind,
21
+ type TranscriptLine,
22
+ type TranscriptPart,
23
+ } from "./transcript-render.js"
24
+ import { classifyPlainOutput, createTranscriptSink, inferErrorKind } from "./tui-sink.js"
25
+ import { upsertTranscriptEntry } from "../../dist/cli/interactive/transcript-upsert.js"
26
+
27
+ extend({ "tui-input": InputRenderable })
28
+
29
+ type TuiInputProps = {
30
+ focused: boolean
31
+ placeholder: string
32
+ value: string
33
+ onInput: (value: string) => void
34
+ onSubmit: (value: string) => void
35
+ }
36
+
37
+ function TuiInput(props: TuiInputProps) {
38
+ return createElement("tui-input", props)
39
+ }
40
+
41
+ type ViewMode = "command" | "input" | "model"
42
+
43
+ type CommandSelectItem = SelectOption & {
44
+ name: string
45
+ description: string
46
+ value: string
47
+ }
48
+
49
+ type ModelSelectItem = SelectOption & {
50
+ name: string
51
+ description: string
52
+ value: string
53
+ }
54
+
55
+ type InteractiveController = {
56
+ session: {
57
+ getModelOverride: () => string | null
58
+ setModelOverride: (value: string | null) => void
59
+ getExecutionMode: () => "local" | "cloud"
60
+ snapshot: () => Array<{ input: string; lines: string[] }>
61
+ }
62
+ cwd: string
63
+ renderHeaderLines: () => string[]
64
+ persistSession: () => void
65
+ processLine: (
66
+ rawLine: string,
67
+ output: { line: (text?: string) => void },
68
+ context?: { signal?: AbortSignal }
69
+ ) => Promise<{ exitShell: boolean; exitCode: number; streamed?: boolean }>
70
+ }
71
+
72
+ function parseCwdArg(): string {
73
+ const args = process.argv.slice(2)
74
+ for (let i = 0; i < args.length; i += 1) {
75
+ if (args[i] === "--cwd" && args[i + 1]) {
76
+ return args[++i]
77
+ }
78
+ }
79
+ return process.cwd()
80
+ }
81
+
82
+ export function App({ cwd: cwdArg }: { cwd?: string }) {
83
+ const cwd = cwdArg ?? parseCwdArg()
84
+ const renderer = useRenderer()
85
+ const { width: columns, height: rows } = useTerminalDimensions()
86
+ const nextIdRef = useRef(0)
87
+ const controllerRef = useRef<InteractiveController | null>(null)
88
+ const streamingIdRef = useRef<string | null>(null)
89
+ const scrollPinnedRef = useRef(true)
90
+ const activeAbortRef = useRef<AbortController | null>(null)
91
+
92
+ const [ready, setReady] = useState(false)
93
+ const [bootError, setBootError] = useState<string | null>(null)
94
+ const [busy, setBusy] = useState(false)
95
+ const [input, setInput] = useState("")
96
+ const [mode, setMode] = useState<ViewMode>("input")
97
+ const [modelSearch, setModelSearch] = useState("")
98
+ const [scrollOffset, setScrollOffset] = useState(0)
99
+ const [transcript, setTranscript] = useState<TranscriptEntry[]>([])
100
+ const [currentModel, setCurrentModel] = useState("composer-2.5-fast")
101
+ const [executionMode, setExecutionMode] = useState<"local" | "cloud">("local")
102
+ const [executionTarget, setExecutionTarget] = useState<string | null>(null)
103
+ const [supportedModels, setSupportedModels] = useState<string[]>([])
104
+ const [slashMenuReady, setSlashMenuReady] = useState(false)
105
+
106
+ const followTranscript = useCallback(() => {
107
+ if (scrollPinnedRef.current) {
108
+ setScrollOffset(0)
109
+ }
110
+ }, [])
111
+
112
+ const markScrollUnpinned = useCallback(() => {
113
+ scrollPinnedRef.current = false
114
+ }, [])
115
+
116
+ const markScrollPinned = useCallback(() => {
117
+ scrollPinnedRef.current = true
118
+ setScrollOffset(0)
119
+ }, [])
120
+
121
+ const nextId = useCallback(() => {
122
+ const id = nextIdRef.current
123
+ nextIdRef.current += 1
124
+ return String(id)
125
+ }, [])
126
+
127
+ const addEntry = useCallback(
128
+ (kind: TranscriptEntryKind, label: string, text: string) => {
129
+ setTranscript((items) => [
130
+ ...items,
131
+ { id: nextId(), kind, label, text },
132
+ ])
133
+ followTranscript()
134
+ },
135
+ [followTranscript, nextId]
136
+ )
137
+
138
+ const upsertTranscriptLine = useCallback(
139
+ (upsertKey: string, entry: Omit<TranscriptEntry, "id">) => {
140
+ setTranscript((items) =>
141
+ upsertTranscriptEntry(items, entry, upsertKey, nextId)
142
+ )
143
+ followTranscript()
144
+ },
145
+ [followTranscript, nextId]
146
+ )
147
+
148
+ const appendToLast = useCallback((text: string) => {
149
+ setTranscript((items) => {
150
+ if (!items.length) {
151
+ return items
152
+ }
153
+ const copy = [...items]
154
+ const last = copy[copy.length - 1]
155
+ copy[copy.length - 1] = {
156
+ ...last,
157
+ text: last.text ? `${last.text}\n${text}` : text,
158
+ }
159
+ return copy
160
+ })
161
+ }, [])
162
+
163
+ const upsertStreaming = useCallback(
164
+ (chunk: string) => {
165
+ if (!chunk) {
166
+ return
167
+ }
168
+ setTranscript((items) => {
169
+ const streamId = streamingIdRef.current
170
+ if (!streamId) {
171
+ const id = nextId()
172
+ streamingIdRef.current = id
173
+ return [...items, { id, kind: "assistant", label: "agent", text: chunk }]
174
+ }
175
+ return items.map((entry) =>
176
+ entry.id === streamId ? { ...entry, text: entry.text + chunk } : entry
177
+ )
178
+ })
179
+ followTranscript()
180
+ },
181
+ [followTranscript, nextId]
182
+ )
183
+
184
+ const clearStreaming = useCallback(() => {
185
+ streamingIdRef.current = null
186
+ }, [])
187
+
188
+ const refreshStatusMeta = useCallback(async () => {
189
+ const controller = controllerRef.current
190
+ if (!controller) {
191
+ return
192
+ }
193
+ const models = await importPatzeDist<typeof import("../../dist/config/models.js")>(
194
+ "config/models.js"
195
+ )
196
+ const config = await importPatzeDist<typeof import("../../dist/config/config.js")>(
197
+ "config/config.js"
198
+ )
199
+ const loaded = config.loadConfig(controller.cwd)
200
+ setCurrentModel(
201
+ models.resolveEffectiveModel({
202
+ sessionOverride: controller.session.getModelOverride(),
203
+ configModel: loaded.model,
204
+ })
205
+ )
206
+ setExecutionMode(controller.session.getExecutionMode())
207
+ setSupportedModels([...models.PATZE_SUPPORTED_MODELS])
208
+
209
+ const cloudGit = await importPatzeDist<
210
+ typeof import("../../dist/cli/interactive/cloud-git.js")
211
+ >("cli/interactive/cloud-git.js")
212
+ if (controller.session.getExecutionMode() === "cloud") {
213
+ const detection = cloudGit.detectCloudRepository(controller.cwd)
214
+ setExecutionTarget(
215
+ detection.ok ? cloudGit.formatCloudTarget(detection) : detection.message
216
+ )
217
+ } else {
218
+ setExecutionTarget(null)
219
+ }
220
+ }, [])
221
+
222
+ useEffect(() => {
223
+ let cancelled = false
224
+
225
+ void (async () => {
226
+ try {
227
+ slashMenuModule = await importPatzeDist("cli/interactive/slash-menu.js")
228
+ setSlashMenuReady(true)
229
+
230
+ const mod = await importPatzeDist<typeof import("../../dist/cli/interactive/session-controller.js")>(
231
+ "cli/interactive/session-controller.js"
232
+ )
233
+ const controller = mod.createInteractiveController({
234
+ cwd,
235
+ interactive: true,
236
+ streamPartials: true,
237
+ onPartial: (chunk: string) => {
238
+ upsertStreaming(chunk)
239
+ },
240
+ }) as InteractiveController
241
+
242
+ if (cancelled) {
243
+ return
244
+ }
245
+
246
+ controllerRef.current = controller
247
+
248
+ const headerEntries = controller.renderHeaderLines().flatMap((line) => {
249
+ const parsed = parseTranscriptLine(line)
250
+ if (!parsed || parsed.continuation) {
251
+ return []
252
+ }
253
+ return [
254
+ {
255
+ id: nextId(),
256
+ kind: "meta" as const,
257
+ label: parsed.label || "info",
258
+ text: parsed.text || line,
259
+ },
260
+ ]
261
+ })
262
+
263
+ const restored: TranscriptEntry[] = []
264
+ for (const turn of controller.session.snapshot()) {
265
+ restored.push({
266
+ id: nextId(),
267
+ kind: "user",
268
+ label: "you",
269
+ text: turn.input,
270
+ })
271
+ for (const entry of classifyPlainOutput(turn.lines)) {
272
+ restored.push({ id: nextId(), ...entry })
273
+ }
274
+ }
275
+
276
+ setTranscript([...headerEntries, ...restored])
277
+ await refreshStatusMeta()
278
+ setReady(true)
279
+ } catch (error) {
280
+ const message = error instanceof Error ? error.message : String(error)
281
+ setBootError(message)
282
+ }
283
+ })()
284
+
285
+ return () => {
286
+ cancelled = true
287
+ }
288
+ }, [cwd, nextId, refreshStatusMeta, upsertStreaming])
289
+
290
+ const exitApp = useCallback(() => {
291
+ controllerRef.current?.persistSession()
292
+ renderer.destroy()
293
+ }, [renderer])
294
+
295
+ const submitToController = useCallback(
296
+ async (rawLine: string) => {
297
+ const controller = controllerRef.current
298
+ if (!controller || busy) {
299
+ return
300
+ }
301
+
302
+ const value = rawLine.trim()
303
+ if (!value) {
304
+ return
305
+ }
306
+
307
+ setBusy(true)
308
+ clearStreaming()
309
+ markScrollPinned()
310
+
311
+ const abort = new AbortController()
312
+ activeAbortRef.current = abort
313
+
314
+ const sink = createTranscriptSink({
315
+ addEntry: (entry) => {
316
+ setTranscript((items) => [...items, { id: nextId(), ...entry }])
317
+ followTranscript()
318
+ },
319
+ upsertEntry: (upsertKey, entry) => {
320
+ upsertTranscriptLine(upsertKey, entry)
321
+ },
322
+ appendToLast,
323
+ upsertStreaming,
324
+ clearStreaming,
325
+ })
326
+
327
+ try {
328
+ const result = await controller.processLine(value, sink, { signal: abort.signal })
329
+ if (result.streamed) {
330
+ clearStreaming()
331
+ }
332
+ await refreshStatusMeta()
333
+ if (result.exitShell) {
334
+ exitApp()
335
+ }
336
+ } finally {
337
+ activeAbortRef.current = null
338
+ setBusy(false)
339
+ clearStreaming()
340
+ }
341
+ },
342
+ [
343
+ appendToLast,
344
+ busy,
345
+ clearStreaming,
346
+ exitApp,
347
+ followTranscript,
348
+ markScrollPinned,
349
+ nextId,
350
+ refreshStatusMeta,
351
+ upsertTranscriptLine,
352
+ upsertStreaming,
353
+ ]
354
+ )
355
+
356
+ const openModelPicker = useCallback(() => {
357
+ setModelSearch("")
358
+ setMode("model")
359
+ }, [])
360
+
361
+ const runCommand = useCallback(
362
+ async (rawCommand: string) => {
363
+ const trimmed = rawCommand.trim()
364
+ setMode("input")
365
+ setInput("")
366
+
367
+ if (!trimmed) {
368
+ return
369
+ }
370
+
371
+ const body = trimmed.replace(/^\//, "")
372
+ const [name, ...rest] = body.split(/\s+/)
373
+ const args = rest.join(" ")
374
+
375
+ switch (name?.toLowerCase()) {
376
+ case "help":
377
+ setInput("/")
378
+ setMode("command")
379
+ return
380
+ case "model":
381
+ if (!args.trim()) {
382
+ openModelPicker()
383
+ return
384
+ }
385
+ break
386
+ case "exit":
387
+ case "quit":
388
+ exitApp()
389
+ return
390
+ default:
391
+ break
392
+ }
393
+
394
+ await submitToController(trimmed)
395
+ },
396
+ [exitApp, openModelPicker, submitToController]
397
+ )
398
+
399
+ const submitInput = useCallback(
400
+ (value: string) => {
401
+ const prompt = value.trim()
402
+ setInput("")
403
+
404
+ if (!prompt || busy) {
405
+ return
406
+ }
407
+
408
+ if (prompt.startsWith("/")) {
409
+ void runCommand(prompt)
410
+ return
411
+ }
412
+
413
+ void submitToController(prompt)
414
+ },
415
+ [busy, runCommand, submitToController]
416
+ )
417
+
418
+ const selectModel = useCallback(
419
+ async (modelId: string) => {
420
+ const controller = controllerRef.current
421
+ if (!controller) {
422
+ return
423
+ }
424
+
425
+ const modelCommand = await importPatzeDist<
426
+ typeof import("../../dist/cli/interactive/model-command.js")
427
+ >("cli/interactive/model-command.js")
428
+
429
+ const result = modelCommand.dispatchModelCommand(controller.session as never, modelId)
430
+ for (const line of result.lines) {
431
+ addEntry(inferErrorKind(line), "model", line)
432
+ }
433
+ await refreshStatusMeta()
434
+ setMode("input")
435
+ },
436
+ [addEntry, refreshStatusMeta]
437
+ )
438
+
439
+ const commandSelectRows = Math.min(6, Math.max(3, rows - 10))
440
+ const modelSelectRows = Math.min(8, Math.max(3, rows - 10))
441
+ const commandPanelRows = 2 + commandSelectRows
442
+ const modelPanelRows = 3 + modelSelectRows
443
+
444
+ const transcriptViewportRows = Math.max(
445
+ 4,
446
+ rows - (mode === "model" ? modelPanelRows + 4 : mode === "command" ? commandPanelRows + 4 : 6)
447
+ )
448
+
449
+ const scrollableEntries = useMemo(
450
+ () => [
451
+ { id: "status-cwd", kind: "meta" as const, label: "cwd", text: cwd },
452
+ {
453
+ id: "status-mode",
454
+ kind: "meta" as const,
455
+ label: "mode",
456
+ text: executionMode,
457
+ },
458
+ ...(executionTarget
459
+ ? [
460
+ {
461
+ id: "status-target",
462
+ kind: "meta" as const,
463
+ label: "target",
464
+ text: executionTarget,
465
+ },
466
+ ]
467
+ : []),
468
+ {
469
+ id: "status-model",
470
+ kind: "meta" as const,
471
+ label: "model",
472
+ text: currentModel,
473
+ },
474
+ ...transcript,
475
+ ],
476
+ [cwd, currentModel, executionMode, executionTarget, transcript]
477
+ )
478
+
479
+ const transcriptLines = useMemo(
480
+ () => buildTranscriptLines(scrollableEntries, columns),
481
+ [columns, scrollableEntries]
482
+ )
483
+
484
+ const maxScrollOffset = Math.max(0, transcriptLines.length - transcriptViewportRows)
485
+ const effectiveScrollOffset = Math.min(scrollOffset, maxScrollOffset)
486
+
487
+ const visibleTranscriptLines = useMemo(() => {
488
+ const end = transcriptLines.length - effectiveScrollOffset
489
+ const start = Math.max(0, end - transcriptViewportRows)
490
+ return transcriptLines.slice(start, end)
491
+ }, [effectiveScrollOffset, transcriptLines, transcriptViewportRows])
492
+
493
+ useEffect(() => {
494
+ setScrollOffset((offset) => Math.min(offset, maxScrollOffset))
495
+ }, [maxScrollOffset])
496
+
497
+ const commandItems = useMemo(() => {
498
+ if (!slashMenuReady || !input.startsWith("/") || input.includes(" ")) {
499
+ return []
500
+ }
501
+ return getCommandItems(input)
502
+ }, [input, slashMenuReady])
503
+
504
+ const modelItems = useMemo(() => {
505
+ const models = supportedModels.length
506
+ ? supportedModels
507
+ : ["composer-2.5-fast", "composer-2.5", "composer-2", "gpt-5.4-mini", "gpt-5.4"]
508
+ const normalized = modelSearch.trim().toLowerCase()
509
+ return models
510
+ .filter((model) => !normalized || model.toLowerCase().includes(normalized))
511
+ .map((model) => ({
512
+ name: model,
513
+ description: model === currentModel ? "current" : "",
514
+ value: model,
515
+ }))
516
+ }, [currentModel, modelSearch, supportedModels])
517
+
518
+ useKeyboard((key: KeyEvent) => {
519
+ const character = getInputCharacter(key)
520
+
521
+ if (key.ctrl && key.name === "c") {
522
+ if (busy && activeAbortRef.current) {
523
+ activeAbortRef.current.abort()
524
+ addEntry("status", "run", "cancelled · stream stopped locally")
525
+ return
526
+ }
527
+ if (!busy) {
528
+ exitApp()
529
+ }
530
+ return
531
+ }
532
+
533
+ if (mode === "model" && key.name === "escape") {
534
+ setMode("input")
535
+ return
536
+ }
537
+
538
+ if (mode === "model") {
539
+ if (key.name === "backspace" || key.name === "delete") {
540
+ setModelSearch((value) => value.slice(0, -1))
541
+ } else if (isSearchInput(character)) {
542
+ setModelSearch((value) => `${value}${character}`)
543
+ }
544
+ return
545
+ }
546
+
547
+ if (mode === "command" && key.name === "escape") {
548
+ setInput("")
549
+ setMode("input")
550
+ return
551
+ }
552
+
553
+ if (mode === "input") {
554
+ const pageSize = Math.max(1, transcriptViewportRows - 1)
555
+
556
+ if (key.name === "up") {
557
+ markScrollUnpinned()
558
+ setScrollOffset((offset) => Math.min(maxScrollOffset, offset + 1))
559
+ } else if (key.name === "down") {
560
+ setScrollOffset((offset) => {
561
+ const next = Math.max(0, offset - 1)
562
+ if (next === 0) {
563
+ scrollPinnedRef.current = true
564
+ }
565
+ return next
566
+ })
567
+ } else if (key.name === "pageup") {
568
+ markScrollUnpinned()
569
+ setScrollOffset((offset) => Math.min(maxScrollOffset, offset + pageSize))
570
+ } else if (key.name === "pagedown") {
571
+ setScrollOffset((offset) => {
572
+ const next = Math.max(0, offset - pageSize)
573
+ if (next === 0) {
574
+ scrollPinnedRef.current = true
575
+ }
576
+ return next
577
+ })
578
+ } else if (key.name === "home") {
579
+ markScrollUnpinned()
580
+ setScrollOffset(maxScrollOffset)
581
+ } else if (key.name === "end") {
582
+ markScrollPinned()
583
+ }
584
+ }
585
+ })
586
+
587
+ const selectCommandOption = (_index: number, option: SelectOption | null) => {
588
+ const item = option as CommandSelectItem | null
589
+ if (item?.value) {
590
+ void runCommand(item.value)
591
+ }
592
+ }
593
+
594
+ const selectModelOption = (_index: number, option: SelectOption | null) => {
595
+ const item = option as ModelSelectItem | null
596
+ if (item?.value) {
597
+ void selectModel(item.value)
598
+ }
599
+ }
600
+
601
+ if (bootError) {
602
+ return createElement(
603
+ "box",
604
+ { flexDirection: "column", height: rows, paddingX: 1 },
605
+ createElement("text", { content: `OpenTUI bootstrap failed: ${bootError}`, fg: "red" }),
606
+ createElement("text", {
607
+ content: "Run: npm run build (in packages/patze-code-cli)",
608
+ attributes: TextAttributes.DIM,
609
+ })
610
+ )
611
+ }
612
+
613
+ return createElement(
614
+ "box",
615
+ { flexDirection: "column", height: rows, paddingX: 1 },
616
+ createElement(
617
+ "box",
618
+ { flexDirection: "column", height: transcriptViewportRows },
619
+ visibleTranscriptLines.map((line) =>
620
+ createElement(TranscriptLine, { key: line.id, line })
621
+ )
622
+ ),
623
+ maxScrollOffset > 0
624
+ ? createElement(
625
+ "text",
626
+ { fg: "gray" },
627
+ `Scroll: Up/Down PgUp/PgDn Home/End · ${
628
+ effectiveScrollOffset === 0 ? "at bottom" : `${effectiveScrollOffset} lines up`
629
+ }`
630
+ )
631
+ : null,
632
+ mode === "command"
633
+ ? createElement(
634
+ "box",
635
+ {
636
+ border: true,
637
+ borderStyle: "single",
638
+ borderColor: "cyan",
639
+ flexDirection: "column",
640
+ marginTop: 1,
641
+ paddingX: 1,
642
+ },
643
+ createElement("text", {
644
+ content: "Commands",
645
+ attributes: TextAttributes.BOLD,
646
+ }),
647
+ createElement("text", {
648
+ content: "Use arrows and Enter, or Escape to cancel.",
649
+ fg: "gray",
650
+ }),
651
+ createElement("select", {
652
+ focused: mode === "command",
653
+ height: commandSelectRows,
654
+ options: commandItems,
655
+ onSelect: selectCommandOption,
656
+ showDescription: false,
657
+ })
658
+ )
659
+ : mode === "model"
660
+ ? createElement(
661
+ "box",
662
+ {
663
+ border: true,
664
+ borderStyle: "single",
665
+ borderColor: "magenta",
666
+ flexDirection: "column",
667
+ marginTop: 1,
668
+ paddingX: 1,
669
+ },
670
+ createElement("text", {
671
+ content: "Select a model",
672
+ attributes: TextAttributes.BOLD,
673
+ }),
674
+ createElement("text", {
675
+ content: "Type to search · Enter choose · Escape cancel",
676
+ fg: "gray",
677
+ }),
678
+ createElement("text", {
679
+ content: `Search: ${modelSearch || "all models"}`,
680
+ fg: "gray",
681
+ }),
682
+ modelItems.length === 0
683
+ ? createElement("text", { content: "No matching models.", fg: "yellow" })
684
+ : createElement("select", {
685
+ focused: mode === "model",
686
+ height: modelSelectRows,
687
+ options: modelItems,
688
+ onSelect: selectModelOption,
689
+ showScrollIndicator: true,
690
+ })
691
+ )
692
+ : createElement(
693
+ "box",
694
+ {
695
+ border: true,
696
+ borderStyle: "single",
697
+ borderColor: busy ? "yellow" : "green",
698
+ marginTop: 1,
699
+ paddingX: 1,
700
+ },
701
+ createElement(TuiInput, {
702
+ focused: ready && !busy,
703
+ placeholder: busy
704
+ ? "Ctrl+C cancel · waiting for run…"
705
+ : ready
706
+ ? "Ask or type /help"
707
+ : "Loading Patze Code…",
708
+ value: input,
709
+ onInput: (value: string) => {
710
+ setInput(value)
711
+ if (busy) {
712
+ return
713
+ }
714
+ if (value.startsWith("/") && !value.includes(" ")) {
715
+ setMode("command")
716
+ } else {
717
+ setMode("input")
718
+ }
719
+ },
720
+ onSubmit: submitInput,
721
+ })
722
+ ),
723
+ createElement("text", {
724
+ content: busy
725
+ ? "Esc exit · Ctrl+C cancel run · preview-first trust loop"
726
+ : "Esc exit · Ctrl+C quit · Patze trust loop (preview-first)",
727
+ attributes: TextAttributes.DIM,
728
+ })
729
+ )
730
+ }
731
+
732
+ function TranscriptLine({ line }: { line: TranscriptLine }) {
733
+ const color = {
734
+ assistant: "white",
735
+ error: "red",
736
+ meta: "cyan",
737
+ status: "yellow",
738
+ tool: "magenta",
739
+ user: "green",
740
+ }[line.kind]
741
+
742
+ return createElement(
743
+ "box",
744
+ null,
745
+ createElement("text", {
746
+ content: line.label.padEnd(7),
747
+ fg: color,
748
+ attributes: TextAttributes.BOLD,
749
+ }),
750
+ createElement(
751
+ "text",
752
+ null,
753
+ line.parts.map((part, index) =>
754
+ createElement(
755
+ "span",
756
+ {
757
+ key: index,
758
+ attributes: partToAttributes(part),
759
+ fg: part.color,
760
+ },
761
+ part.text
762
+ )
763
+ )
764
+ )
765
+ )
766
+ }
767
+
768
+ function partToAttributes(part: TranscriptPart) {
769
+ let attributes = TextAttributes.NONE
770
+ if (part.bold) {
771
+ attributes |= TextAttributes.BOLD
772
+ }
773
+ if (part.dimColor) {
774
+ attributes |= TextAttributes.DIM
775
+ }
776
+ return attributes
777
+ }
778
+
779
+ function getInputCharacter(key: KeyEvent): string {
780
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
781
+ return key.sequence
782
+ }
783
+ return ""
784
+ }
785
+
786
+ function isSearchInput(character: string): boolean {
787
+ return Boolean(character && character !== " " && character.charCodeAt(0) >= 32)
788
+ }
789
+
790
+ type SlashMenuModule = typeof import("../../dist/cli/interactive/slash-menu.js")
791
+
792
+ let slashMenuModule: SlashMenuModule | null = null
793
+
794
+ function getCommandItems(input: string): CommandSelectItem[] {
795
+ if (!slashMenuModule) {
796
+ return []
797
+ }
798
+ const menu = slashMenuModule.computeSlashMenuItems(input)
799
+ return menu.items.map((command) => ({
800
+ name: `/${command.name}`,
801
+ description: command.description,
802
+ value: `/${command.name}`,
803
+ }))
804
+ }
805
+
806
+ export { parseCwdArg }