@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- 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
|
+
}
|