@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.
- package/CHANGELOG.md +33 -0
- package/VERSION +1 -1
- package/dist/backend/run-stream-client.d.ts +1 -0
- package/dist/backend/run-stream-client.d.ts.map +1 -1
- package/dist/backend/run-stream-client.js +17 -0
- package/dist/backend/run-stream-client.js.map +1 -1
- package/dist/cli/interactive/agent-execute-turn.d.ts +1 -0
- package/dist/cli/interactive/agent-execute-turn.d.ts.map +1 -1
- package/dist/cli/interactive/agent-execute-turn.js +24 -1
- package/dist/cli/interactive/agent-execute-turn.js.map +1 -1
- package/dist/cli/interactive/agent-turn.d.ts +1 -0
- package/dist/cli/interactive/agent-turn.d.ts.map +1 -1
- package/dist/cli/interactive/agent-turn.js +1 -0
- package/dist/cli/interactive/agent-turn.js.map +1 -1
- package/dist/cli/interactive/composer-keys.d.ts +11 -0
- package/dist/cli/interactive/composer-keys.d.ts.map +1 -0
- package/dist/cli/interactive/composer-keys.js +19 -0
- package/dist/cli/interactive/composer-keys.js.map +1 -0
- package/dist/cli/interactive/header.d.ts.map +1 -1
- package/dist/cli/interactive/header.js +0 -1
- package/dist/cli/interactive/header.js.map +1 -1
- package/dist/cli/interactive/line-editor.d.ts +23 -0
- package/dist/cli/interactive/line-editor.d.ts.map +1 -1
- package/dist/cli/interactive/line-editor.js +95 -24
- package/dist/cli/interactive/line-editor.js.map +1 -1
- package/dist/cli/interactive/session-controller.d.ts +4 -1
- package/dist/cli/interactive/session-controller.d.ts.map +1 -1
- package/dist/cli/interactive/session-controller.js +2 -1
- package/dist/cli/interactive/session-controller.js.map +1 -1
- package/dist/cli/interactive/shell.d.ts.map +1 -1
- package/dist/cli/interactive/shell.js +0 -1
- package/dist/cli/interactive/shell.js.map +1 -1
- package/dist/cli/interactive/slash-dispatch.d.ts +1 -0
- package/dist/cli/interactive/slash-dispatch.d.ts.map +1 -1
- package/dist/cli/interactive/slash-dispatch.js +1 -0
- package/dist/cli/interactive/slash-dispatch.js.map +1 -1
- package/dist/cli/interactive/transcript-upsert.d.ts +12 -0
- package/dist/cli/interactive/transcript-upsert.d.ts.map +1 -0
- package/dist/cli/interactive/transcript-upsert.js +33 -0
- package/dist/cli/interactive/transcript-upsert.js.map +1 -0
- package/opentui/package.json +18 -0
- package/opentui/scripts/assert-parse.mjs +21 -0
- package/opentui/src/App.tsx +806 -0
- package/opentui/src/line-parse.ts +54 -0
- package/opentui/src/main.tsx +45 -0
- package/opentui/src/patze-dist.ts +11 -0
- package/opentui/src/transcript-render.ts +218 -0
- package/opentui/src/tui-sink.ts +78 -0
- package/opentui/tsconfig.json +13 -0
- 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 }
|