@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,313 @@
|
|
|
1
|
+
import { type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Input, KeybindingsManager as TuiKeybindingsManager, TuiMainScreen, TUI_KEYBINDINGS, stripTerminalSequences, visibleWidth, type AutocompleteProvider, type Component, type Terminal } from '@earendil-works/pi-tui'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { DialogPanel, SecretInput } from './dialogs'
|
|
6
|
+
import { createHarnessTheme } from './theme'
|
|
7
|
+
import { createTerminalUi, type TerminalUi } from './ui'
|
|
8
|
+
|
|
9
|
+
const open: TerminalUi[] = []
|
|
10
|
+
afterEach(() => { for (const ui of open.splice(0)) ui.dispose(); vi.useRealTimers() })
|
|
11
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
12
|
+
function setup() {
|
|
13
|
+
const terminal = { columns: 80, rows: 30, kittyProtocolActive: false, write: vi.fn(), hideCursor: vi.fn(), setTitle: vi.fn(), start: vi.fn(), stop: vi.fn() } as unknown as Terminal
|
|
14
|
+
const tui = new TuiMainScreen(terminal)
|
|
15
|
+
vi.spyOn(tui, 'requestRender').mockImplementation(() => {})
|
|
16
|
+
const originalInput = new Input()
|
|
17
|
+
const original = Object.assign(originalInput, { getText: () => originalInput.getValue(), setText: (value: string) => originalInput.setValue(value) })
|
|
18
|
+
tui.addChild(original)
|
|
19
|
+
tui.setFocus(original)
|
|
20
|
+
const callbacks = {
|
|
21
|
+
changed: vi.fn(), notify: vi.fn(), setEditor: vi.fn(), getEditorText: vi.fn(() => 'draft'), setEditorText: vi.fn(),
|
|
22
|
+
setStatus: vi.fn(), setWidget: vi.fn(), setHeader: vi.fn(), setFooter: vi.fn(), themeChanged: vi.fn(), setToolsExpanded: vi.fn()
|
|
23
|
+
}
|
|
24
|
+
const host = createTerminalUi({ tui, theme, keybindings: new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, ...callbacks, getEditor: () => original })
|
|
25
|
+
open.push(host)
|
|
26
|
+
return { host, tui, terminal, original, callbacks }
|
|
27
|
+
}
|
|
28
|
+
function key(tui: TuiMainScreen, data: string): void { tui.getFocusedComponent()?.handleInput?.(data) }
|
|
29
|
+
|
|
30
|
+
function plain(component: Component, width = 80): string[] { return component.render(width).map(stripTerminalSequences) }
|
|
31
|
+
|
|
32
|
+
describe('the complete terminal extension UI host', () => {
|
|
33
|
+
it('selects original option values and restores the actual TUI focus', async () => {
|
|
34
|
+
const { host, tui, original } = setup()
|
|
35
|
+
const promise = host.select('Choose account', ['First', 'Second\noriginal'])
|
|
36
|
+
expect(tui.hasOverlay()).toBe(true)
|
|
37
|
+
key(tui, '\x1b[B')
|
|
38
|
+
key(tui, '\r')
|
|
39
|
+
expect(await promise).toBe('Second\noriginal')
|
|
40
|
+
expect(tui.getFocusedComponent()).toBe(original)
|
|
41
|
+
expect(tui.hasOverlay()).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('resolves confirmation cancellation false, supports input and multiline editor values', async () => {
|
|
45
|
+
const { host, tui } = setup()
|
|
46
|
+
const confirmation = host.confirm('Delete credential?', 'Remove only this account')
|
|
47
|
+
key(tui, '\x1b')
|
|
48
|
+
expect(await confirmation).toBe(false)
|
|
49
|
+
const input = host.input('Account label', 'Personal')
|
|
50
|
+
key(tui, 'Work')
|
|
51
|
+
key(tui, '\r')
|
|
52
|
+
expect(await input).toBe('Work')
|
|
53
|
+
const editor = host.editor('Edit prompt', 'Line 1')
|
|
54
|
+
key(tui, '\n')
|
|
55
|
+
key(tui, 'Line 2')
|
|
56
|
+
key(tui, '\r')
|
|
57
|
+
expect(await editor).toBe('Line 1\nLine 2')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('dismisses aborted and timed dialogs, shows countdowns and removes every timer', async () => {
|
|
61
|
+
vi.useFakeTimers()
|
|
62
|
+
const { host, tui, original } = setup()
|
|
63
|
+
const controller = new AbortController()
|
|
64
|
+
controller.abort()
|
|
65
|
+
expect(await host.input('Already cancelled', undefined, { signal: controller.signal })).toBeUndefined()
|
|
66
|
+
expect(tui.hasOverlay()).toBe(false)
|
|
67
|
+
const active = new AbortController()
|
|
68
|
+
const cancelled = host.select('Choice', ['One'], { signal: active.signal })
|
|
69
|
+
active.abort()
|
|
70
|
+
expect(await cancelled).toBeUndefined()
|
|
71
|
+
expect(tui.getFocusedComponent()).toBe(original)
|
|
72
|
+
const timed = host.input('Expires', undefined, { timeout: 1000 })
|
|
73
|
+
expect(plain(tui.getFocusedComponent()!).join('\n')).toContain('1s')
|
|
74
|
+
await vi.advanceTimersByTimeAsync(1000)
|
|
75
|
+
expect(await timed).toBeUndefined()
|
|
76
|
+
expect(tui.getFocusedComponent()).toBe(original)
|
|
77
|
+
expect(vi.getTimerCount()).toBe(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('masks credentials while retaining exact pasted and edited values', async () => {
|
|
81
|
+
const { host, tui } = setup()
|
|
82
|
+
const result = host.secretInput('API key')
|
|
83
|
+
key(tui, '\x1b[200~secret_日本語_credential\x1b[201~')
|
|
84
|
+
const component = tui.getFocusedComponent()!
|
|
85
|
+
expect(plain(component).join('\n')).not.toContain('secret')
|
|
86
|
+
expect(plain(component).join('\n')).not.toContain('credential')
|
|
87
|
+
expect(plain(component).join('\n')).toContain('•')
|
|
88
|
+
key(tui, '\r')
|
|
89
|
+
expect(await result).toBe('secret_日本語_credential')
|
|
90
|
+
const direct = new SecretInput({ prompt: '' })
|
|
91
|
+
direct.setValue('private 🧠 secret')
|
|
92
|
+
direct.focused = true
|
|
93
|
+
for (let width = 1; width < 40; width++) {
|
|
94
|
+
for (const row of direct.render(width)) {
|
|
95
|
+
expect(row).not.toContain('private')
|
|
96
|
+
expect(row).not.toContain('secret')
|
|
97
|
+
expect(visibleWidth(row)).toBeLessThanOrEqual(width)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('mounts trusted custom overlays and disposes them exactly once after done', async () => {
|
|
103
|
+
const { host, tui, original, terminal } = setup()
|
|
104
|
+
let finish: (value: string) => void = () => {}
|
|
105
|
+
const custom = { render: () => ['Custom component'], invalidate() {}, dispose: vi.fn() }
|
|
106
|
+
const onHandle = vi.fn()
|
|
107
|
+
const factory = vi.fn((_tui, _theme, _keys, done) => { finish = done; return custom })
|
|
108
|
+
const result = host.custom<string>(factory, { overlay: true, overlayOptions: () => ({ width: '50%', anchor: 'center' }), onHandle })
|
|
109
|
+
await vi.waitFor(() => expect(onHandle).toHaveBeenCalledOnce())
|
|
110
|
+
expect(factory.mock.calls[0][0]).toBe(tui)
|
|
111
|
+
expect(factory.mock.calls[0][1]).toBe(theme)
|
|
112
|
+
expect(tui.getFocusedComponent()).toBe(custom)
|
|
113
|
+
finish('selected')
|
|
114
|
+
finish('duplicate')
|
|
115
|
+
expect(await result).toBe('selected')
|
|
116
|
+
expect(custom.dispose).toHaveBeenCalledOnce()
|
|
117
|
+
expect(tui.getFocusedComponent()).toBe(original)
|
|
118
|
+
expect(terminal.start).not.toHaveBeenCalled()
|
|
119
|
+
expect(terminal.stop).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('handles synchronous completion and async rejection without leaked focus or mounts', async () => {
|
|
123
|
+
const { host, tui, original } = setup()
|
|
124
|
+
const custom = { render: () => ['Custom'], invalidate() {}, dispose: vi.fn() }
|
|
125
|
+
expect(await host.custom<string>((_tui, _theme, _keys, done) => { done('early'); return custom })).toBe('early')
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
expect(custom.dispose).toHaveBeenCalledOnce()
|
|
128
|
+
await expect(host.custom(async () => { throw new Error('extension failed') })).rejects.toThrow('extension failed')
|
|
129
|
+
expect(tui.getFocusedComponent()).toBe(original)
|
|
130
|
+
expect(tui.hasOverlay()).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('preserves trusted factories and forwards widgets, statuses, notifications and editor operations', () => {
|
|
134
|
+
const { host, callbacks, original, terminal } = setup()
|
|
135
|
+
const factory = vi.fn(() => original)
|
|
136
|
+
host.setWidget('stats', factory, { placement: 'belowEditor' })
|
|
137
|
+
host.setHeader(factory)
|
|
138
|
+
host.setFooter(factory)
|
|
139
|
+
host.setStatus('custom', 'Ready')
|
|
140
|
+
host.notify('Loaded', 'info')
|
|
141
|
+
host.pasteToEditor('inserted')
|
|
142
|
+
host.setEditorText('replacement')
|
|
143
|
+
host.setEditorComponent(factory)
|
|
144
|
+
expect(host.getEditorComponent()).toBe(factory)
|
|
145
|
+
expect(callbacks.setWidget).toHaveBeenCalledWith('stats', factory, 'belowEditor')
|
|
146
|
+
expect(callbacks.setHeader).toHaveBeenCalledWith(factory)
|
|
147
|
+
expect(callbacks.setFooter).toHaveBeenCalledWith(factory)
|
|
148
|
+
expect(callbacks.setEditor).toHaveBeenCalledWith(factory)
|
|
149
|
+
expect(callbacks.setStatus).toHaveBeenCalledWith('custom', 'Ready')
|
|
150
|
+
expect(callbacks.notify).toHaveBeenCalledWith('Loaded', 'info')
|
|
151
|
+
expect(original.getValue()).toBe('inserted')
|
|
152
|
+
expect(callbacks.setEditorText).toHaveBeenCalledWith('replacement')
|
|
153
|
+
expect(host.getEditorText()).toBe('draft')
|
|
154
|
+
host.setTitle('Session\x1b]2;bad\x07\nname')
|
|
155
|
+
expect(terminal.setTitle).toHaveBeenCalledWith('Session name')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('retains theme, working indicator and output expansion state with actual callbacks', () => {
|
|
159
|
+
const { host, callbacks } = setup()
|
|
160
|
+
host.setWorkingMessage('Inspecting')
|
|
161
|
+
host.setWorkingVisible(false)
|
|
162
|
+
host.setWorkingIndicator({ frames: ['●'], intervalMs: 500 })
|
|
163
|
+
host.setHiddenThinkingLabel('Reasoning')
|
|
164
|
+
host.setToolsExpanded(true)
|
|
165
|
+
expect(host.state).toMatchObject({ workingMessage: 'Inspecting', workingVisible: false, workingIndicator: { frames: ['●'], intervalMs: 500 }, hiddenThinkingLabel: 'Reasoning' })
|
|
166
|
+
expect(host.getToolsExpanded()).toBe(true)
|
|
167
|
+
expect(callbacks.setToolsExpanded).toHaveBeenCalledWith(true)
|
|
168
|
+
expect(host.getAllThemes()).toEqual([{ name: 'harness-titanium', path: undefined }])
|
|
169
|
+
expect(host.getTheme('harness-titanium')).toBe(theme)
|
|
170
|
+
expect(host.setTheme('missing').success).toBe(false)
|
|
171
|
+
expect(host.setTheme(theme)).toEqual({ success: true })
|
|
172
|
+
expect(callbacks.themeChanged).toHaveBeenCalledWith(theme)
|
|
173
|
+
expect(host.theme).toBe(theme)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('stacks autocomplete wrappers over the original provider and preserves input listener cleanup', () => {
|
|
177
|
+
const { host, tui } = setup()
|
|
178
|
+
const provider: AutocompleteProvider = { getSuggestions: async () => null, applyCompletion: (lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol }) }
|
|
179
|
+
const first = vi.fn((base: AutocompleteProvider) => ({ ...base, triggerCharacters: ['@'] }))
|
|
180
|
+
const second = vi.fn((base: AutocompleteProvider) => ({ ...base, triggerCharacters: [...base.triggerCharacters!, '#'] }))
|
|
181
|
+
host.addAutocompleteProvider(first)
|
|
182
|
+
host.addAutocompleteProvider(second)
|
|
183
|
+
expect(host.wrapAutocomplete(provider).triggerCharacters).toEqual(['@', '#'])
|
|
184
|
+
const remove = vi.fn()
|
|
185
|
+
vi.spyOn(tui, 'addInputListener').mockReturnValue(remove)
|
|
186
|
+
const unsubscribe = host.onTerminalInput(() => ({ consume: true }))
|
|
187
|
+
unsubscribe()
|
|
188
|
+
unsubscribe()
|
|
189
|
+
host.dispose()
|
|
190
|
+
expect(remove).toHaveBeenCalledOnce()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('resets session UI contributions and listeners while retaining a reusable UI object', async () => {
|
|
194
|
+
const { host, tui, callbacks } = setup()
|
|
195
|
+
const remove = vi.fn()
|
|
196
|
+
vi.spyOn(tui, 'addInputListener').mockReturnValue(remove)
|
|
197
|
+
host.onTerminalInput(() => undefined)
|
|
198
|
+
host.setStatus('session-status', 'Busy')
|
|
199
|
+
host.setWidget('session-widget', ['Temporary'])
|
|
200
|
+
host.setWorkingMessage('Old session')
|
|
201
|
+
host.setWorkingVisible(false)
|
|
202
|
+
host.setWorkingIndicator({ frames: ['●'] })
|
|
203
|
+
host.setHiddenThinkingLabel('Hidden')
|
|
204
|
+
host.setToolsExpanded(true)
|
|
205
|
+
host.addAutocompleteProvider(provider => provider)
|
|
206
|
+
const pending = host.input('Old session question')
|
|
207
|
+
host.reset()
|
|
208
|
+
expect(await pending).toBeUndefined()
|
|
209
|
+
expect(remove).toHaveBeenCalledOnce()
|
|
210
|
+
expect(callbacks.setStatus).toHaveBeenLastCalledWith('session-status', undefined)
|
|
211
|
+
expect(callbacks.setWidget).toHaveBeenLastCalledWith('session-widget', undefined)
|
|
212
|
+
expect(callbacks.setHeader).toHaveBeenLastCalledWith(undefined)
|
|
213
|
+
expect(callbacks.setFooter).toHaveBeenLastCalledWith(undefined)
|
|
214
|
+
expect(callbacks.setEditor).toHaveBeenLastCalledWith(undefined)
|
|
215
|
+
expect(host.state).toMatchObject({ workingVisible: true, toolsExpanded: false, autocompleteFactories: [] })
|
|
216
|
+
expect(host.state.workingMessage).toBeUndefined()
|
|
217
|
+
expect(host.state.workingIndicator).toBeUndefined()
|
|
218
|
+
expect(host.state.hiddenThinkingLabel).toBeUndefined()
|
|
219
|
+
const next = host.input('New session question')
|
|
220
|
+
key(tui, 'new session')
|
|
221
|
+
key(tui, '\r')
|
|
222
|
+
expect(await next).toBe('new session')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('refreshes loaded theme identities without retaining removed resource themes', () => {
|
|
226
|
+
const { host } = setup()
|
|
227
|
+
const loaded = Object.create(theme) as Theme
|
|
228
|
+
Object.defineProperty(loaded, 'name', { value: 'plugin-theme' })
|
|
229
|
+
Object.defineProperty(loaded, 'sourcePath', { value: '/trusted/theme.json' })
|
|
230
|
+
host.setThemes([loaded])
|
|
231
|
+
expect(host.getAllThemes()).toContainEqual({ name: 'plugin-theme', path: '/trusted/theme.json' })
|
|
232
|
+
expect(host.getTheme('plugin-theme')).toBe(loaded)
|
|
233
|
+
host.setThemes([])
|
|
234
|
+
expect(host.getTheme('plugin-theme')).toBeUndefined()
|
|
235
|
+
expect(host.getTheme('harness-titanium')).toBe(theme)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('filters long selectors by their labels and preserves real selection across terminal resizes', async () => {
|
|
239
|
+
const { host, tui, terminal } = setup()
|
|
240
|
+
const options = Array.from({ length: 30 }, (_, index) => `Provider model ${index}`)
|
|
241
|
+
options.push('Distinct 中文 model')
|
|
242
|
+
const selected = host.select('Models', options)
|
|
243
|
+
const panel = tui.getFocusedComponent()!
|
|
244
|
+
expect(plain(panel).join('\n')).toContain('Type to search')
|
|
245
|
+
key(tui, 'Distinct')
|
|
246
|
+
expect(plain(panel).join('\n')).toContain('Distinct 中文 model')
|
|
247
|
+
expect(plain(panel).join('\n')).not.toContain('Provider model')
|
|
248
|
+
Object.defineProperty(terminal, 'rows', { value: 8 })
|
|
249
|
+
for (let width = 0; width < 40; width++) {
|
|
250
|
+
for (const row of panel.render(width)) expect(visibleWidth(row)).toBeLessThanOrEqual(width)
|
|
251
|
+
}
|
|
252
|
+
key(tui, '\r')
|
|
253
|
+
expect(await selected).toBe('Distinct 中文 model')
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('keeps CJK multiline editors responsive even in one-column overlays', async () => {
|
|
257
|
+
const { host, tui } = setup()
|
|
258
|
+
const editing = host.editor('中文', '日本語 🧠\nSecond line')
|
|
259
|
+
const panel = tui.getFocusedComponent()!
|
|
260
|
+
for (const width of [0, 1, 2, 3, 4, 5, 10, 80]) {
|
|
261
|
+
for (const row of panel.render(width)) expect(visibleWidth(row), `width ${width}`).toBeLessThanOrEqual(width)
|
|
262
|
+
}
|
|
263
|
+
key(tui, '\r')
|
|
264
|
+
expect(await editing).toBe('日本語 🧠\nSecond line')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('keeps approval choices and controls visible while every long action detail remains scrollable', async () => {
|
|
268
|
+
const { host, tui, terminal } = setup()
|
|
269
|
+
Object.defineProperty(terminal, 'rows', { value: 20 })
|
|
270
|
+
const message = Array.from({ length: 50 }, (_, index) => `Action detail ${index}: /important/path/file-${index}.txt`).join('\n')
|
|
271
|
+
const confirmation = host.confirm('Allow this action?', message)
|
|
272
|
+
const panel = tui.getFocusedComponent()!
|
|
273
|
+
const observed: string[] = []
|
|
274
|
+
for (let page = 0; page < 20; page++) {
|
|
275
|
+
const rows = plain(panel, 40)
|
|
276
|
+
expect(rows.length).toBeLessThanOrEqual(16)
|
|
277
|
+
expect(rows.join('\n')).toContain('❯ Yes')
|
|
278
|
+
expect(rows.join('\n')).toContain('No')
|
|
279
|
+
expect(rows.some(row => row.includes('PgUp/PgDn details') && row.includes('Enter'))).toBe(true)
|
|
280
|
+
observed.push(...rows)
|
|
281
|
+
key(tui, '\x1b[6~')
|
|
282
|
+
}
|
|
283
|
+
expect(observed.join('\n')).toContain('file-0.txt')
|
|
284
|
+
expect(observed.join('\n')).toContain('file-49.txt')
|
|
285
|
+
key(tui, '\r')
|
|
286
|
+
expect(await confirmation).toBe(true)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('keeps the write-answer choice visible when a long question menu scrolls at 40 by 20', async () => {
|
|
290
|
+
const { host, tui, terminal } = setup()
|
|
291
|
+
Object.defineProperty(terminal, 'rows', { value: 20 })
|
|
292
|
+
const choices = [...Array.from({ length: 20 }, (_, index) => `Choice ${index}: description ${'long '.repeat(20)}`), 'Write an answer']
|
|
293
|
+
const selected = host.select('A question with long context', choices)
|
|
294
|
+
for (let index = 1; index < choices.length; index++) key(tui, '\x1b[B')
|
|
295
|
+
const rows = plain(tui.getFocusedComponent()!, 40)
|
|
296
|
+
expect(rows.length).toBeLessThanOrEqual(16)
|
|
297
|
+
expect(rows.join('\n')).toContain('❯ Write an answer')
|
|
298
|
+
key(tui, '\r')
|
|
299
|
+
expect(await selected).toBe('Write an answer')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('cancels all pending dialogs on shutdown and keeps every dialog row within the terminal width', async () => {
|
|
303
|
+
const { host, tui } = setup()
|
|
304
|
+
const input = host.input('中文title', 'Placeholder')
|
|
305
|
+
const panel = tui.getFocusedComponent() as DialogPanel
|
|
306
|
+
for (let width = 0; width < 100; width++) {
|
|
307
|
+
for (const row of panel.render(width)) expect(visibleWidth(row), `width ${width}`).toBeLessThanOrEqual(width)
|
|
308
|
+
}
|
|
309
|
+
host.dispose()
|
|
310
|
+
expect(await input).toBeUndefined()
|
|
311
|
+
expect(tui.hasOverlay()).toBe(false)
|
|
312
|
+
})
|
|
313
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Theme, type AutocompleteProviderFactory, type ExtensionUIContext, type KeybindingsManager, type WorkingIndicatorOptions } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, type AutocompleteProvider, type EditorComponent, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
|
|
4
|
+
import { TerminalDialogs } from './dialogs'
|
|
5
|
+
|
|
6
|
+
type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
|
|
7
|
+
type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
|
|
8
|
+
type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
|
|
9
|
+
type WidgetContent = string[] | ((...args: Parameters<NonNullable<HeaderFactory>>) => ReturnType<NonNullable<HeaderFactory>>) | undefined
|
|
10
|
+
|
|
11
|
+
export interface TerminalUiState {
|
|
12
|
+
theme: Theme
|
|
13
|
+
workingMessage?: string
|
|
14
|
+
workingVisible: boolean
|
|
15
|
+
workingIndicator?: WorkingIndicatorOptions
|
|
16
|
+
hiddenThinkingLabel?: string
|
|
17
|
+
toolsExpanded: boolean
|
|
18
|
+
autocompleteFactories: AutocompleteProviderFactory[]
|
|
19
|
+
}
|
|
20
|
+
export interface TerminalUiOptions {
|
|
21
|
+
tui: TUI
|
|
22
|
+
theme: Theme
|
|
23
|
+
keybindings: KeybindingsManager
|
|
24
|
+
changed(): void
|
|
25
|
+
notify(message: string, type?: string): void
|
|
26
|
+
setEditor(factory: EditorFactory): void
|
|
27
|
+
getEditorText(): string
|
|
28
|
+
setEditorText(value: string): void
|
|
29
|
+
setStatus(key: string, value: string | undefined): void
|
|
30
|
+
setWidget(key: string, content: WidgetContent, placement?: string): void
|
|
31
|
+
setHeader(factory: HeaderFactory): void
|
|
32
|
+
setFooter(factory: FooterFactory): void
|
|
33
|
+
getEditor?(): EditorComponent
|
|
34
|
+
getAutocompleteProvider?(): AutocompleteProvider
|
|
35
|
+
setAutocompleteProvider?(provider: AutocompleteProvider): void
|
|
36
|
+
themes?: Theme[]
|
|
37
|
+
themeChanged?(theme: Theme): void
|
|
38
|
+
getToolsExpanded?(): boolean
|
|
39
|
+
setToolsExpanded?(expanded: boolean): void
|
|
40
|
+
}
|
|
41
|
+
export interface TerminalUi extends ExtensionUIContext {
|
|
42
|
+
readonly state: TerminalUiState
|
|
43
|
+
secretInput: ExtensionUIContext['input']
|
|
44
|
+
wrapAutocomplete(provider: AutocompleteProvider): AutocompleteProvider
|
|
45
|
+
reset(): void
|
|
46
|
+
setThemes(themes: Theme[]): void
|
|
47
|
+
dispose(): void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** UI adaptation only: the Application owns component mounting and terminal lifecycle. */
|
|
51
|
+
export function createTerminalUi(options: TerminalUiOptions): TerminalUi {
|
|
52
|
+
const state: TerminalUiState = {
|
|
53
|
+
theme: options.theme, workingVisible: true, toolsExpanded: false, autocompleteFactories: []
|
|
54
|
+
}
|
|
55
|
+
const dialogs = new TerminalDialogs(options.tui, () => state.theme, options.keybindings, options.changed)
|
|
56
|
+
const inputSubscriptions = new Set<() => void>()
|
|
57
|
+
const builtInThemes = options.themes ?? []
|
|
58
|
+
const themes = new Map(builtInThemes.filter(theme => theme.name).map(theme => [theme.name!, theme]))
|
|
59
|
+
if (options.theme.name) themes.set(options.theme.name, options.theme)
|
|
60
|
+
const statusKeys = new Set<string>()
|
|
61
|
+
const widgetKeys = new Set<string>()
|
|
62
|
+
let editorFactory: EditorFactory
|
|
63
|
+
let disposed = false
|
|
64
|
+
const changed = () => { if (!disposed) options.changed() }
|
|
65
|
+
const wrapAutocomplete = (provider: AutocompleteProvider) => state.autocompleteFactories.reduce((current, factory) => factory(current), provider)
|
|
66
|
+
return {
|
|
67
|
+
state,
|
|
68
|
+
select: (title, items, opts) => dialogs.select(title, items, opts),
|
|
69
|
+
confirm: (title, message, opts) => dialogs.confirm(title, message, opts),
|
|
70
|
+
input: (title, placeholder, opts) => dialogs.input(title, placeholder, opts),
|
|
71
|
+
secretInput: (title, placeholder, opts) => dialogs.input(title, placeholder, opts, true),
|
|
72
|
+
editor: (title, prefill) => dialogs.editor(title, prefill),
|
|
73
|
+
custom: (factory, opts) => dialogs.custom(factory, opts),
|
|
74
|
+
notify: (message, type) => options.notify(message, type),
|
|
75
|
+
onTerminalInput(handler) {
|
|
76
|
+
if (disposed) return () => {}
|
|
77
|
+
const remove = options.tui.addInputListener(handler)
|
|
78
|
+
const unsubscribe = () => { if (inputSubscriptions.delete(unsubscribe)) remove() }
|
|
79
|
+
inputSubscriptions.add(unsubscribe)
|
|
80
|
+
return unsubscribe
|
|
81
|
+
},
|
|
82
|
+
setStatus(key, value) { if (value === undefined) statusKeys.delete(key); else statusKeys.add(key); options.setStatus(key, value); changed() },
|
|
83
|
+
setWorkingMessage(message) { state.workingMessage = message; changed() },
|
|
84
|
+
setWorkingVisible(visible) { state.workingVisible = visible; changed() },
|
|
85
|
+
setWorkingIndicator(indicator) {
|
|
86
|
+
state.workingIndicator = indicator ? { ...indicator, frames: indicator.frames?.slice() } : undefined
|
|
87
|
+
changed()
|
|
88
|
+
},
|
|
89
|
+
setHiddenThinkingLabel(label) { state.hiddenThinkingLabel = label; changed() },
|
|
90
|
+
setWidget(key, content, placement) { if (content === undefined) widgetKeys.delete(key); else widgetKeys.add(key); options.setWidget(key, content, placement?.placement); changed() },
|
|
91
|
+
setHeader(factory) { options.setHeader(factory); changed() },
|
|
92
|
+
setFooter(factory) { options.setFooter(factory); changed() },
|
|
93
|
+
setTitle(title) { options.tui.terminal.setTitle(stripTerminalSequences(title).replace(/[\x00-\x1f\x7f]/g, ' ')) },
|
|
94
|
+
pasteToEditor(text) {
|
|
95
|
+
const editor = options.getEditor?.()
|
|
96
|
+
if (editor) editor.handleInput(`\x1b[200~${text}\x1b[201~`)
|
|
97
|
+
else options.setEditorText(options.getEditorText() + text)
|
|
98
|
+
changed()
|
|
99
|
+
},
|
|
100
|
+
setEditorText(text) { options.setEditorText(text); changed() },
|
|
101
|
+
getEditorText: options.getEditorText,
|
|
102
|
+
addAutocompleteProvider(factory) {
|
|
103
|
+
state.autocompleteFactories.push(factory)
|
|
104
|
+
const base = options.getAutocompleteProvider?.()
|
|
105
|
+
if (base) options.setAutocompleteProvider?.(wrapAutocomplete(base))
|
|
106
|
+
changed()
|
|
107
|
+
},
|
|
108
|
+
wrapAutocomplete,
|
|
109
|
+
setEditorComponent(factory) { editorFactory = factory; options.setEditor(factory); changed() },
|
|
110
|
+
getEditorComponent: () => editorFactory,
|
|
111
|
+
get theme() { return state.theme },
|
|
112
|
+
getAllThemes: () => [...themes].map(([name, theme]) => ({ name, path: theme.sourcePath })),
|
|
113
|
+
getTheme: name => themes.get(name),
|
|
114
|
+
setTheme(themeOrName) {
|
|
115
|
+
const theme = typeof themeOrName === 'string' ? themes.get(themeOrName) : themeOrName
|
|
116
|
+
if (!(theme instanceof Theme)) return { success: false, error: `Unknown theme: ${String(themeOrName)}` }
|
|
117
|
+
state.theme = theme
|
|
118
|
+
if (theme.name) themes.set(theme.name, theme)
|
|
119
|
+
options.themeChanged?.(theme)
|
|
120
|
+
options.tui.invalidate()
|
|
121
|
+
changed()
|
|
122
|
+
return { success: true }
|
|
123
|
+
},
|
|
124
|
+
getToolsExpanded: () => options.getToolsExpanded?.() ?? state.toolsExpanded,
|
|
125
|
+
setToolsExpanded(expanded) { state.toolsExpanded = expanded; options.setToolsExpanded?.(expanded); changed() },
|
|
126
|
+
setThemes(loaded) {
|
|
127
|
+
themes.clear()
|
|
128
|
+
for (const theme of builtInThemes) if (theme.name) themes.set(theme.name, theme)
|
|
129
|
+
for (const theme of loaded) if (theme.name) themes.set(theme.name, theme)
|
|
130
|
+
if (options.theme.name && !themes.has(options.theme.name)) themes.set(options.theme.name, options.theme)
|
|
131
|
+
if (state.theme.name && !themes.has(state.theme.name)) themes.set(state.theme.name, state.theme)
|
|
132
|
+
changed()
|
|
133
|
+
},
|
|
134
|
+
reset() {
|
|
135
|
+
if (disposed) return
|
|
136
|
+
dialogs.reset()
|
|
137
|
+
for (const unsubscribe of [...inputSubscriptions]) unsubscribe()
|
|
138
|
+
inputSubscriptions.clear()
|
|
139
|
+
for (const key of statusKeys) options.setStatus(key, undefined)
|
|
140
|
+
for (const key of widgetKeys) options.setWidget(key, undefined)
|
|
141
|
+
statusKeys.clear()
|
|
142
|
+
widgetKeys.clear()
|
|
143
|
+
state.autocompleteFactories = []
|
|
144
|
+
state.workingMessage = undefined
|
|
145
|
+
state.workingVisible = true
|
|
146
|
+
state.workingIndicator = undefined
|
|
147
|
+
state.hiddenThinkingLabel = undefined
|
|
148
|
+
state.toolsExpanded = false
|
|
149
|
+
editorFactory = undefined
|
|
150
|
+
options.setHeader(undefined)
|
|
151
|
+
options.setFooter(undefined)
|
|
152
|
+
options.setEditor(undefined)
|
|
153
|
+
options.setToolsExpanded?.(false)
|
|
154
|
+
const base = options.getAutocompleteProvider?.()
|
|
155
|
+
if (base) options.setAutocompleteProvider?.(base)
|
|
156
|
+
changed()
|
|
157
|
+
},
|
|
158
|
+
dispose() {
|
|
159
|
+
if (disposed) return
|
|
160
|
+
disposed = true
|
|
161
|
+
dialogs.dispose()
|
|
162
|
+
for (const unsubscribe of [...inputSubscriptions]) unsubscribe()
|
|
163
|
+
inputSubscriptions.clear()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Adapted from @oh-my-pi/pi-tui 18.11.3 (https://github.com/can1357/oh-my-pi).
|
|
3
|
+
*
|
|
4
|
+
* MIT License
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2025 Mario Zechner
|
|
7
|
+
* Copyright (c) 2025-2026 Can Bölük
|
|
8
|
+
* Copyright (c) 2026 Stencil Labs, Inc.
|
|
9
|
+
*
|
|
10
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
* in the Software without restriction, including without limitation the rights
|
|
13
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
* furnished to do so, subject to the following conditions:
|
|
16
|
+
*
|
|
17
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
* copies or substantial portions of the Software.
|
|
19
|
+
*
|
|
20
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
* SOFTWARE.
|
|
27
|
+
*/
|
|
28
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
29
|
+
import { type Component, type TerminalColorMode, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'
|
|
30
|
+
|
|
31
|
+
import manifest from '../../../package.json'
|
|
32
|
+
|
|
33
|
+
export const PI_LOGO = ['████████████', ' ██ ██ ', ' ██ ██ ', ' ▒▒ ██ ', ' ██ '] as const
|
|
34
|
+
|
|
35
|
+
const GRADIENT_STOPS = [[248, 79, 204], [147, 98, 244], [0, 219, 228]] as const
|
|
36
|
+
const GRADIENT_RAMP_256 = [206, 170, 134, 99, 69, 74, 44] as const
|
|
37
|
+
const LOGO_WIDTH = 12
|
|
38
|
+
const LOCKUP_GAP = 4
|
|
39
|
+
const TIP_MEASURE = 72
|
|
40
|
+
const TIP_MIN_COLUMNS = 50
|
|
41
|
+
const DEFAULT_TIP = 'Use /accounts to manage sign-ins, /plugins to manage extensions, and /hub to browse reusable skills.'
|
|
42
|
+
|
|
43
|
+
/** The upstream resting diagonal gradient, including its exact indexed-color fallback. */
|
|
44
|
+
export function gradientLogo(lines: readonly string[], mode: TerminalColorMode): string[] {
|
|
45
|
+
const xSpan = Math.max(1, ...lines.map((line) => line.length - 1))
|
|
46
|
+
const ySpan = Math.max(1, lines.length - 1)
|
|
47
|
+
return lines.map((line, y) => [...line].map((glyph, x) => {
|
|
48
|
+
if (glyph === ' ') return glyph
|
|
49
|
+
const t = (x / xSpan + y / ySpan) / 2
|
|
50
|
+
let color: string
|
|
51
|
+
if (mode === 'truecolor') {
|
|
52
|
+
const segment = t * (GRADIENT_STOPS.length - 1)
|
|
53
|
+
const index = Math.min(GRADIENT_STOPS.length - 2, Math.floor(segment))
|
|
54
|
+
const fraction = segment - index
|
|
55
|
+
const from = GRADIENT_STOPS[index]
|
|
56
|
+
const to = GRADIENT_STOPS[index + 1]
|
|
57
|
+
const [r, g, b] = from.map((value, channel) => Math.round(value + (to[channel] - value) * fraction))
|
|
58
|
+
color = `\x1b[38;2;${r};${g};${b}m`
|
|
59
|
+
} else {
|
|
60
|
+
const index = Math.min(GRADIENT_RAMP_256.length - 1, Math.max(0, Math.floor(t * (GRADIENT_RAMP_256.length - 1) + 0.5)))
|
|
61
|
+
color = `\x1b[38;5;${GRADIENT_RAMP_256[index]}m`
|
|
62
|
+
}
|
|
63
|
+
return color + glyph + '\x1b[0m'
|
|
64
|
+
}).join(''))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function centerBlock(lines: readonly string[], width: number): string[] {
|
|
68
|
+
const widest = Math.max(0, ...lines.map((line) => visibleWidth(line)))
|
|
69
|
+
const indent = ' '.repeat(Math.max(0, Math.floor((width - widest) / 2)))
|
|
70
|
+
return lines.map((line) => indent + line)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class HarnessWelcome implements Component {
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly theme: Theme,
|
|
76
|
+
private readonly productName = 'Xingular Harness',
|
|
77
|
+
private readonly version = manifest.version,
|
|
78
|
+
private readonly tip = DEFAULT_TIP
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
invalidate(): void {}
|
|
82
|
+
|
|
83
|
+
render(width: number): string[] {
|
|
84
|
+
const room = width - 2
|
|
85
|
+
if (room < LOGO_WIDTH) return []
|
|
86
|
+
const logo = gradientLogo(PI_LOGO, this.theme.getColorMode())
|
|
87
|
+
const name = this.productName.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
88
|
+
const version = this.version.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
89
|
+
const labelWidth = Math.max(visibleWidth(name), visibleWidth(`v${version}`))
|
|
90
|
+
const lockupWidth = LOGO_WIDTH + LOCKUP_GAP + labelWidth
|
|
91
|
+
const beside = ['', this.theme.bold(this.theme.fg('text', name)), '', '', this.theme.fg('dim', `v${version}`)]
|
|
92
|
+
const art = room >= lockupWidth ? logo.map((line, index) => line + ' '.repeat(LOCKUP_GAP) + beside[index]) : logo
|
|
93
|
+
const lines = centerBlock(art, width)
|
|
94
|
+
if (width < TIP_MIN_COLUMNS || !this.tip) return lines
|
|
95
|
+
const label = 'Tip: '
|
|
96
|
+
const wrapped = wrapTextWithAnsi(label + this.tip, Math.min(room, TIP_MEASURE)).map((line) => line.trimEnd())
|
|
97
|
+
const tips = wrapped.map((line, index) => this.theme.italic(index === 0 && line.startsWith(label)
|
|
98
|
+
? this.theme.fg('customMessageLabel', label) + this.theme.fg('muted', line.slice(label.length))
|
|
99
|
+
: this.theme.fg('muted', line)))
|
|
100
|
+
return [...lines, '', ...tips.flatMap((line) => centerBlock([line], width))]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
chooseCompletionVerb,
|
|
5
|
+
chooseWorkingVerb,
|
|
6
|
+
COMPLETION_VERBS,
|
|
7
|
+
createCompletionStatus,
|
|
8
|
+
formatWorkingDuration,
|
|
9
|
+
getWorkingSpinnerFrames,
|
|
10
|
+
SPINNER_INTERVAL_MS,
|
|
11
|
+
spinnerFrameAt,
|
|
12
|
+
WORKING_VERBS
|
|
13
|
+
} from './workingStatus'
|
|
14
|
+
|
|
15
|
+
describe('Claude-style working status', () => {
|
|
16
|
+
it('keeps the full default verb set and selects a stable value from one random sample', () => {
|
|
17
|
+
expect(WORKING_VERBS).toHaveLength(188)
|
|
18
|
+
expect(WORKING_VERBS).toContain('Thundering')
|
|
19
|
+
expect(WORKING_VERBS).toContain('Wandering')
|
|
20
|
+
expect(chooseWorkingVerb(() => 0)).toBe('Accomplishing')
|
|
21
|
+
expect(chooseWorkingVerb(() => 1)).toBe('Zigzagging')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('animates the same glyph sequence, with the Ghostty-compatible ending', () => {
|
|
25
|
+
expect(getWorkingSpinnerFrames()).toEqual(['·', '✢', '✳', '✶', '✻', '✽', '✽', '✻', '✶', '✳', '✢', '·'])
|
|
26
|
+
expect(getWorkingSpinnerFrames('xterm-ghostty')).toEqual(['·', '✢', '✳', '✶', '✻', '✻', '✻', '✻', '✶', '✳', '✢', '·'])
|
|
27
|
+
const frames = getWorkingSpinnerFrames()
|
|
28
|
+
expect(SPINNER_INTERVAL_MS).toBe(80)
|
|
29
|
+
expect(spinnerFrameAt(0, frames)).toBe('·')
|
|
30
|
+
expect(spinnerFrameAt(80, frames)).toBe('✢')
|
|
31
|
+
expect(spinnerFrameAt(12 * SPINNER_INTERVAL_MS, frames)).toBe('·')
|
|
32
|
+
expect(spinnerFrameAt(-1, frames)).toBe('·')
|
|
33
|
+
expect(spinnerFrameAt(0, [])).toBe('')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('keeps the completion verb deterministic and formats elapsed time', () => {
|
|
37
|
+
const turnId = 'turn-uuid'
|
|
38
|
+
expect(COMPLETION_VERBS).toHaveLength(8)
|
|
39
|
+
expect(chooseCompletionVerb(turnId)).toBe(chooseCompletionVerb(turnId))
|
|
40
|
+
expect(formatWorkingDuration(65_900)).toBe('1m 5s')
|
|
41
|
+
expect(formatWorkingDuration(-100)).toBe('0s')
|
|
42
|
+
expect(createCompletionStatus(turnId, 65_900)).toBe('✻ ' + chooseCompletionVerb(turnId) + ' for 1m 5s')
|
|
43
|
+
})
|
|
44
|
+
})
|