@xl0/pi-lovely-agents 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 (29) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +184 -0
  4. package/extensions/lovely-agents/agent.ts +1374 -0
  5. package/extensions/lovely-agents/bash.ts +599 -0
  6. package/extensions/lovely-agents/child-session.ts +296 -0
  7. package/extensions/lovely-agents/config.ts +221 -0
  8. package/extensions/lovely-agents/coordinator.ts +506 -0
  9. package/extensions/lovely-agents/definitions.ts +380 -0
  10. package/extensions/lovely-agents/index.ts +400 -0
  11. package/extensions/lovely-agents/lifecycle.ts +251 -0
  12. package/extensions/lovely-agents/management.ts +638 -0
  13. package/extensions/lovely-agents/notifications.ts +220 -0
  14. package/extensions/lovely-agents/provider-limits.ts +13 -0
  15. package/extensions/lovely-agents/rendering.ts +90 -0
  16. package/extensions/lovely-agents/state.ts +1179 -0
  17. package/extensions/lovely-agents/task-panel.ts +192 -0
  18. package/extensions/lovely-agents/tools.ts +635 -0
  19. package/extensions/lovely-agents/updates.ts +45 -0
  20. package/node_modules/@xl0/pi-lovely-config/CHANGELOG.md +79 -0
  21. package/node_modules/@xl0/pi-lovely-config/LICENSE +21 -0
  22. package/node_modules/@xl0/pi-lovely-config/README.md +200 -0
  23. package/node_modules/@xl0/pi-lovely-config/package.json +59 -0
  24. package/node_modules/@xl0/pi-lovely-config/src/config.ts +399 -0
  25. package/node_modules/@xl0/pi-lovely-config/src/index.ts +3 -0
  26. package/node_modules/@xl0/pi-lovely-config/src/ui.ts +786 -0
  27. package/package.json +68 -0
  28. package/skills/agent/SKILL.md +21 -0
  29. package/skills/agent-creator/SKILL.md +35 -0
