@xingular/harness 0.1.6 → 2.0.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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,143 @@
1
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { type Theme } from '@earendil-works/pi-coding-agent'
5
+ import { Container, TuiAltScreen, TuiMainScreen, VStack, stripTerminalSequences, type Component, type Terminal } from '@earendil-works/pi-tui'
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences } from './screen'
9
+ import { createHarnessTheme } from './theme'
10
+
11
+ const directories: string[] = []
12
+ afterEach(async () => { for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }) })
13
+
14
+ const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
15
+ const hooks: ScreenHooks = { theme: () => theme, latestKey: () => 'ctrl+end', openUrl: vi.fn(), pasteClipboard: vi.fn() }
16
+ const defaults = readScreenPreferences(undefined)
17
+ const prefs = (mode: ScreenPreferences['mode']): ScreenPreferences => ({ ...defaults, mode })
18
+
19
+ function fakeTerminal(columns = 40, rows = 12): Terminal {
20
+ return {
21
+ columns, rows, kittyProtocolActive: false, start: vi.fn(), stop: vi.fn(), drainInput: vi.fn(async () => {}), write: vi.fn(),
22
+ moveBy: vi.fn(), hideCursor: vi.fn(), showCursor: vi.fn(), clearLine: vi.fn(), clearFromCursor: vi.fn(), clearScreen: vi.fn(),
23
+ setTitle: vi.fn(), setProgress: vi.fn(), setProgramStatus: vi.fn()
24
+ } as unknown as Terminal
25
+ }
26
+
27
+ /** A transcript whose rows the test changes, and a three-row dock standing in for the rule, prompt and footer. */
28
+ function panes(initial: string[]) {
29
+ let transcript = initial
30
+ const document = new Container()
31
+ document.addChild({ render: () => [...transcript], invalidate: () => {} })
32
+ const dock = new VStack([{ component: { render: () => ['─'.repeat(40), '❯ prompt', '? for shortcuts'], invalidate: () => {} }, shrink: 1, minSize: 0 }])
33
+ return { document, dock, set(lines: string[]) { transcript = lines } }
34
+ }
35
+
36
+ function rowOf(lines: string[]): Component {
37
+ return { render: () => [...lines], invalidate: () => {} }
38
+ }
39
+
40
+ function screenRows(tui: TuiAltScreen): string[] {
41
+ return tui.getScreenLines().map(line => stripTerminalSequences(line).trimEnd())
42
+ }
43
+
44
+ describe('terminal screen preferences', () => {
45
+ it('follows Pi fullscreen defaults when no account folder is prepared', () => {
46
+ expect(defaults).toEqual({ mode: 'fullscreen', exitOutput: 'transcript', scrollbar: 'auto', wheelScrollLines: 'auto', copyOnSelect: true })
47
+ })
48
+
49
+ it('reads the regular screen from the account settings', async () => {
50
+ const directory = await mkdtemp(join(tmpdir(), 'harness-screen-'))
51
+ directories.push(directory)
52
+ await writeFile(join(directory, 'settings.json'), JSON.stringify({ tuiMode: 'regular' }))
53
+ expect(readScreenPreferences(directory).mode).toBe('regular')
54
+ })
55
+ })
56
+
57
+ describe('terminal screen layout', () => {
58
+ it('keeps the dock on the last rows while the transcript is short', () => {
59
+ const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
60
+ const { document, dock } = panes(['Welcome'])
61
+ mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
62
+ tui.start()
63
+ tui.renderNow(true)
64
+ const rows = screenRows(tui)
65
+ expect(rows).toHaveLength(12)
66
+ expect(rows[0]).toBe('Welcome')
67
+ expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
68
+ tui.stop()
69
+ })
70
+
71
+ it('does not move the dock as the transcript grows', () => {
72
+ const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
73
+ const { document, dock, set } = panes(['Welcome'])
74
+ mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
75
+ tui.start()
76
+ tui.renderNow(true)
77
+ set(Array.from({ length: 30 }, (_, index) => `message ${index}`))
78
+ tui.renderNow(true)
79
+ const rows = screenRows(tui)
80
+ expect(rows).toHaveLength(12)
81
+ expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
82
+ expect(rows[8]).toBe('message 29')
83
+ tui.stop()
84
+ })
85
+
86
+ it('scrolls the transcript above a dock that stays where it is', () => {
87
+ const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
88
+ const { document, dock, set } = panes(Array.from({ length: 30 }, (_, index) => `message ${index}`))
89
+ mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
90
+ tui.start()
91
+ tui.renderNow(true)
92
+ expect(tui.isFollowingOutput).toBe(true)
93
+ tui.scrollBy(-10)
94
+ tui.renderNow(true)
95
+ expect(tui.isFollowingOutput).toBe(false)
96
+ const rows = screenRows(tui)
97
+ expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
98
+ expect(rows.some(row => row === 'message 29')).toBe(false)
99
+ set(['message 0'])
100
+ tui.stop()
101
+ })
102
+
103
+ it('shows the first rows of a long queue while the prompt and status stay whole', () => {
104
+ const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
105
+ const queued = Array.from({ length: 20 }, (_, index) => `queued ${index}`)
106
+ const dock = createDock({
107
+ title: rowOf(['title']), above: rowOf([]), queue: rowOf(queued), below: rowOf([]), footer: rowOf(['status']),
108
+ editor: rowOf(['─'.repeat(40), '❯ prompt', '─'.repeat(40), '? for shortcuts'])
109
+ })
110
+ const document = new Container()
111
+ document.addChild(rowOf(['Welcome']))
112
+ mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
113
+ tui.start()
114
+ tui.renderNow(true)
115
+ const rows = screenRows(tui)
116
+ expect(rows).toHaveLength(12)
117
+ expect(rows.slice(-5)).toEqual(['─'.repeat(40), '❯ prompt', '─'.repeat(40), '? for shortcuts', 'status'])
118
+ expect(rows).toContain('queued 0')
119
+ expect(rows).not.toContain('queued 19')
120
+ tui.stop()
121
+ })
122
+
123
+ it('flows the regular screen in order without a viewport', () => {
124
+ const tui = createTerminalScreen(fakeTerminal(), prefs('regular'), hooks)
125
+ expect(tui).toBeInstanceOf(TuiMainScreen)
126
+ const { document, dock } = panes(['Welcome'])
127
+ expect(mountTerminalLayout(tui, { document, dock }, prefs('regular'), hooks)).toBeUndefined()
128
+ expect(tui.children).toEqual([document, dock])
129
+ })
130
+ })
131
+
132
+ describe('terminal scrollback on exit', () => {
133
+ it('truncates the conversation to the terminal width', () => {
134
+ const component: Component = { render: () => ['x'.repeat(30)], invalidate: () => {} }
135
+ expect(scrollbackLines(component, 10).map(line => stripTerminalSequences(line))).toEqual(['x'.repeat(10)])
136
+ })
137
+
138
+ it('writes each line with its styles reset and CRLF endings', () => {
139
+ const terminal = fakeTerminal()
140
+ writeScrollback(terminal, ['first', 'second'])
141
+ expect(terminal.write).toHaveBeenCalledWith('first\x1b[0m\r\nsecond\x1b[0m\r\n')
142
+ })
143
+ })
@@ -0,0 +1,121 @@
1
+ // Screen mode and layout. Pi's fullscreen settings still choose the mode; Harness supplies its own composition.
2
+ import { SettingsManager, copyToClipboard, type FullscreenExitOutput, type Theme, type TuiMode } from '@earendil-works/pi-coding-agent'
3
+ import { ScrollView, TuiAltScreen, TuiMainScreen, VStack, isViewportTUI, truncateToWidth, type Component, type Terminal, type TuiAltScreenOptions } from '@earendil-works/pi-tui'
4
+
5
+ export interface DockSlots {
6
+ readonly title: Component
7
+ readonly above: Component
8
+ readonly queue: Component
9
+ readonly editor: Component
10
+ readonly below: Component
11
+ readonly footer: Component
12
+ }
13
+
14
+ export type TerminalScreen = TuiMainScreen | TuiAltScreen
15
+
16
+ export interface ScreenPreferences {
17
+ readonly mode: TuiMode
18
+ readonly exitOutput: FullscreenExitOutput
19
+ readonly scrollbar: ReturnType<SettingsManager['getFullscreenScrollbar']>
20
+ readonly wheelScrollLines: ReturnType<SettingsManager['getFullscreenWheelScrollLines']>
21
+ readonly copyOnSelect: boolean
22
+ }
23
+
24
+ export interface ScreenHooks {
25
+ theme(): Theme
26
+ latestKey(): string | undefined
27
+ openUrl(url: string): void
28
+ pasteClipboard(): void
29
+ }
30
+
31
+ export interface TerminalPanes {
32
+ readonly document: Component
33
+ readonly dock: Component
34
+ }
35
+
36
+ export function readScreenPreferences(agentDir: string | undefined): ScreenPreferences {
37
+ // Without an account folder there is no settings file to read, so Pi's defaults apply.
38
+ const settings = agentDir ? SettingsManager.create(process.cwd(), agentDir) : SettingsManager.inMemory()
39
+ return {
40
+ mode: settings.getTuiMode(),
41
+ exitOutput: settings.getFullscreenExitOutput(),
42
+ scrollbar: settings.getFullscreenScrollbar(),
43
+ wheelScrollLines: settings.getFullscreenWheelScrollLines(),
44
+ copyOnSelect: settings.getFullscreenCopyOnSelect()
45
+ }
46
+ }
47
+
48
+ export function createTerminalScreen(terminal: Terminal, preferences: ScreenPreferences, hooks: ScreenHooks): TerminalScreen {
49
+ if (preferences.mode === 'regular') return new TuiMainScreen(terminal)
50
+ const { theme } = hooks
51
+ const options: TuiAltScreenOptions = {
52
+ wheelScrollLines: preferences.wheelScrollLines,
53
+ copyOnSelect: preferences.copyOnSelect,
54
+ searchMatchStyle: text => theme().underline(theme().bg('searchMatchBg', theme().fg('searchMatchText', text))),
55
+ searchCurrentMatchStyle: text => theme().bold(theme().inverse(theme().bg('searchMatchBg', theme().fg('searchMatchText', text)))),
56
+ searchNavigationButtonStyle: (text, hovered) => hovered ? theme().underline(text) : text,
57
+ scrollToEndIndicator: () => {
58
+ const key = hooks.latestKey()
59
+ return theme().bg('selectedBg', theme().fg('text', ` ↓ Jump to latest message${key ? ` · ${keyLabel(key)}` : ''} `))
60
+ },
61
+ openUrl: hooks.openUrl,
62
+ onRightClickPaste: hooks.pasteClipboard,
63
+ copySelection: copySelectionText
64
+ }
65
+ return new TuiAltScreen(terminal, undefined, undefined, options)
66
+ }
67
+
68
+ /** The prompt and status keep their full height; when the screen is short, queued messages and widgets show their first rows and scroll for the rest. */
69
+ export function createDock(slots: DockSlots): VStack {
70
+ const region = (component: Component) => new ScrollView(component)
71
+ return new VStack([
72
+ { component: slots.title, shrink: 0 },
73
+ { component: region(slots.above), shrink: 1, minSize: 0 },
74
+ { component: region(slots.queue), shrink: 1, minSize: 0 },
75
+ { component: slots.editor, shrink: 0 },
76
+ { component: region(slots.below), shrink: 1, minSize: 0 },
77
+ { component: slots.footer, shrink: 0 }
78
+ ])
79
+ }
80
+
81
+ /** Fullscreen keeps the dock on the last rows and scrolls only the transcript above it; regular mode flows in order. */
82
+ export function mountTerminalLayout(tui: TerminalScreen, panes: TerminalPanes, preferences: ScreenPreferences, hooks: ScreenHooks): ScrollView | undefined {
83
+ if (!isViewportTUI(tui)) {
84
+ tui.addChild(panes.document)
85
+ tui.addChild(panes.dock)
86
+ return undefined
87
+ }
88
+ const transcript = new ScrollView(panes.document, {
89
+ follow: 'end', primary: true, overscroll: 'chain', scrollbar: preferences.scrollbar,
90
+ scrollbarTrackStyle: text => hooks.theme().fg('scrollbarTrack', text),
91
+ scrollbarThumbStyle: text => hooks.theme().fg('scrollbarThumb', text)
92
+ })
93
+ tui.setLayoutRoot(new VStack([
94
+ { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
95
+ { component: panes.dock, basis: 'auto', grow: 0, shrink: 1, minSize: 1 }
96
+ ]))
97
+ return transcript
98
+ }
99
+
100
+ /** Rendered before the alternate screen is left, because the components are disposed afterwards. */
101
+ export function scrollbackLines(component: Component, width: number): string[] {
102
+ return component.render(width).map(line => truncateToWidth(line, width, ''))
103
+ }
104
+
105
+ /** Leaving fullscreen discards the alternate screen, so the conversation is copied into normal scrollback. */
106
+ export function writeScrollback(terminal: Terminal, lines: string[]): void {
107
+ terminal.write(lines.map(line => `${line}\x1b[0m`).join('\r\n') + '\r\n')
108
+ }
109
+
110
+ function keyLabel(key: string): string {
111
+ return key.replace(/(^|\+)([a-z])/g, (_match, separator: string, letter: string) => separator + letter.toUpperCase())
112
+ }
113
+
114
+ async function copySelectionText(text: string): Promise<boolean | string> {
115
+ try {
116
+ await copyToClipboard(text)
117
+ return true
118
+ } catch (error) {
119
+ return error instanceof Error ? error.message : String(error)
120
+ }
121
+ }
@@ -0,0 +1,24 @@
1
+ import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
+
3
+ const dialogs = new Set(['select', 'input', 'editor', 'custom'])
4
+
5
+ /** Retained UI methods belong to the resource generation that received them. */
6
+ export function sessionUiContext(ui: ExtensionUIContext, current: () => boolean): ExtensionUIContext {
7
+ return new Proxy(ui, {
8
+ get(target, key, receiver) {
9
+ const value = Reflect.get(target, key, receiver)
10
+ if (typeof value !== 'function') return value
11
+ return (...args: unknown[]) => {
12
+ if (current()) return Reflect.apply(value, target, args)
13
+ if (key === 'confirm') return Promise.resolve(false)
14
+ if (dialogs.has(String(key))) return Promise.resolve(undefined)
15
+ if (key === 'onTerminalInput') return () => {}
16
+ if (key === 'setTheme') return { success: false, error: 'This terminal session has ended.' }
17
+ if (key === 'getEditorText') return ''
18
+ if (key === 'getToolsExpanded') return false
19
+ if (key === 'getAllThemes') return []
20
+ return undefined
21
+ }
22
+ }
23
+ })
24
+ }
@@ -0,0 +1,150 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { CURSOR_MARKER, stripTerminalSequences, visibleWidth, type TUI } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { SettingsPanel, type TerminalSettingItem } from './settings'
6
+ import { createHarnessTheme } from './theme'
7
+
8
+ const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
9
+ const items: TerminalSettingItem[] = [
10
+ { id: 'theme', tab: 'appearance', group: 'Theme', label: 'Dark Theme', value: 'Titanium', description: 'Theme used with dark terminal backgrounds.' },
11
+ { id: 'composer', tab: 'appearance', group: 'Composer', label: 'Composer Shape', value: 'Band', description: 'Choose the terminal input layout.' },
12
+ { id: 'expanded', tab: 'appearance', group: 'Display', label: 'Expand Tool Output', value: 'Off', description: 'Expand actual output from tools.' },
13
+ { id: 'thinking', tab: 'model', group: 'Thinking', label: 'Thinking Level', value: 'High', description: 'Control reasoning effort for the current model.' },
14
+ { id: 'input', tab: 'interaction', group: 'Input', label: 'Autocomplete', value: 'On' },
15
+ { id: 'compact', tab: 'context', group: 'Compaction', label: 'Auto Compact', value: 'On', description: 'Compact the context when the supported token threshold is reached.' }
16
+ ]
17
+ function setup(initialId?: string, rows = 30, source = items) {
18
+ const terminal = { rows }
19
+ const done = vi.fn()
20
+ const panel = new SettingsPanel({ terminal } as unknown as TUI, () => theme, source, done, initialId)
21
+ panel.focused = true
22
+ return { panel, done, terminal }
23
+ }
24
+ const plain = (panel: SettingsPanel, width = 100) => panel.render(width).map(stripTerminalSequences)
25
+
26
+ describe('the supported settings terminal panel', () => {
27
+ it('fills the terminal with the upstream rounded settings frame, supported tabs and group rail', () => {
28
+ const { panel } = setup()
29
+ const rows = plain(panel)
30
+ expect(rows).toHaveLength(30)
31
+ expect(rows[0]).toMatch(/^╭─ Settings /)
32
+ expect(rows[0]).toMatch(/╮$/)
33
+ expect(rows.at(-1)).toMatch(/^╰─.*╯$/)
34
+ expect(rows[1]).toContain('🎨 Appearance')
35
+ expect(rows[1]).toContain('Model')
36
+ expect(rows[1]).toContain('Interaction')
37
+ expect(rows[1]).toContain('Context')
38
+ expect(rows.join('\n')).not.toMatch(/Providers|Advisor|Preview:|Tasks|Memory/)
39
+ expect(rows.some(row => row.includes('│ Theme') && row.slice(25).includes('│'))).toBe(true)
40
+ expect(rows.join('\n')).toContain('❯ Dark Theme')
41
+ expect(rows.join('\n')).toContain('Titanium')
42
+ expect(rows.join('\n')).toContain('Theme used with dark terminal backgrounds.')
43
+ expect(panel.render(100).join('')).toContain(theme.getBgAnsi('selectedBg'))
44
+ const compactTabs = plain(panel, 40)[1]
45
+ for (const icon of ['🎨', '🤖', '⌨', '📋']) expect(compactTabs).toContain(icon)
46
+ })
47
+
48
+ it('uses flat rows at 80 columns and a section rail only when the value pane fits', () => {
49
+ const { panel } = setup()
50
+ const narrow = plain(panel, 80).find(row => row.includes('❯ Dark Theme'))!
51
+ const wide = plain(panel, 100).find(row => row.includes('❯ Dark Theme'))!
52
+ expect(narrow.match(/│/g)).toHaveLength(2)
53
+ expect(wide.match(/│/g)).toHaveLength(3)
54
+ })
55
+
56
+ it('moves through tabs and items and resolves only an actual selected setting id', () => {
57
+ const { panel, done } = setup()
58
+ panel.handleInput('\x1b[B')
59
+ expect(panel.selectedId).toBe('composer')
60
+ panel.handleInput('\r')
61
+ expect(done).toHaveBeenLastCalledWith('composer')
62
+ panel.handleInput('\x1b[C')
63
+ expect(panel.selectedId).toBe('thinking')
64
+ panel.handleInput(' ')
65
+ expect(done).toHaveBeenLastCalledWith('thinking')
66
+ panel.handleInput('\x1b[D')
67
+ expect(panel.selectedId).toBe('composer')
68
+ panel.handleInput('\x1b')
69
+ expect(done).toHaveBeenLastCalledWith(undefined)
70
+ })
71
+
72
+ it('moves group focus through the rail and returns to item editing without triggering an action', () => {
73
+ const { panel, done } = setup()
74
+ panel.handleInput('\t')
75
+ panel.handleInput('\x1b[B')
76
+ expect(panel.selectedId).toBe('composer')
77
+ expect(plain(panel).join('\n')).toContain('❯ Composer')
78
+ panel.handleInput('\r')
79
+ expect(done).not.toHaveBeenCalled()
80
+ panel.handleInput('\r')
81
+ expect(done).toHaveBeenCalledWith('composer')
82
+ })
83
+
84
+ it('restores the last selected tab, group and item from initialId', () => {
85
+ const { panel } = setup('compact')
86
+ expect(panel.selectedId).toBe('compact')
87
+ expect(plain(panel)[1]).toContain('Context')
88
+ expect(plain(panel).join('\n')).toContain('❯ Auto Compact')
89
+ expect(plain(panel).join('\n')).not.toContain('Dark Theme')
90
+ expect(setup('missing').panel.selectedId).toBe('theme')
91
+ })
92
+
93
+ it('searches across supported tabs using labels, values and descriptions with a visible cursor', () => {
94
+ const { panel, done } = setup()
95
+ panel.handleInput('reasoning')
96
+ const rows = plain(panel).join('\n')
97
+ expect(rows).toContain('1 match')
98
+ expect(rows).toContain('Thinking Level')
99
+ expect(rows).not.toContain('Dark Theme')
100
+ expect(panel.render(100).join('')).toContain(CURSOR_MARKER)
101
+ panel.handleInput('\r')
102
+ expect(done).toHaveBeenCalledWith('thinking')
103
+ panel.handleInput('\x1b')
104
+ expect(done).toHaveBeenLastCalledWith(undefined)
105
+ })
106
+
107
+ it('renders explicit empty search results and never selects a hidden item', () => {
108
+ const { panel, done } = setup()
109
+ panel.handleInput('zzzzzzzzzzz')
110
+ expect(plain(panel).join('\n')).toContain('No matching settings')
111
+ expect(panel.selectedId).toBeUndefined()
112
+ panel.handleInput('\r')
113
+ panel.handleInput(' ')
114
+ expect(done).not.toHaveBeenCalled()
115
+ panel.handleInput('\x7f')
116
+ expect(panel.selectedId).toBeUndefined()
117
+ })
118
+
119
+ it('keeps the selected setting visible as lists grow and the terminal resizes', () => {
120
+ const source = Array.from({ length: 100 }, (_, index) => ({ id: `item${index}`, tab: 'appearance', group: 'Display', label: `Setting ${index}`, value: String(index) }))
121
+ const { panel, terminal } = setup('item85', 20, source)
122
+ for (const height of [40, 20, 10, 7]) {
123
+ terminal.rows = height
124
+ expect(plain(panel).join('\n'), `height ${height}`).toContain('❯ Setting 85')
125
+ expect(panel.render(80)).toHaveLength(height)
126
+ }
127
+ expect(panel.selectedId).toBe('item85')
128
+ })
129
+
130
+ it('bounds every row and terminal height for narrow Unicode labels and descriptions', () => {
131
+ const source = [{ id: 'cjk', tab: 'appearance', group: '日本語 🧠', label: '知識設定 日本語', value: '🧠 有効', description: '日本語の説明を長く表示します。'.repeat(20) }]
132
+ const { panel, terminal } = setup('cjk', 30, source)
133
+ for (const height of [0, 1, 3, 6, 10, 20, 40]) {
134
+ terminal.rows = height
135
+ for (let width = 0; width <= 130; width++) {
136
+ const rows = panel.render(width)
137
+ expect(rows.length, `${width}x${height}`).toBeLessThanOrEqual(height)
138
+ for (const row of rows) expect(visibleWidth(row), `${width}x${height}`).toBeLessThanOrEqual(width)
139
+ }
140
+ }
141
+ expect(panel.selectedId).toBe('cjk')
142
+ })
143
+
144
+ it('does not expose unsupported categories or mutate caller setting data', () => {
145
+ const source = Object.freeze([...items, Object.freeze({ id: 'fake', tab: 'tasks', group: 'Agents', label: 'Invented feature', value: 'Off' })])
146
+ const { panel } = setup(undefined, 30, source as unknown as TerminalSettingItem[])
147
+ expect(plain(panel).join('\n')).not.toContain('Invented feature')
148
+ expect(source[0].value).toBe('Titanium')
149
+ })
150
+ })
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Settings chrome adapted from Oh My Pi 18.11.3 settings-selector.ts and
3
+ * settings-list.ts (MIT; see NOTICE-oh-my-pi). Values and actions belong to the host.
4
+ */
5
+ import type { Theme } from '@earendil-works/pi-coding-agent'
6
+ import { Input, fuzzyFilter, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from '@earendil-works/pi-tui'
7
+
8
+ export interface TerminalSettingItem {
9
+ id: string
10
+ tab: string
11
+ group: string
12
+ label: string
13
+ value: string
14
+ description?: string
15
+ }
16
+ const categories = ['appearance', 'model', 'interaction', 'context'] as const
17
+ const categoryIcon: Record<string, string> = { appearance: '🎨', model: '🤖', interaction: '⌨', context: '📋' }
18
+ const categoryName = (value: string) => value.charAt(0).toUpperCase() + value.slice(1)
19
+ const plain = (value: string) => stripTerminalSequences(value).replace(/[\x00-\x1f\x7f]/g, ' ')
20
+ const tabOf = (item: TerminalSettingItem) => item.tab.toLowerCase()
21
+
22
+ /** A full terminal settings view. It describes existing host settings without owning them. */
23
+ export class SettingsPanel implements Component, Focusable {
24
+ private search = new Input({ prompt: '' })
25
+ private query = ''
26
+ private currentTab = ''
27
+ private selected: string | undefined
28
+ private groupFocus = false
29
+ private focusedValue = false
30
+ private remembered = new Map<string, string>()
31
+ private readonly items: TerminalSettingItem[]
32
+ private readonly tabs: string[]
33
+ constructor(private readonly tui: TUI, private readonly theme: () => Theme, items: TerminalSettingItem[], private readonly done: (id: string | undefined) => void, initialId?: string) {
34
+ this.items = items.filter(item => categories.includes(tabOf(item) as typeof categories[number]))
35
+ this.tabs = categories.filter(tab => this.items.some(item => tabOf(item) === tab))
36
+ const initial = this.items.find(item => item.id === initialId)
37
+ this.currentTab = initial ? tabOf(initial) : this.tabs[0] ?? ''
38
+ this.selected = initial?.id ?? this.visibleItems()[0]?.id
39
+ }
40
+ get selectedId(): string | undefined { return this.selected }
41
+ get focused(): boolean { return this.focusedValue }
42
+ set focused(value: boolean) { this.focusedValue = value; this.search.focused = value && this.query.length > 0 }
43
+ invalidate(): void { this.search.invalidate() }
44
+ private visibleItems(): TerminalSettingItem[] {
45
+ return this.query
46
+ ? fuzzyFilter(this.items, this.query, item => `${item.label} ${item.id} ${item.value} ${item.description ?? ''} ${item.group} ${item.tab}`)
47
+ : this.items.filter(item => tabOf(item) === this.currentTab)
48
+ }
49
+ private select(item: TerminalSettingItem | undefined): void {
50
+ this.selected = item?.id
51
+ if (item) { this.currentTab = tabOf(item); this.remembered.set(this.currentTab, item.id) }
52
+ }
53
+ private switchTab(direction: number): void {
54
+ const tabs = this.query ? this.tabs.filter(tab => this.visibleItems().some(item => tabOf(item) === tab)) : this.tabs
55
+ if (!tabs.length) return
56
+ const at = Math.max(0, tabs.indexOf(this.currentTab))
57
+ this.currentTab = tabs[(at + direction + tabs.length) % tabs.length]
58
+ const items = this.visibleItems().filter(item => tabOf(item) === this.currentTab)
59
+ this.select(items.find(item => item.id === this.remembered.get(this.currentTab)) ?? items[0])
60
+ this.groupFocus = false
61
+ }
62
+ private jumpGroup(direction: number): void {
63
+ const items = this.visibleItems()
64
+ const groups = [...new Set(items.map(item => item.group))]
65
+ const current = items.find(item => item.id === this.selected)?.group
66
+ const index = Math.max(0, groups.indexOf(current ?? ''))
67
+ const group = groups[(index + direction + groups.length) % groups.length]
68
+ this.select(items.find(item => item.group === group))
69
+ }
70
+ handleInput(data: string): void {
71
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.done(undefined); return }
72
+ if (matchesKey(data, 'left') || matchesKey(data, 'right')) { this.switchTab(matchesKey(data, 'left') ? -1 : 1); return }
73
+ if (matchesKey(data, 'tab') || matchesKey(data, 'shift+tab')) {
74
+ if (this.query) this.switchTab(matchesKey(data, 'shift+tab') ? -1 : 1)
75
+ else if (new Set(this.visibleItems().map(item => item.group)).size > 1) this.groupFocus = !this.groupFocus
76
+ else this.switchTab(matchesKey(data, 'shift+tab') ? -1 : 1)
77
+ return
78
+ }
79
+ if (matchesKey(data, 'up') || matchesKey(data, 'down') || matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) {
80
+ const direction = matchesKey(data, 'up') || matchesKey(data, 'pageUp') ? -1 : 1
81
+ if (this.groupFocus || matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) this.jumpGroup(direction)
82
+ else {
83
+ const items = this.visibleItems()
84
+ const at = Math.max(0, items.findIndex(item => item.id === this.selected))
85
+ this.select(items[Math.max(0, Math.min(items.length - 1, at + direction))])
86
+ }
87
+ return
88
+ }
89
+ if (matchesKey(data, 'enter') || data === ' ') {
90
+ if (this.groupFocus) this.groupFocus = false
91
+ else if (this.selected) this.done(this.selected)
92
+ return
93
+ }
94
+ this.search.handleInput(data)
95
+ if (this.search.getValue() !== this.query) {
96
+ this.query = this.search.getValue()
97
+ this.groupFocus = false
98
+ this.search.focused = this.focusedValue && !!this.query
99
+ const items = this.visibleItems()
100
+ this.select(items.find(item => item.id === this.selected) ?? items[0])
101
+ }
102
+ }
103
+
104
+ render(width: number): string[] {
105
+ const height = Math.max(0, Math.trunc(this.tui.terminal.rows))
106
+ width = Math.max(0, Math.trunc(width))
107
+ if (!width || !height) return []
108
+ const theme = this.theme()
109
+ const inner = Math.max(0, width - 4)
110
+ const clip = (value: string, columns = width) => truncateToWidth(value, columns, '')
111
+ const pad = (value: string, columns: number) => clip(value, columns) + ' '.repeat(Math.max(0, columns - Math.min(columns, visibleWidth(value))))
112
+ const border = (value: string) => theme.fg('border', value)
113
+ const row = (value: string) => clip(border('│') + ' ' + pad(value, inner) + ' ' + border('│'))
114
+ const rule = () => clip(border('├' + '─'.repeat(Math.max(0, width - 2)) + '┤'))
115
+ const title = clip(' Settings ', Math.max(0, width - 4))
116
+ const top = clip(border('╭─') + theme.bold(theme.fg('accent', title)) + border('─'.repeat(Math.max(0, width - visibleWidth(title) - 3)) + '╮'))
117
+ const bottom = clip(border('╰' + '─'.repeat(Math.max(0, width - 2)) + '╯'))
118
+ const tabRow = (compact: boolean) => this.tabs.map(tab => {
119
+ const label = ` ${categoryIcon[tab]}${compact ? '' : ` ${categoryName(tab)}`} `
120
+ return tab === this.currentTab ? theme.bg('selectedBg', theme.fg('accent', label)) : theme.fg('muted', label)
121
+ }).join(' ')
122
+ let tabs = tabRow(false)
123
+ if (visibleWidth(tabs) > inner) tabs = tabRow(true)
124
+ if (visibleWidth(tabs) > inner) tabs = theme.fg('accent', `‹ ${categoryIcon[this.currentTab] ?? ''} ${categoryName(this.currentTab)} ›`)
125
+ const lines = [top, row(tabs), rule()]
126
+ const items = this.visibleItems()
127
+ const current = items.find(item => item.id === this.selected)
128
+ if (this.query) {
129
+ const count = `${items.length} ${items.length === 1 ? 'match' : 'matches'}`
130
+ const prefix = theme.fg('accent', '🔍 ')
131
+ const queryWidth = Math.max(2, inner - visibleWidth(prefix) - count.length - 1)
132
+ lines.push(row(prefix + (this.search.render(queryWidth)[0] ?? '') + ' ' + theme.fg('dim', count)))
133
+ }
134
+ const contentRows = Math.max(0, height - lines.length - 3)
135
+ const descriptionRows = Math.min(3, Math.max(0, contentRows - 2))
136
+ const listRows = Math.max(0, contentRows - descriptionRows - (descriptionRows ? 1 : 0))
137
+ const groups = [...new Set(items.map(item => item.group))]
138
+ // The shipped SettingsList keeps at least 60 columns for editable rows.
139
+ const sidebar = !this.query && groups.length > 1 && inner - 28 >= 60 ? 26 : 0
140
+ const paneWidth = Math.max(0, inner - (sidebar ? sidebar + 2 : 0))
141
+ const entries: { item?: TerminalSettingItem; heading?: string }[] = []
142
+ let lastGroup: string | undefined
143
+ for (const item of items) {
144
+ const group = this.query ? `${categoryName(tabOf(item))} / ${item.group}` : item.group
145
+ if (group !== lastGroup) { entries.push({ heading: group }); lastGroup = group }
146
+ entries.push({ item })
147
+ }
148
+ const selectedRow = entries.findIndex(entry => entry.item?.id === this.selected)
149
+ const start = Math.max(0, Math.min(Math.max(0, entries.length - listRows), selectedRow - Math.floor(listRows / 2)))
150
+ const maxLabel = Math.min(30, Math.max(0, ...items.map(item => visibleWidth(plain(item.label)))))
151
+ const labelWidth = Math.min(maxLabel, Math.max(0, Math.floor((paneWidth - 4) * 0.6)))
152
+ for (let index = 0; index < listRows; index++) {
153
+ const entry = entries[start + index]
154
+ let right = ''
155
+ if (entry?.heading) right = theme.fg(entry.heading.includes(current?.group ?? '\u0000') ? 'accent' : 'dim', plain(entry.heading))
156
+ if (entry?.item) {
157
+ const item = entry.item
158
+ const selected = item.id === this.selected && !this.groupFocus
159
+ const active = this.query || item.group === current?.group
160
+ const prefix = selected ? '❯ ' : ' '
161
+ const labelText = prefix + pad(plain(item.label), labelWidth)
162
+ const text = labelText + ' ' + clip(plain(item.value), Math.max(0, paneWidth - visibleWidth(labelText) - 2))
163
+ right = selected
164
+ ? theme.bg('selectedBg', theme.fg('accent', pad(text, paneWidth)))
165
+ : theme.fg(active ? 'text' : 'dim', text)
166
+ }
167
+ if (!entries.length && index === 0) right = theme.fg('warning', this.query ? 'No matching settings' : 'No settings')
168
+ if (sidebar) {
169
+ const name = groups[index]
170
+ const active = name === current?.group
171
+ const left = name ? (active && this.groupFocus ? '❯ ' : ' ') + plain(name) : ''
172
+ const styled = active && this.groupFocus
173
+ ? theme.bg('selectedBg', theme.fg('accent', pad(left, sidebar)))
174
+ : theme.fg(active ? 'accent' : 'dim', pad(left, sidebar))
175
+ lines.push(row(styled + theme.fg('dim', '│ ') + right))
176
+ } else lines.push(row(right))
177
+ }
178
+ if (descriptionRows) lines.push(row(''))
179
+ const description = current?.description && inner > 1 ? wrapTextWithAnsi(plain(current.description), inner) : []
180
+ for (let index = 0; index < descriptionRows; index++) lines.push(row(theme.fg('muted', description[index] ?? '')))
181
+ const footer = this.groupFocus
182
+ ? '↑/↓ jump sections · Tab/Enter to settings · ←/→ switch tabs · Esc close'
183
+ : this.query ? 'Enter/Space change · Tab jump tabs · Esc close'
184
+ : `Enter/Space change · ${groups.length > 1 ? 'Tab jump sections · ←/→ switch tabs' : 'Tab/←/→ switch tabs'} · Type to search · Esc close`
185
+ lines.push(rule(), row(theme.fg('dim', footer)), bottom)
186
+ return lines.slice(0, height).map(value => clip(value))
187
+ }
188
+ }