@@ -0,0 +1,786 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent"
2
+ import {
3
+ CURSOR_MARKER,
4
+ Editor,
5
+ type EditorTheme,
6
+ fuzzyFilter,
7
+ getKeybindings,
8
+ Input,
9
+ Key,
10
+ matchesKey,
11
+ type TUI,
12
+ truncateToWidth,
13
+ visibleWidth,
14
+ wrapTextWithAnsi
15
+ } from "@earendil-works/pi-tui"
16
+ import type { ConfigPatch, ConfigScope, ScopedConfig, ScopedConfigField, ScopedConfigPatch } from "./config"
17
+ import { getConfigWarnings } from "./config"
18
+
19
+ type Keybindings = ReturnType<typeof getKeybindings>
20
+
21
+ type ScopedConfigChangeHandler<Config extends object> = (config: ScopedConfig<Config>) => void
22
+ type FocusPart = "include" | "value"
23
+ type NumberInputParseResult = { ok: true; value: number } | { ok: false; message: string }
24
+ type EnumField = Extract<ScopedConfigField, { kind: "enum" | "multiEnum" }>
25
+ // pending holds the uncommitted multiEnum selection; absent for single enums
26
+ type EnumSearchState = { input: Input; fieldKey: string; selectedIndex: number; pending: Set<string> | undefined }
27
+ type TextEditorState = { editor: Editor; fieldKey: string }
28
+
29
+ export class ScopedConfigEditor<Config extends object> {
30
+ private readonly tui: TUI
31
+ private readonly theme: Theme
32
+ private readonly config: ScopedConfig<Config>
33
+ private readonly onChange: ScopedConfigChangeHandler<Config>
34
+ private readonly fields: readonly ScopedConfigField[]
35
+ private readonly scopes: readonly ConfigScope[]
36
+ private readonly done: (result: undefined) => void
37
+ private activeInput: Input | undefined
38
+ private activeEnumSearch: EnumSearchState | undefined
39
+ private activeTextEditor: TextEditorState | undefined
40
+ private activeInputError: string | undefined
41
+ private focusPart: FocusPart = "value"
42
+ private currentTab = 0
43
+ private currentRow = 0
44
+
45
+ constructor(options: {
46
+ tui: TUI
47
+ theme: Theme
48
+ config: ScopedConfig<Config>
49
+ onChange: ScopedConfigChangeHandler<Config>
50
+ done: (result: undefined) => void
51
+ }) {
52
+ this.tui = options.tui
53
+ this.theme = options.theme
54
+ this.config = options.config
55
+ this.onChange = options.onChange
56
+ this.fields = options.config.fields
57
+ this.scopes = options.config.scopes
58
+ this.done = options.done
59
+ this.updateFocus()
60
+ }
61
+
62
+ private get scoped(): ScopedConfigPatch {
63
+ return this.config.scoped
64
+ }
65
+
66
+ render(width: number): string[] {
67
+ const lines: string[] = []
68
+ const renderWidth = Math.max(1, width)
69
+ const scope = this.currentScope()
70
+ const fields = this.visibleFields(scope)
71
+
72
+ lines.push(this.theme.fg("accent", "─".repeat(renderWidth)))
73
+ this.renderTabs(lines, renderWidth)
74
+ lines.push("")
75
+ this.renderScopeHeader(lines, renderWidth)
76
+ lines.push("")
77
+ this.renderActiveFieldDescription(lines, renderWidth, fields)
78
+ this.renderRows(lines, renderWidth, scope, fields)
79
+ this.renderActiveValueDescription(lines, renderWidth, scope, fields)
80
+ lines.push("")
81
+ addWrappedWithPrefix(
82
+ lines,
83
+ renderWidth,
84
+ " ",
85
+ this.theme.fg("dim", "Tab switch scope • ↑↓ select • ←→ include/value • Enter edit/cycle • Space toggle/step • Esc close")
86
+ )
87
+ lines.push(this.theme.fg("accent", "─".repeat(renderWidth)))
88
+
89
+ return lines
90
+ }
91
+
92
+ invalidate() {}
93
+
94
+ handleInput(data: string): void {
95
+ const kb = getKeybindings()
96
+ const isSpace = data === " " || matchesKey(data, Key.space) || matchesKey(data, Key.shift("space"))
97
+ if (this.handleCloseKey(data, kb)) return
98
+ if (this.handleActiveTextEditorInput(data, kb)) return
99
+ if (this.handleNavigationKey(data, kb)) return
100
+ if (this.handleActiveInput(data, kb, isSpace)) return
101
+ if (this.handleActivationKey(data, kb, isSpace)) return
102
+ }
103
+
104
+ private handleCloseKey(data: string, kb: Keybindings): boolean {
105
+ if (matchesKey(data, Key.ctrl("c"))) {
106
+ this.done(undefined)
107
+ return true
108
+ }
109
+ if ((this.activeInput || this.activeEnumSearch || this.activeTextEditor) && kb.matches(data, "tui.select.cancel")) {
110
+ this.activeInput = undefined
111
+ this.activeEnumSearch = undefined
112
+ this.activeTextEditor = undefined
113
+ this.activeInputError = undefined
114
+ this.tui.requestRender()
115
+ return true
116
+ }
117
+ if (kb.matches(data, "tui.select.cancel")) {
118
+ this.done(undefined)
119
+ return true
120
+ }
121
+ return false
122
+ }
123
+
124
+ private handleNavigationKey(data: string, kb: Keybindings): boolean {
125
+ if (this.activeEnumSearch) {
126
+ if (kb.matches(data, "tui.select.up")) {
127
+ this.moveEnumSearchSelection(-1)
128
+ return true
129
+ }
130
+ if (kb.matches(data, "tui.select.down")) {
131
+ this.moveEnumSearchSelection(1)
132
+ return true
133
+ }
134
+ return false
135
+ }
136
+ if (kb.matches(data, "tui.select.up")) {
137
+ if (!this.commitActiveInput()) return true
138
+ this.moveRow(-1)
139
+ return true
140
+ }
141
+ if (kb.matches(data, "tui.select.down")) {
142
+ if (!this.commitActiveInput()) return true
143
+ this.moveRow(1)
144
+ return true
145
+ }
146
+ if (matchesKey(data, Key.tab)) {
147
+ if (!this.commitActiveInput()) return true
148
+ this.switchTab(1)
149
+ return true
150
+ }
151
+ if (this.activeInput && (kb.matches(data, "tui.editor.cursorLeft") || kb.matches(data, "tui.editor.cursorRight"))) return false
152
+ if (kb.matches(data, "tui.editor.cursorRight")) {
153
+ const field = this.selectedField()
154
+ if (!field || getConfigValue(this.scoped[this.currentScope()], field.key) === undefined) return true
155
+ this.focusPart = "value"
156
+ this.tui.requestRender()
157
+ return true
158
+ }
159
+ if (!kb.matches(data, "tui.editor.cursorLeft")) return false
160
+ if (!this.commitActiveInput()) return true
161
+ this.focusPart = "include"
162
+ this.activeInput = undefined
163
+ this.tui.requestRender()
164
+ return true
165
+ }
166
+
167
+ private handleActivationKey(data: string, kb: Keybindings, isSpace: boolean): boolean {
168
+ if (kb.matches(data, "tui.input.submit")) {
169
+ this.activateRow()
170
+ return true
171
+ }
172
+ if (isSpace) {
173
+ const field = this.selectedField()
174
+ if (field && this.focusPart === "include") this.toggleField(this.currentScope(), field)
175
+ else if (field?.kind === "number" && !field.values)
176
+ this.saveValue(this.currentScope(), field, nextNumberValue(field, getConfigValue(this.scoped[this.currentScope()], field.key)))
177
+ else if (field?.kind === "enum" && field.search)
178
+ this.saveValue(this.currentScope(), field, nextFieldValue(this.scoped[this.currentScope()], field))
179
+ else if (field?.kind !== "string" && field?.kind !== "text") this.activateRow()
180
+ else if (this.isResetSelected()) this.reset(this.currentScope())
181
+ return true
182
+ }
183
+ return false
184
+ }
185
+
186
+ private renderTabs(lines: string[], width: number): void {
187
+ const tabs = []
188
+ for (const [index, scope] of this.scopes.entries()) {
189
+ const isUnset = this.scopeIsUnset(scope)
190
+ const text = ` ${isUnset ? "□" : "■"} ${scopeLabel(scope)} `
191
+ const styled =
192
+ index === this.currentTab
193
+ ? this.theme.bg("selectedBg", this.theme.fg("text", text))
194
+ : this.theme.fg(isUnset ? "muted" : "success", text)
195
+ tabs.push(`${styled} `)
196
+ }
197
+ addWrappedWithPrefix(lines, width, " ", tabs.join(""))
198
+ }
199
+
200
+ private renderScopeHeader(lines: string[], width: number): void {
201
+ const scope = this.scopes[this.currentTab]
202
+ if (!scope) return
203
+
204
+ const path = this.config.path(scope)
205
+ addWrappedWithPrefix(
206
+ lines,
207
+ width,
208
+ " ",
209
+ `${this.theme.fg("accent", this.theme.bold(`${scopeLabel(scope)} config`))} ${this.theme.fg("dim", path)}`
210
+ )
211
+ }
212
+
213
+ private renderRows(lines: string[], width: number, scope: ConfigScope, fields: readonly ScopedConfigField[]): void {
214
+ for (const [index, field] of fields.entries()) {
215
+ const selected = index === this.currentRow
216
+ const prefix = this.theme.fg(selected ? "accent" : "muted", `${selected ? "> " : " "}${" ".repeat(field.depth ?? 0)}`)
217
+ const rawValue = getConfigValue(this.scoped[scope], field.key)
218
+ const isSet = rawValue !== undefined
219
+ const include = `${isSet ? "[x]" : "[ ]"} `
220
+ const renderedInclude = this.theme.fg(selected && this.focusPart === "include" ? "accent" : "muted", include)
221
+ let value = formatScopedValue(this.scoped[scope], field)
222
+ if (selected && this.focusPart === "value" && this.activeInput) {
223
+ if (field.kind === "string") value = renderStringInput(this.activeInput, width)
224
+ else if (field.kind === "number" && !field.values) value = renderInput(this.activeInput, width)
225
+ }
226
+ if (selected && this.focusPart === "value" && this.activeEnumSearch?.fieldKey === field.key) {
227
+ value = renderInput(this.activeEnumSearch.input, width)
228
+ }
229
+ const note = getScopeNote(scope, this.scopes, this.scoped, field)
230
+ const renderedNote = note ? ` ${this.theme.fg("muted", `(${note})`)}` : ""
231
+ const valueStyle = getFieldWarning(this.scoped[scope], field)
232
+ ? "warning"
233
+ : !isSet
234
+ ? "muted"
235
+ : selected && this.focusPart === "value"
236
+ ? "accent"
237
+ : "text"
238
+ const renderedValue = !isSet && note ? "" : ` ${this.theme.fg(valueStyle, value)}`
239
+ addWrappedWithPrefix(lines, width, prefix, `${renderedInclude}${this.theme.fg("text", field.label)}${renderedValue}${renderedNote}`)
240
+ if (selected) {
241
+ this.renderEnumSearch(lines, width, field, " ".repeat(visibleWidth(prefix) + visibleWidth(include) + visibleWidth(field.label) + 1))
242
+ this.renderTextEditor(lines, width, field, " ".repeat(visibleWidth(prefix) + visibleWidth(include)))
243
+ }
244
+ }
245
+ this.renderResetRow(lines, width, this.isResetSelected(fields))
246
+ }
247
+
248
+ private renderEnumSearch(lines: string[], width: number, field: ScopedConfigField, indent: string): void {
249
+ const state = this.activeEnumSearch
250
+ if (!state || !isEnumField(field) || field.key !== state.fieldKey) return
251
+
252
+ const values = enumSearchValues(field, state.input.getValue(), state.pending)
253
+ if (values.length === 0) {
254
+ addWrappedWithPrefix(lines, width, indent, this.theme.fg("muted", "No matching values"))
255
+ return
256
+ }
257
+
258
+ const maxVisible = 8
259
+ const startIndex = Math.max(0, Math.min(state.selectedIndex - Math.floor(maxVisible / 2), values.length - maxVisible))
260
+ const endIndex = Math.min(startIndex + maxVisible, values.length)
261
+ for (let index = startIndex; index < endIndex; index++) {
262
+ const value = values[index] ?? ""
263
+ const selected = index === state.selectedIndex
264
+ const marker = state.pending ? (state.pending.has(value) ? this.theme.fg("accent", "✓ ") : " ") : ""
265
+ const prefix = `${indent}${this.theme.fg(selected ? "accent" : "muted", selected ? "> " : " ")}${marker}`
266
+ const description = field.valueDescriptions?.[value]
267
+ const renderedValue = this.theme.fg(selected ? "accent" : "text", value)
268
+ const renderedDescription = description ? ` ${this.theme.fg("muted", description)}` : ""
269
+ addWrappedWithPrefix(lines, width, prefix, `${renderedValue}${renderedDescription}`)
270
+ }
271
+
272
+ if (startIndex > 0 || endIndex < values.length) {
273
+ addWrappedWithPrefix(lines, width, indent, this.theme.fg("muted", `(${state.selectedIndex + 1}/${values.length})`))
274
+ }
275
+ }
276
+
277
+ private renderTextEditor(lines: string[], width: number, field: ScopedConfigField, indent: string): void {
278
+ const state = this.activeTextEditor
279
+ if (!state || field.kind !== "text" || field.key !== state.fieldKey) return
280
+
281
+ const editorWidth = Math.max(1, width - visibleWidth(indent))
282
+ for (const line of state.editor.render(editorWidth)) lines.push(`${indent}${line}`)
283
+ }
284
+
285
+ private renderActiveValueDescription(lines: string[], width: number, scope: ConfigScope, fields: readonly ScopedConfigField[]): void {
286
+ const field = this.selectedField(fields)
287
+ const error = this.activeInput ? this.activeInputError : undefined
288
+ const warning = field ? getFieldWarning(this.scoped[scope], field) : undefined
289
+ const valueDescription =
290
+ field && !this.activeEnumSearch && !this.activeTextEditor ? getValueDescription(this.scoped[scope], field) : undefined
291
+ lines.push("")
292
+ if (error) addWrappedWithPrefix(lines, width, " ", this.theme.fg("error", error))
293
+ else if (this.activeTextEditor)
294
+ addWrappedWithPrefix(lines, width, " ", this.theme.fg("muted", "Enter save • Shift+Enter newline • Esc discard"))
295
+ else if (this.activeEnumSearch?.pending)
296
+ addWrappedWithPrefix(lines, width, " ", this.theme.fg("muted", "Space toggle • Enter save • Esc discard"))
297
+ else if (warning) addWrappedWithPrefix(lines, width, " ", this.theme.fg("warning", warning))
298
+ else if (valueDescription) addWrappedWithPrefix(lines, width, " ", this.theme.fg("muted", valueDescription))
299
+ else lines.push("")
300
+ }
301
+
302
+ private renderActiveFieldDescription(lines: string[], width: number, fields: readonly ScopedConfigField[]): void {
303
+ const description = this.selectedField(fields)?.description
304
+ const wrapped = description ? wrapTextWithAnsi(this.theme.fg("muted", description), Math.max(1, width - 1)).slice(0, 2) : []
305
+ for (let index = 0; index < 2; index++) lines.push(wrapped[index] ? ` ${wrapped[index]}` : "")
306
+ }
307
+
308
+ private renderResetRow(lines: string[], width: number, selected: boolean): void {
309
+ lines.push(this.theme.fg("dim", ` ${"─".repeat(Math.max(1, width - 2))}`))
310
+ const prefix = this.theme.fg(selected ? "accent" : "muted", selected ? "> " : " ")
311
+ addWrappedWithPrefix(
312
+ lines,
313
+ width,
314
+ prefix,
315
+ `${this.theme.fg("text", `Reset ${scopeLabel(this.currentScope())} to default`)} ${this.theme.fg("muted", "delete this scope config file")}`
316
+ )
317
+ }
318
+
319
+ private currentScope(): ConfigScope {
320
+ return this.scopes[this.currentTab] ?? "user"
321
+ }
322
+
323
+ private resolvedConfig(scope: ConfigScope): Record<string, unknown> {
324
+ const scoped = { user: {}, workspace: {} } as ScopedConfigPatch
325
+ for (const configScope of this.scopes) {
326
+ scoped[configScope] = this.scoped[configScope]
327
+ if (configScope === scope) break
328
+ }
329
+ return this.config.resolve(scoped)
330
+ }
331
+
332
+ private visibleFields(scope: ConfigScope = this.currentScope()): ScopedConfigField[] {
333
+ const resolved = this.resolvedConfig(scope)
334
+ return this.fields.filter(field => isFieldVisible(field, scope, this.scoped, resolved))
335
+ }
336
+
337
+ private selectedField(fields: readonly ScopedConfigField[] = this.visibleFields()): ScopedConfigField | undefined {
338
+ return fields[this.currentRow]
339
+ }
340
+
341
+ private isResetSelected(fields: readonly ScopedConfigField[] = this.visibleFields()): boolean {
342
+ return this.currentRow === fields.length
343
+ }
344
+
345
+ private rowCount(): number {
346
+ return this.visibleFields().length + 1
347
+ }
348
+
349
+ private scopeIsUnset(scope: ConfigScope): boolean {
350
+ return this.fields.every(field => getConfigValue(this.scoped[scope], field.key) === undefined)
351
+ }
352
+
353
+ private refresh(): void {
354
+ this.currentRow = Math.min(this.currentRow, this.rowCount() - 1)
355
+ if (this.activeEnumSearch && this.selectedField()?.key !== this.activeEnumSearch.fieldKey) this.activeEnumSearch = undefined
356
+ if (this.activeTextEditor && this.selectedField()?.key !== this.activeTextEditor.fieldKey) this.activeTextEditor = undefined
357
+ this.updateFocus()
358
+ this.tui.requestRender()
359
+ }
360
+
361
+ private updateFocus(): void {
362
+ if (this.activeEnumSearch || this.activeTextEditor) {
363
+ this.focusPart = "value"
364
+ return
365
+ }
366
+ const field = this.selectedField()
367
+ this.focusPart = field && getConfigValue(this.scoped[this.currentScope()], field.key) === undefined ? "include" : "value"
368
+ }
369
+
370
+ private switchTab(delta: number): void {
371
+ this.currentTab = (this.currentTab + delta + this.scopes.length) % this.scopes.length
372
+ this.refresh()
373
+ }
374
+
375
+ private moveRow(delta: number): void {
376
+ const rowCount = this.rowCount()
377
+ this.currentRow = (this.currentRow + delta + rowCount) % rowCount
378
+ this.refresh()
379
+ }
380
+
381
+ private activateRow(): void {
382
+ const scope = this.currentScope()
383
+ const fields = this.visibleFields(scope)
384
+ const field = this.selectedField(fields)
385
+ if (!field) {
386
+ if (this.isResetSelected(fields)) this.reset(scope)
387
+ return
388
+ }
389
+ if (this.focusPart === "include") this.toggleField(scope, field)
390
+ else if (getConfigValue(this.scoped[scope], field.key) === undefined) this.saveValue(scope, field, field.default)
391
+ else if (field.kind === "number" && field.values)
392
+ this.saveValue(scope, field, nextNumberValue(field, getConfigValue(this.scoped[scope], field.key)))
393
+ else if (field.kind === "multiEnum" || (field.kind === "enum" && field.search)) this.startEnumSearch(field)
394
+ else if (field.kind === "text") this.startTextEditor(field)
395
+ else if (fieldUsesInput(field)) this.startInput(field)
396
+ else this.saveValue(scope, field, nextFieldValue(this.scoped[scope], field))
397
+ }
398
+
399
+ private toggleField(scope: ConfigScope, field: ScopedConfigField): void {
400
+ const current = getConfigValue(this.scoped[scope], field.key)
401
+ const nextIsSet = current === undefined
402
+ this.activeInput = undefined
403
+ this.activeTextEditor = undefined
404
+ this.saveValue(scope, field, nextIsSet ? field.default : undefined)
405
+ }
406
+
407
+ private startInput(field: ScopedConfigField, initial?: string): void {
408
+ if (!fieldUsesInput(field)) return
409
+ this.activeEnumSearch = undefined
410
+ const value = initial ?? getConfigValue(this.scoped[this.currentScope()], field.key)
411
+ const input = new Input()
412
+ input.setValue(typeof value === "string" || typeof value === "number" ? String(value) : "")
413
+ input.focused = true
414
+ this.activeInput = input
415
+ this.updateActiveInputError(field)
416
+ this.tui.requestRender()
417
+ }
418
+
419
+ private startTextEditor(field: Extract<ScopedConfigField, { kind: "text" }>): void {
420
+ const editor = new Editor(this.tui, getTextEditorTheme(this.theme), { paddingX: 0 })
421
+ const value = getConfigValue(this.scoped[this.currentScope()], field.key)
422
+ editor.setText(typeof value === "string" ? value : "")
423
+ editor.focused = true
424
+ editor.disableSubmit = true
425
+ editor.onChange = () => this.tui.requestRender()
426
+ this.activeInput = undefined
427
+ this.activeEnumSearch = undefined
428
+ this.activeInputError = undefined
429
+ this.activeTextEditor = { editor, fieldKey: field.key }
430
+ this.tui.requestRender()
431
+ }
432
+
433
+ private startEnumSearch(field: EnumField): void {
434
+ const input = new Input()
435
+ input.focused = true
436
+ const value = getConfigValue(this.scoped[this.currentScope()], field.key)
437
+ const selectedIndex = typeof value === "string" ? Math.max(0, field.values.indexOf(value)) : 0
438
+ const pending = field.kind === "multiEnum" ? new Set(Array.isArray(value) ? (value as string[]) : []) : undefined
439
+ this.activeInput = undefined
440
+ this.activeInputError = undefined
441
+ this.activeEnumSearch = { input, fieldKey: field.key, selectedIndex, pending }
442
+ this.tui.requestRender()
443
+ }
444
+
445
+ private handleActiveInput(data: string, kb: Keybindings, isSpace: boolean): boolean {
446
+ if (this.handleEnumSearchInput(data, kb, isSpace)) return true
447
+
448
+ const field = this.selectedField()
449
+ if (this.focusPart !== "value" || !field || !fieldUsesInput(field)) return false
450
+
451
+ if (this.activeInput && kb.matches(data, "tui.input.submit")) {
452
+ this.commitActiveInput()
453
+ return true
454
+ }
455
+
456
+ if (this.activeInput) {
457
+ if (field.kind === "number" && !isNumberInputAllowed(data, this.activeInput, kb)) return true
458
+ this.activeInput.handleInput(data)
459
+ this.updateActiveInputError(field)
460
+ this.tui.requestRender()
461
+ return true
462
+ }
463
+
464
+ return false
465
+ }
466
+
467
+ private handleActiveTextEditorInput(data: string, kb: Keybindings): boolean {
468
+ const state = this.activeTextEditor
469
+ const field = this.selectedField()
470
+ if (!state || field?.kind !== "text" || field.key !== state.fieldKey) return false
471
+
472
+ if (kb.matches(data, "tui.input.submit")) {
473
+ this.activeTextEditor = undefined
474
+ this.saveValue(this.currentScope(), field, state.editor.getExpandedText())
475
+ return true
476
+ }
477
+
478
+ state.editor.handleInput(data)
479
+ this.tui.requestRender()
480
+ return true
481
+ }
482
+
483
+ private handleEnumSearchInput(data: string, kb: Keybindings, isSpace: boolean): boolean {
484
+ const state = this.activeEnumSearch
485
+ const field = this.selectedField()
486
+ if (!state || !isEnumField(field) || field.key !== state.fieldKey) return false
487
+
488
+ const pending = state.pending
489
+ const query = state.input.getValue()
490
+ const highlighted = enumSearchValues(field, query, pending)[state.selectedIndex]
491
+ if (kb.matches(data, "tui.input.submit")) {
492
+ if (pending) {
493
+ this.saveValue(
494
+ this.currentScope(),
495
+ field,
496
+ field.values.filter(value => pending.has(value))
497
+ )
498
+ } else if (highlighted !== undefined) {
499
+ this.activeEnumSearch = undefined
500
+ this.saveValue(this.currentScope(), field, highlighted)
501
+ }
502
+ return true
503
+ }
504
+ if (pending && isSpace) {
505
+ if (highlighted !== undefined) {
506
+ if (pending.has(highlighted)) pending.delete(highlighted)
507
+ else pending.add(highlighted)
508
+ // list reorders on toggle; keep the cursor on the same item
509
+ state.selectedIndex = enumSearchValues(field, query, pending).indexOf(highlighted)
510
+ }
511
+ this.tui.requestRender()
512
+ return true
513
+ }
514
+
515
+ const previousQuery = state.input.getValue()
516
+ state.input.handleInput(data)
517
+ if (state.input.getValue() !== previousQuery) state.selectedIndex = 0
518
+ this.tui.requestRender()
519
+ return true
520
+ }
521
+
522
+ private moveEnumSearchSelection(delta: number): void {
523
+ const state = this.activeEnumSearch
524
+ const field = this.selectedField()
525
+ if (!state || !isEnumField(field) || field.key !== state.fieldKey) return
526
+ const values = enumSearchValues(field, state.input.getValue(), state.pending)
527
+ if (values.length === 0) return
528
+ state.selectedIndex = (state.selectedIndex + delta + values.length) % values.length
529
+ this.tui.requestRender()
530
+ }
531
+
532
+ private commitActiveInput(): boolean {
533
+ const field = this.selectedField()
534
+ if (!field || !fieldUsesInput(field) || !this.activeInput) return true
535
+ const value = this.activeInput.getValue()
536
+ if (field.kind === "number") {
537
+ const parsed = parseNumberInput(field, value)
538
+ if (!parsed.ok) {
539
+ this.activeInputError = parsed.message
540
+ this.tui.requestRender()
541
+ return false
542
+ }
543
+ this.activeInput = undefined
544
+ this.activeInputError = undefined
545
+ this.saveValue(this.currentScope(), field, parsed.value)
546
+ return true
547
+ }
548
+
549
+ this.activeInput = undefined
550
+ this.activeInputError = undefined
551
+ this.saveValue(this.currentScope(), field, value)
552
+ return true
553
+ }
554
+
555
+ private updateActiveInputError(field: ScopedConfigField): void {
556
+ if (field.kind !== "number" || field.values || !this.activeInput) {
557
+ this.activeInputError = undefined
558
+ return
559
+ }
560
+
561
+ const parsed = parseNumberInput(field, this.activeInput.getValue())
562
+ this.activeInputError = parsed.ok ? undefined : parsed.message
563
+ }
564
+
565
+ private saveValue(scope: ConfigScope, field: ScopedConfigField, value: unknown): void {
566
+ this.activeEnumSearch = undefined
567
+ this.activeTextEditor = undefined
568
+ this.activeInputError = undefined
569
+ this.config.update(scope, field.key as keyof Config & string, value as Config[keyof Config & string] | undefined)
570
+ this.onChange(this.config)
571
+ this.refresh()
572
+ }
573
+
574
+ private reset(scope: ConfigScope): void {
575
+ this.config.resetScope(scope)
576
+ this.onChange(this.config)
577
+ this.refresh()
578
+ }
579
+ }
580
+
581
+ function addWrappedWithPrefix(lines: string[], width: number, prefix: string, text: string): void {
582
+ const prefixWidth = visibleWidth(prefix)
583
+ if (prefixWidth >= width) {
584
+ lines.push(...wrapTextWithAnsi(prefix + text, width))
585
+ return
586
+ }
587
+
588
+ const wrapped = wrapTextWithAnsi(text, width - prefixWidth)
589
+ const continuationPrefix = " ".repeat(prefixWidth)
590
+ for (let i = 0; i < wrapped.length; i++) lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`)
591
+ }
592
+
593
+ function isFieldVisible(
594
+ field: ScopedConfigField,
595
+ scope: ConfigScope,
596
+ configs: ScopedConfigPatch,
597
+ resolved: Record<string, unknown>
598
+ ): boolean {
599
+ if (!field.visibleWhen) return true
600
+ return field.visibleWhen({
601
+ scope,
602
+ get: key => resolved[key],
603
+ getScoped: (key, targetScope = scope) => getConfigValue(configs[targetScope], key)
604
+ })
605
+ }
606
+
607
+ function getConfigValue(config: object, key: string): unknown {
608
+ return (config as Record<string, unknown>)[key]
609
+ }
610
+
611
+ function isEnumField(field: ScopedConfigField | undefined): field is EnumField {
612
+ return field?.kind === "enum" || field?.kind === "multiEnum"
613
+ }
614
+
615
+ function fieldUsesInput(field: ScopedConfigField): boolean {
616
+ return field.kind === "string" || (field.kind === "number" && !field.values)
617
+ }
618
+
619
+ function getTextEditorTheme(theme: Theme): EditorTheme {
620
+ return {
621
+ borderColor: text => theme.fg("border", text),
622
+ selectList: {
623
+ selectedPrefix: text => theme.fg("accent", text),
624
+ selectedText: text => theme.bg("selectedBg", theme.fg("text", text)),
625
+ description: text => theme.fg("muted", text),
626
+ scrollInfo: text => theme.fg("muted", text),
627
+ noMatch: text => theme.fg("muted", text)
628
+ }
629
+ }
630
+ }
631
+
632
+ function renderInput(input: Input, width: number): string {
633
+ const rendered = input.render(width + 2)[0]?.slice(2) ?? ""
634
+ return rendered.trimEnd()
635
+ }
636
+
637
+ function inputCursor(input: Input): number {
638
+ return (input as unknown as { cursor: number }).cursor
639
+ }
640
+
641
+ function renderStringInput(input: Input, width: number): string {
642
+ const text = `"${input.getValue()}"`
643
+ const cursor = Math.min(inputCursor(input) + 1, text.length - 1)
644
+ const beforeCursor = text.slice(0, cursor)
645
+ const atCursor = text[cursor] ?? '"'
646
+ const afterCursor = text.slice(cursor + atCursor.length)
647
+ return truncateToWidth(`${beforeCursor}${CURSOR_MARKER}\x1b[7m${atCursor}\x1b[27m${afterCursor}`, width, "")
648
+ }
649
+
650
+ function isNumberInputAllowed(data: string, input: Input | undefined, kb: Keybindings): boolean {
651
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || kb.matches(data, "tui.editor.deleteCharForward")) return true
652
+ if (/^[0-9]$/.test(data)) return true
653
+ if (data === ".") return !input?.getValue().includes(".")
654
+ if (data === "+" || data === "-") {
655
+ if (!input) return true
656
+ return inputCursor(input) === 0 && !/[+-]/.test(input.getValue())
657
+ }
658
+ return data.length !== 1
659
+ }
660
+
661
+ function parseNumberInput(field: Extract<ScopedConfigField, { kind: "number" }>, value: string): NumberInputParseResult {
662
+ const trimmed = value.trim()
663
+ if (trimmed === "") return { ok: false, message: `${field.label} must be a number` }
664
+
665
+ const parsed = Number(trimmed)
666
+ if (!Number.isFinite(parsed)) return { ok: false, message: `${field.label} must be a number` }
667
+ if (field.values !== undefined && !field.values.includes(parsed)) {
668
+ return { ok: false, message: `${field.label} must be one of: ${field.values.join(", ")}` }
669
+ }
670
+ if (field.min !== undefined && parsed < field.min) return { ok: false, message: `${field.label} must be at least ${field.min}` }
671
+ if (field.max !== undefined && parsed > field.max) return { ok: false, message: `${field.label} must be at most ${field.max}` }
672
+ return { ok: true, value: parsed }
673
+ }
674
+
675
+ function nextFieldValue(config: ConfigPatch, field: ScopedConfigField): unknown {
676
+ const current = formatScopedValue(config, field)
677
+ const options = field.kind === "enum" ? field.values : ["on", "off"]
678
+ const next = nextOption(options, current)
679
+ return field.kind === "boolean" ? next === "on" : next
680
+ }
681
+
682
+ function nextNumberValue(field: Extract<ScopedConfigField, { kind: "number" }>, value: unknown): number {
683
+ if (field.values) return nextOption(field.values, typeof value === "number" ? value : field.default)
684
+ const step = field.step ?? 1
685
+ const current = typeof value === "number" ? value : field.default
686
+ const next = roundToStepPrecision(current + step, step)
687
+ if (field.max !== undefined && next > field.max) return current < field.max ? field.max : (field.min ?? field.default)
688
+ return next
689
+ }
690
+
691
+ function roundToStepPrecision(value: number, step: number): number {
692
+ const decimalPlaces = step.toString().split(".")[1]?.length ?? 0
693
+ return Number(value.toFixed(decimalPlaces))
694
+ }
695
+
696
+ function nextOption<T>(options: readonly T[], value: T): T {
697
+ const index = options.indexOf(value)
698
+ return options[(index + 1) % options.length] ?? options[0] ?? value
699
+ }
700
+
701
+ // Checked items (pending) float to the top, keeping relative order.
702
+ function enumSearchValues(field: EnumField, query: string, pending?: Set<string>): string[] {
703
+ const values = field.values.map(value => ({ value }))
704
+ const matched = query
705
+ ? fuzzyFilter(values, query, item => `${item.value} ${field.valueDescriptions?.[item.value] ?? ""}`).map(item => item.value)
706
+ : values.map(item => item.value)
707
+ if (!pending) return matched
708
+ return [...matched.filter(value => pending.has(value)), ...matched.filter(value => !pending.has(value))]
709
+ }
710
+
711
+ function formatScopedValue(config: object, field: ScopedConfigField): string {
712
+ const value = getConfigValue(config, field.key)
713
+ return formatFieldValue(field, value)
714
+ }
715
+
716
+ function getFieldWarning(config: object, field: ScopedConfigField): string | undefined {
717
+ return getConfigWarnings([field], config)[0]?.message
718
+ }
719
+
720
+ function getFieldValueWarning(field: ScopedConfigField, value: unknown): string | undefined {
721
+ return getConfigWarnings([field], { [field.key]: value })[0]?.message
722
+ }
723
+
724
+ function getValueDescription(config: object, field: ScopedConfigField): string | undefined {
725
+ const rawValue = getConfigValue(config, field.key)
726
+ if (rawValue === undefined) return undefined
727
+ const value = formatFieldValue(field, rawValue)
728
+
729
+ switch (field.kind) {
730
+ case "enum":
731
+ case "number":
732
+ return field.valueDescriptions?.[value]
733
+ case "boolean":
734
+ return field.valueDescriptions?.[value as "on" | "off"]
735
+ case "multiEnum":
736
+ case "string":
737
+ case "text":
738
+ return undefined
739
+ }
740
+ }
741
+
742
+ function formatFieldValue(field: ScopedConfigField, value: unknown): string {
743
+ if (value === undefined) return "unset"
744
+ if (field.kind === "boolean") return value ? "on" : "off"
745
+ if (field.kind === "string") return JSON.stringify(String(value))
746
+ if (field.kind === "text") return formatTextValue(String(value))
747
+ if (field.kind === "number") return String(value)
748
+ if (field.kind === "multiEnum" && Array.isArray(value)) return value.length ? value.join(", ") : "none"
749
+ return String(value)
750
+ }
751
+
752
+ function formatTextValue(value: string): string {
753
+ const lineCount = value.split("\n").length
754
+ const preview = value.replace(/\s+/g, " ").trim()
755
+ const quoted = preview ? JSON.stringify(preview) : '""'
756
+ const suffix = lineCount > 1 ? ` (${lineCount} lines)` : ""
757
+ return `${truncateToWidth(quoted, 48, "…")}${suffix}`
758
+ }
759
+
760
+ function getScopeNote(
761
+ scope: ConfigScope,
762
+ scopes: readonly ConfigScope[],
763
+ configs: ScopedConfigPatch,
764
+ field: ScopedConfigField
765
+ ): string | undefined {
766
+ const value = getConfigValue(configs[scope], field.key)
767
+ const userValue = getConfigValue(configs.user, field.key)
768
+ const workspaceValue = getConfigValue(configs.workspace, field.key)
769
+ const user = userValue === undefined || getFieldValueWarning(field, userValue) ? undefined : formatFieldValue(field, userValue)
770
+ const workspace =
771
+ workspaceValue === undefined || getFieldValueWarning(field, workspaceValue) ? undefined : formatFieldValue(field, workspaceValue)
772
+ const defaultValue = formatFieldValue(field, field.default)
773
+
774
+ if (value !== undefined) {
775
+ if (getFieldValueWarning(field, value)) return undefined
776
+ return scope === "user" && scopes.includes("workspace") && workspace !== undefined ? `Workspace: ${workspace}` : undefined
777
+ }
778
+
779
+ if (scope === "user" && scopes.includes("workspace") && workspace !== undefined) return `Workspace: ${workspace}`
780
+ if (scope === "workspace" && scopes.includes("user") && user !== undefined) return `User: ${user}`
781
+ return `default: ${defaultValue}`
782
+ }
783
+
784
+ function scopeLabel(scope: ConfigScope): string {
785
+ return `${scope[0]?.toUpperCase() ?? ""}${scope.slice(1)}`
786
+ }