@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,71 @@
|
|
|
1
|
+
import type { AutocompleteProvider } from '@earendil-works/pi-tui'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { expandSkillInvocation, skillCompletions, type SkillEntry } from './skillInvocation'
|
|
5
|
+
|
|
6
|
+
const skills: SkillEntry[] = [
|
|
7
|
+
{ name: 'review', description: 'Review a change' },
|
|
8
|
+
{ name: 'refactor', description: 'Refactor a module' },
|
|
9
|
+
{ name: 'deploy', description: 'Ship a build' }
|
|
10
|
+
]
|
|
11
|
+
const names = new Set(skills.map((skill) => skill.name))
|
|
12
|
+
const signal = new AbortController().signal
|
|
13
|
+
|
|
14
|
+
const base: AutocompleteProvider = {
|
|
15
|
+
getSuggestions: async () => ({ items: [{ value: 'accounts', label: 'accounts' }], prefix: '/a' }),
|
|
16
|
+
applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('invoking a skill with $name', () => {
|
|
20
|
+
it('rewrites a leading $name of a loaded skill into the form Pi expands', () => {
|
|
21
|
+
expect(expandSkillInvocation('$review the module', names)).toBe('/skill:review the module')
|
|
22
|
+
expect(expandSkillInvocation(' $deploy', names)).toBe('/skill:deploy')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('leaves text alone when the name is not a loaded skill, or the $ is not a skill', () => {
|
|
26
|
+
expect(expandSkillInvocation('$unknown the module', names)).toBe('$unknown the module')
|
|
27
|
+
expect(expandSkillInvocation('It costs $5 today', names)).toBe('It costs $5 today')
|
|
28
|
+
expect(expandSkillInvocation('$5 budget', names)).toBe('$5 budget')
|
|
29
|
+
expect(expandSkillInvocation('/review module', names)).toBe('/review module')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('the $ completions', () => {
|
|
34
|
+
it('offers the skills that start with what has been typed after a word-start $', async () => {
|
|
35
|
+
const provider = skillCompletions(base, () => skills)
|
|
36
|
+
const suggestions = await provider.getSuggestions(['look at $rev'], 0, 12, { signal })
|
|
37
|
+
expect(suggestions).toEqual({
|
|
38
|
+
items: [{ value: 'review', label: '$review', description: 'Review a change' }],
|
|
39
|
+
prefix: '$rev'
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('offers every skill at a bare $, and nothing when no skill matches', async () => {
|
|
44
|
+
const provider = skillCompletions(base, () => skills)
|
|
45
|
+
expect((await provider.getSuggestions(['$'], 0, 1, { signal }))?.items.map((item) => item.value)).toEqual(['review', 'refactor', 'deploy'])
|
|
46
|
+
expect(await provider.getSuggestions(['cost $5'], 0, 7, { signal })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('gives a line that starts with a skill invocation no file completions in its arguments', async () => {
|
|
50
|
+
const files: AutocompleteProvider = { getSuggestions: async () => ({ items: [{ value: 'src/', label: 'src/' }], prefix: 'sr' }), applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }) }
|
|
51
|
+
const provider = skillCompletions(files, () => skills)
|
|
52
|
+
expect(await provider.getSuggestions(['$review '], 0, 8, { signal })).toBeNull()
|
|
53
|
+
expect(await provider.getSuggestions(['$review check the sr'], 0, 18, { signal })).toBeNull()
|
|
54
|
+
expect((await provider.getSuggestions(['check the sr'], 0, 12, { signal }))?.items).toEqual([{ value: 'src/', label: 'src/' }])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('hands every other completion to the base provider', async () => {
|
|
58
|
+
const provider = skillCompletions(base, () => skills)
|
|
59
|
+
expect(await provider.getSuggestions(['/a'], 0, 2, { signal })).toEqual({ items: [{ value: 'accounts', label: 'accounts' }], prefix: '/a' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('inserts the chosen skill as $name and a space in place of the typed prefix', () => {
|
|
63
|
+
const provider = skillCompletions(base, () => skills)
|
|
64
|
+
const completed = provider.applyCompletion(['look at $re and more'], 0, 11, { value: 'review', label: '$review' }, '$re')
|
|
65
|
+
expect(completed).toEqual({ lines: ['look at $review and more'], cursorLine: 0, cursorCol: 16 })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('opens the menu when a $ is typed', () => {
|
|
69
|
+
expect(skillCompletions(base, () => skills).triggerCharacters).toContain('$')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A skill is invoked as `$name` in the terminal. Pi expands only `/skill:name`, so the terminal
|
|
2
|
+
// rewrites the leading `$name` before the prompt is sent, and offers the loaded skills at a `$`.
|
|
3
|
+
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
export interface SkillEntry {
|
|
6
|
+
name: string
|
|
7
|
+
description: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Rewrites a leading `$name` of a loaded skill into `/skill:name`. Anything else passes through, so `$5` stays text. */
|
|
11
|
+
export function expandSkillInvocation(text: string, skills: ReadonlySet<string>): string {
|
|
12
|
+
const trimmed = text.trimStart()
|
|
13
|
+
const match = /^\$(\S+)(?=\s|$)/.exec(trimmed)
|
|
14
|
+
if (!match || !skills.has(match[1]!)) return text
|
|
15
|
+
return `/skill:${match[1]}${trimmed.slice(match[0].length)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A `$` that starts a word, and the partial name after it, at the cursor. */
|
|
19
|
+
const SKILL_TOKEN = /(?:^|\s)\$([^\s$]*)$/
|
|
20
|
+
/** A line whose first word is a skill invocation. */
|
|
21
|
+
const SKILL_LINE = /^\s*\$\S/
|
|
22
|
+
|
|
23
|
+
/** Offers the loaded skills at a `$` that starts a word. Every other completion is `base`'s. */
|
|
24
|
+
export function skillCompletions(base: AutocompleteProvider, skills: () => readonly SkillEntry[]): AutocompleteProvider {
|
|
25
|
+
return {
|
|
26
|
+
triggerCharacters: [...(base.triggerCharacters ?? []), '$'],
|
|
27
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
28
|
+
const line = lines[cursorLine] ?? ''
|
|
29
|
+
const token = SKILL_TOKEN.exec(line.slice(0, cursorCol))
|
|
30
|
+
if (token) {
|
|
31
|
+
const partial = token[1]!
|
|
32
|
+
const items: AutocompleteItem[] = skills()
|
|
33
|
+
.filter((skill) => skill.name.startsWith(partial))
|
|
34
|
+
.map((skill) => ({ value: skill.name, label: `$${skill.name}`, description: skill.description }))
|
|
35
|
+
return items.length ? { items, prefix: `$${partial}` } : null
|
|
36
|
+
}
|
|
37
|
+
// The arguments of a line that starts with a skill invocation are free text, not file paths.
|
|
38
|
+
if (SKILL_LINE.test(line)) return null
|
|
39
|
+
return base.getSuggestions(lines, cursorLine, cursorCol, options)
|
|
40
|
+
},
|
|
41
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
42
|
+
if (!prefix.startsWith('$')) return base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
|
43
|
+
const line = lines[cursorLine] ?? ''
|
|
44
|
+
const start = cursorCol - prefix.length
|
|
45
|
+
const inserted = `$${item.value} `
|
|
46
|
+
const newLines = [...lines]
|
|
47
|
+
newLines[cursorLine] = `${line.slice(0, start)}${inserted}${line.slice(cursorCol)}`
|
|
48
|
+
return { lines: newLines, cursorLine, cursorCol: start + inserted.length }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { initTheme, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { displayProjectName, renderStatusBand, type BandState } from './status'
|
|
6
|
+
import { createHarnessTheme } from './theme'
|
|
7
|
+
|
|
8
|
+
initTheme('dark', false)
|
|
9
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
+
const state: BandState = {
|
|
11
|
+
cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
12
|
+
thinkingLevel: 'high', branch: 'main', account: 'Tanny', inputTokens: 1300, outputTokens: 50,
|
|
13
|
+
tokensPerSecond: 30
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('composer status band', () => {
|
|
17
|
+
it('renders the four text groups in order with distinct colors', () => {
|
|
18
|
+
const line = renderStatusBand(state, 120, theme)
|
|
19
|
+
expect(stripTerminalSequences(line)).toBe('project | main | Tanny · Sonnet 4.6 · high | 30 tok/s in 1,300 out 50')
|
|
20
|
+
for (const color of ['38;2;232;236;244', '38;2;0;255;136', '38;2;0;180;255', '38;2;212;192;144']) {
|
|
21
|
+
expect(line).toContain(color)
|
|
22
|
+
}
|
|
23
|
+
expect(visibleWidth(line)).toBe(stripTerminalSequences(line).length)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('uses the last path component and a readable root fallback', () => {
|
|
27
|
+
expect(displayProjectName('/work/a/long-project/')).toBe('long-project')
|
|
28
|
+
expect(displayProjectName('/')).toBe('/')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('shows text fallbacks when branch, account, model, and rate are unavailable', () => {
|
|
32
|
+
const line = renderStatusBand({ ...state, branch: null, account: undefined, model: undefined, tokensPerSecond: null }, 120, theme)
|
|
33
|
+
expect(stripTerminalSequences(line)).toBe('project | — | account unknown · no-model · high | — tok/s in 1,300 out 50')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('stays within narrow terminal widths and sanitizes metadata', () => {
|
|
37
|
+
for (let width = 1; width <= 180; width++) {
|
|
38
|
+
const line = renderStatusBand({ ...state,
|
|
39
|
+
cwd: '/work/日本語/long-project-name',
|
|
40
|
+
branch: '\x1b]2;malicious title\x07main\nother',
|
|
41
|
+
model: { ...state.model!, name: '非常に長い名前'.repeat(20) }
|
|
42
|
+
}, width, theme)
|
|
43
|
+
expect(visibleWidth(line), `width ${width}`).toBeLessThanOrEqual(width)
|
|
44
|
+
expect(stripTerminalSequences(line)).not.toContain('malicious title')
|
|
45
|
+
expect(stripTerminalSequences(line)).not.toContain('\n')
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Presentation adapted from Oh My Pi 18.11.3's status line; it sits in the footer below the prompt frame.
|
|
2
|
+
// Upstream MIT license and evidence are recorded in NOTICE.md and docs/terminal-tui.md.
|
|
3
|
+
import { basename, sep } from 'node:path'
|
|
4
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { parseColor, stripTerminalSequences, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
6
|
+
|
|
7
|
+
import { TITANIUM } from './theme'
|
|
8
|
+
|
|
9
|
+
export interface BandState {
|
|
10
|
+
cwd: string
|
|
11
|
+
model?: { name: string; reasoning: boolean; contextWindow: number }
|
|
12
|
+
thinkingLevel: string
|
|
13
|
+
branch: string | null
|
|
14
|
+
account?: string
|
|
15
|
+
inputTokens: number
|
|
16
|
+
outputTokens: number
|
|
17
|
+
tokensPerSecond: number | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// OMP's shipped light theme supplies status colors separately from transcript colors.
|
|
21
|
+
const LIGHT_STATUS = {
|
|
22
|
+
...TITANIUM, darkTitanium: '#e0e0e0', electricBlue: '#875f87', subtleGray: '#808080',
|
|
23
|
+
brightAluminum: '#005f87', readoutGreen: '#005f00', titaniumGold: '#005f5f'
|
|
24
|
+
}
|
|
25
|
+
const palette = (theme: Theme) => theme.appearance === 'light' ? LIGHT_STATUS : TITANIUM
|
|
26
|
+
|
|
27
|
+
type StatusPart = { text: string; color: string }
|
|
28
|
+
|
|
29
|
+
export function singleLine(text: string): string {
|
|
30
|
+
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/ +/g, ' ').trim()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function displayProjectName(cwd: string): string {
|
|
34
|
+
return singleLine(basename(cwd) || cwd || sep)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function tokenCount(value: number): string {
|
|
38
|
+
return Math.max(0, Number.isFinite(value) ? Math.round(value) : 0).toLocaleString('en-US')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fitParts(parts: StatusPart[], width: number): StatusPart[] {
|
|
42
|
+
const fitted = parts.map(part => ({ ...part }))
|
|
43
|
+
const separator = ' | '
|
|
44
|
+
const plain = () => fitted.map(part => part.text).join(separator)
|
|
45
|
+
while (fitted.length > 1 && visibleWidth(plain()) > width) {
|
|
46
|
+
const last = fitted[fitted.length - 1]
|
|
47
|
+
const otherWidth = visibleWidth(plain()) - visibleWidth(separator) - visibleWidth(last.text)
|
|
48
|
+
const available = width - otherWidth - visibleWidth(separator)
|
|
49
|
+
if (available > 0 && available < visibleWidth(last.text)) {
|
|
50
|
+
last.text = truncateToWidth(last.text, available, '', false)
|
|
51
|
+
if (visibleWidth(plain()) <= width) return fitted
|
|
52
|
+
}
|
|
53
|
+
fitted.pop()
|
|
54
|
+
}
|
|
55
|
+
if (fitted.length && visibleWidth(plain()) > width) {
|
|
56
|
+
fitted[0].text = truncateToWidth(fitted[0].text, width, '', false)
|
|
57
|
+
}
|
|
58
|
+
return fitted
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function color(theme: Theme, hex: string, text: string): string {
|
|
62
|
+
return theme.style(text, { fg: parseColor(hex) })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function renderStatusBand(state: BandState, width: number, theme: Theme): string {
|
|
66
|
+
const size = Math.max(0, Math.floor(width))
|
|
67
|
+
if (!size) return ''
|
|
68
|
+
const colors = palette(theme)
|
|
69
|
+
const model = state.model ? singleLine(state.model.name).replace(/^Claude /, '') || 'no-model' : 'no-model'
|
|
70
|
+
const account = singleLine(state.account ?? '') || 'account unknown'
|
|
71
|
+
const effort = singleLine(state.thinkingLevel) || 'off'
|
|
72
|
+
const rate = state.tokensPerSecond === null || !Number.isFinite(state.tokensPerSecond)
|
|
73
|
+
? '— tok/s' : `${Math.max(0, Math.round(state.tokensPerSecond))} tok/s`
|
|
74
|
+
const parts = fitParts([
|
|
75
|
+
{ text: displayProjectName(state.cwd), color: colors.brightAluminum },
|
|
76
|
+
{ text: singleLine(state.branch ?? '') || '—', color: colors.readoutGreen },
|
|
77
|
+
{ text: `${account} · ${model} · ${effort}`, color: colors.electricBlue },
|
|
78
|
+
{ text: `${rate} in ${tokenCount(state.inputTokens)} out ${tokenCount(state.outputTokens)}`, color: colors.titaniumGold }
|
|
79
|
+
], size)
|
|
80
|
+
const separator = color(theme, colors.subtleGray, ' | ')
|
|
81
|
+
return parts.map(part => color(theme, part.color, part.text)).join(separator)
|
|
82
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
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 { Theme, type ThemeBg, type ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
29
|
+
|
|
30
|
+
const orange = { dark: '#ffb347', light: '#9a7326' } as const
|
|
31
|
+
|
|
32
|
+
export const TITANIUM = {
|
|
33
|
+
brushedTitanium: '#151820',
|
|
34
|
+
darkTitanium: '#0f1216',
|
|
35
|
+
electricBlue: '#00b4ff',
|
|
36
|
+
deepBlue: '#0082b3',
|
|
37
|
+
titaniumGold: '#d4c090',
|
|
38
|
+
brightAluminum: '#e8ecf4',
|
|
39
|
+
dimAluminum: '#9ca3b0',
|
|
40
|
+
warningAmber: orange.dark,
|
|
41
|
+
readoutGreen: '#00ff88',
|
|
42
|
+
alertRed: '#ff4757',
|
|
43
|
+
subtleGray: '#2a3038'
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
const foreground = {
|
|
47
|
+
accent: '#00b4ff',
|
|
48
|
+
border: '#2a3038',
|
|
49
|
+
borderAccent: '#00b4ff',
|
|
50
|
+
borderMuted: '#1f252d',
|
|
51
|
+
success: '#00ff88',
|
|
52
|
+
error: '#ff4757',
|
|
53
|
+
warning: orange.dark,
|
|
54
|
+
muted: '#9ca3b0',
|
|
55
|
+
dim: '#6b7280',
|
|
56
|
+
text: '',
|
|
57
|
+
thinkingText: '#9ca3b0',
|
|
58
|
+
userMessageText: '',
|
|
59
|
+
customMessageText: '',
|
|
60
|
+
customMessageLabel: '#d4c090',
|
|
61
|
+
toolTitle: '',
|
|
62
|
+
toolOutput: '#9ca3b0',
|
|
63
|
+
mdHeading: '#00b4ff',
|
|
64
|
+
mdLink: '#00b4ff',
|
|
65
|
+
mdLinkUrl: '#0082b3',
|
|
66
|
+
mdCode: '#00ff88',
|
|
67
|
+
mdCodeBlock: '#9ca3b0',
|
|
68
|
+
mdCodeBlockBorder: '#2a3038',
|
|
69
|
+
mdQuote: '#9ca3b0',
|
|
70
|
+
mdQuoteBorder: '#2a3038',
|
|
71
|
+
mdHr: '#2a3038',
|
|
72
|
+
mdListBullet: '#00b4ff',
|
|
73
|
+
toolDiffAdded: '#00ff88',
|
|
74
|
+
toolDiffRemoved: '#ff4757',
|
|
75
|
+
toolDiffContext: '#9ca3b0',
|
|
76
|
+
syntaxComment: '#6b7280',
|
|
77
|
+
syntaxKeyword: '#00b4ff',
|
|
78
|
+
syntaxFunction: '#00ff88',
|
|
79
|
+
syntaxVariable: '#e8ecf4',
|
|
80
|
+
syntaxString: '#d4c090',
|
|
81
|
+
syntaxNumber: '#ffb347',
|
|
82
|
+
syntaxType: '#00b4ff',
|
|
83
|
+
syntaxOperator: '#00b4ff',
|
|
84
|
+
syntaxPunctuation: '#9ca3b0',
|
|
85
|
+
thinkingOff: '#4a5058',
|
|
86
|
+
thinkingMinimal: '#5a6068',
|
|
87
|
+
thinkingLow: '#6a7078',
|
|
88
|
+
thinkingMedium: '#9ca3b0',
|
|
89
|
+
thinkingHigh: '#00b4ff',
|
|
90
|
+
thinkingXhigh: '#d4c090',
|
|
91
|
+
bashMode: '#00ff88',
|
|
92
|
+
scrollbarTrack: '#2a3038',
|
|
93
|
+
scrollbarThumb: '#9ca3b0',
|
|
94
|
+
searchMatchText: '#e8ecf4',
|
|
95
|
+
thinkingMax: '#d4c090'
|
|
96
|
+
} satisfies Record<ThemeColor, string>
|
|
97
|
+
|
|
98
|
+
const background = {
|
|
99
|
+
selectedBg: '#0082b3',
|
|
100
|
+
searchMatchBg: '#0082b3',
|
|
101
|
+
userMessageBg: '#0f1216',
|
|
102
|
+
customMessageBg: '#2a3038',
|
|
103
|
+
toolPendingBg: '#0f1216',
|
|
104
|
+
toolSuccessBg: '#0f1216',
|
|
105
|
+
toolErrorBg: '#1a0f10'
|
|
106
|
+
} satisfies Record<ThemeBg, string>
|
|
107
|
+
|
|
108
|
+
export function createHarnessTheme(base: Theme): Theme {
|
|
109
|
+
return new Theme(foreground, background, base.getColorMode(), { name: 'harness-titanium', appearance: 'dark' })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lightForeground = {
|
|
113
|
+
accent: '#5a8080',
|
|
114
|
+
border: '#547da7',
|
|
115
|
+
borderAccent: '#5a8080',
|
|
116
|
+
borderMuted: '#b0b0b0',
|
|
117
|
+
success: '#588458',
|
|
118
|
+
error: '#aa5555',
|
|
119
|
+
warning: orange.light,
|
|
120
|
+
muted: '#6c6c6c',
|
|
121
|
+
dim: '#767676',
|
|
122
|
+
text: '',
|
|
123
|
+
thinkingText: '#6c6c6c',
|
|
124
|
+
userMessageText: '',
|
|
125
|
+
customMessageText: '',
|
|
126
|
+
customMessageLabel: '#7e57c2',
|
|
127
|
+
toolTitle: '',
|
|
128
|
+
toolOutput: '#6c6c6c',
|
|
129
|
+
mdHeading: '#9a7326',
|
|
130
|
+
mdLink: '#547da7',
|
|
131
|
+
mdLinkUrl: '#767676',
|
|
132
|
+
mdCode: '#5a8080',
|
|
133
|
+
mdCodeBlock: '#588458',
|
|
134
|
+
mdCodeBlockBorder: '#6c6c6c',
|
|
135
|
+
mdQuote: '#6c6c6c',
|
|
136
|
+
mdQuoteBorder: '#6c6c6c',
|
|
137
|
+
mdHr: '#6c6c6c',
|
|
138
|
+
mdListBullet: '#588458',
|
|
139
|
+
toolDiffAdded: '#588458',
|
|
140
|
+
toolDiffRemoved: '#aa5555',
|
|
141
|
+
toolDiffContext: '#6c6c6c',
|
|
142
|
+
syntaxComment: '#008000',
|
|
143
|
+
syntaxKeyword: '#0000FF',
|
|
144
|
+
syntaxFunction: '#795E26',
|
|
145
|
+
syntaxVariable: '#001080',
|
|
146
|
+
syntaxString: '#A31515',
|
|
147
|
+
syntaxNumber: '#098658',
|
|
148
|
+
syntaxType: '#267F99',
|
|
149
|
+
syntaxOperator: '#000000',
|
|
150
|
+
syntaxPunctuation: '#000000',
|
|
151
|
+
thinkingOff: '#b0b0b0',
|
|
152
|
+
thinkingMinimal: '#767676',
|
|
153
|
+
thinkingLow: '#547da7',
|
|
154
|
+
thinkingMedium: '#5a8080',
|
|
155
|
+
thinkingHigh: '#875f87',
|
|
156
|
+
thinkingXhigh: '#8b008b',
|
|
157
|
+
bashMode: '#588458',
|
|
158
|
+
scrollbarTrack: '#b0b0b0',
|
|
159
|
+
scrollbarThumb: '#6c6c6c',
|
|
160
|
+
searchMatchText: '#3b3b3b',
|
|
161
|
+
thinkingMax: '#8b008b'
|
|
162
|
+
} satisfies Record<ThemeColor, string>
|
|
163
|
+
|
|
164
|
+
const lightBackground = {
|
|
165
|
+
selectedBg: '#d0d0e0',
|
|
166
|
+
searchMatchBg: '#d0d0e0',
|
|
167
|
+
userMessageBg: '#e8e8e8',
|
|
168
|
+
customMessageBg: '#ede7f6',
|
|
169
|
+
toolPendingBg: '#e8e8f0',
|
|
170
|
+
toolSuccessBg: '#e8f0e8',
|
|
171
|
+
toolErrorBg: '#f0e8e8'
|
|
172
|
+
} satisfies Record<ThemeBg, string>
|
|
173
|
+
|
|
174
|
+
export function createHarnessLightTheme(base: Theme): Theme {
|
|
175
|
+
return new Theme(lightForeground, lightBackground, base.getColorMode(), { name: 'light', appearance: 'light' })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The composer keeps its neutral frame; the slash completion divider uses warning amber.
|
|
179
|
+
export function composerRuleColor(theme: Theme): string {
|
|
180
|
+
return theme.appearance === 'light' ? '#8a8a8a' : '#888888'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function autocompleteRuleColor(theme: Theme): string {
|
|
184
|
+
return theme.appearance === 'light' ? orange.light : orange.dark
|
|
185
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createEditToolDefinition, createReadToolDefinition, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { createHarnessTheme } from './theme'
|
|
6
|
+
import { harnessToolRenderers } from './tools'
|
|
7
|
+
|
|
8
|
+
type Context = Parameters<NonNullable<ToolRenderers['renderCall']>>[2]
|
|
9
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
+
function context(overrides: Partial<Context> = {}): Context {
|
|
11
|
+
return {
|
|
12
|
+
args: {}, toolCallId: 'test-call', invalidate() {}, lastComponent: undefined,
|
|
13
|
+
state: {}, cwd: '/private/tmp', executionStarted: false, argsComplete: true,
|
|
14
|
+
isPartial: true, expanded: false, showImages: true, isError: false,
|
|
15
|
+
durationMs: undefined, outputPad: 1, ...overrides
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function generic(name = 'wiki_recall') {
|
|
19
|
+
return harnessToolRenderers(name, () => undefined)!
|
|
20
|
+
}
|
|
21
|
+
function result(lines: number) {
|
|
22
|
+
return { content: [{ type: 'text' as const, text: Array.from({ length: lines }, (_, index) => `source line ${index}`).join('\n') }], details: { evidence: 'original' } }
|
|
23
|
+
}
|
|
24
|
+
const plain = (rows: string[]) => rows.map(stripTerminalSequences)
|
|
25
|
+
|
|
26
|
+
describe('Harness tool presentation', () => {
|
|
27
|
+
it('resolves display hooks without replacing execution, schemas or the original tool', () => {
|
|
28
|
+
const execute = vi.fn()
|
|
29
|
+
const parameters = { type: 'object', properties: { query: { type: 'string' } } }
|
|
30
|
+
const definition = Object.freeze({ name: 'wiki_recall', parameters, execute, renderShell: 'default' as const })
|
|
31
|
+
const next = vi.fn(() => definition)
|
|
32
|
+
const display = harnessToolRenderers('wiki_recall', next)!
|
|
33
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
34
|
+
expect(Object.keys(display).sort()).toEqual(['renderCall', 'renderResult', 'renderShell'])
|
|
35
|
+
expect(display.renderShell).toBe('default')
|
|
36
|
+
expect(definition.parameters).toBe(parameters)
|
|
37
|
+
expect(definition.execute).toBe(execute)
|
|
38
|
+
expect(execute).not.toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('respects plugin renderer hooks even when the plugin uses a built-in tool name', () => {
|
|
42
|
+
for (const name of ['read', 'edit', 'bash', 'wiki_recall']) {
|
|
43
|
+
for (const hooks of [{ renderCall: vi.fn() }, { renderResult: vi.fn() }, { renderShell: 'self' as const }]) {
|
|
44
|
+
const next = vi.fn(() => hooks)
|
|
45
|
+
expect(harnessToolRenderers(name, next)).toBe(hooks)
|
|
46
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves Pi rich file, diff and image renderer identities', () => {
|
|
52
|
+
for (const definition of [createReadToolDefinition('/private/tmp'), createEditToolDefinition('/private/tmp')]) {
|
|
53
|
+
const resolved = harnessToolRenderers(definition.name, () => definition as unknown as ToolRenderers)
|
|
54
|
+
expect(resolved).toBe(definition)
|
|
55
|
+
expect(resolved?.renderCall).toBe(definition.renderCall)
|
|
56
|
+
expect(resolved?.renderResult).toBe(definition.renderResult)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows pending, executing, completed and error state from actual rendering context', () => {
|
|
61
|
+
const hooks = generic()
|
|
62
|
+
const states = [
|
|
63
|
+
[{}, '⏳ wiki_recall'],
|
|
64
|
+
[{ executionStarted: true }, '⟳ wiki_recall'],
|
|
65
|
+
[{ isPartial: false }, '• wiki_recall'],
|
|
66
|
+
[{ isPartial: false, isError: true }, '✘ wiki_recall']
|
|
67
|
+
] as const
|
|
68
|
+
for (const [state, expected] of states) {
|
|
69
|
+
const rows = hooks.renderCall!({}, theme, context(state)).render(80)
|
|
70
|
+
expect(plain(rows)[0]).toBe(expected)
|
|
71
|
+
if ('isError' in state && state.isError) expect(rows[0]).toContain(theme.getFgAnsi('error'))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reveals real nested arguments when expanded and keeps partial JSON metadata out of the display', () => {
|
|
76
|
+
const hooks = generic()
|
|
77
|
+
const args = Object.freeze({ query: 'source evidence', filter: { page: ['Introduction', 'Results'] }, __partialJson: 'transport metadata' })
|
|
78
|
+
const collapsed = plain(hooks.renderCall!(args, theme, context()).render(90)).join('\n')
|
|
79
|
+
expect(collapsed).toContain('└─ query="source evidence", filter={1 keys}')
|
|
80
|
+
expect(collapsed).not.toContain('transport metadata')
|
|
81
|
+
const expanded = plain(hooks.renderCall!(args, theme, context({ expanded: true })).render(90)).join('\n')
|
|
82
|
+
expect(expanded).toContain('Args')
|
|
83
|
+
expect(expanded).toContain('page: [2 items]')
|
|
84
|
+
expect(expanded).toContain('0: "Introduction"')
|
|
85
|
+
expect(expanded).toContain('1: "Results"')
|
|
86
|
+
expect(args.filter.page).toEqual(['Introduction', 'Results'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses the returned text verbatim while changing only the output window', () => {
|
|
90
|
+
const hooks = generic()
|
|
91
|
+
const source = result(20)
|
|
92
|
+
const snapshot = structuredClone(source)
|
|
93
|
+
for (const isPartial of [false, true]) {
|
|
94
|
+
const collapsed = plain(hooks.renderResult!(source, { expanded: false, isPartial }, theme, context()).render(80))
|
|
95
|
+
expect(collapsed).toEqual(['source line 0', 'source line 1', 'source line 2', 'source line 3', '… (16 more lines)', '[Ctrl+O: Expand]'])
|
|
96
|
+
const expanded = plain(hooks.renderResult!(source, { expanded: true, isPartial }, theme, context()).render(80))
|
|
97
|
+
expect(expanded).toHaveLength(13)
|
|
98
|
+
expect(expanded[11]).toBe('source line 11')
|
|
99
|
+
expect(expanded[12]).toBe('… (8 more lines)')
|
|
100
|
+
}
|
|
101
|
+
expect(source).toEqual(snapshot)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders JSON trees and preserves mixed image payloads and result metadata', () => {
|
|
105
|
+
const hooks = generic()
|
|
106
|
+
const image = { type: 'image' as const, data: 'original-image-bytes', mimeType: 'image/png' }
|
|
107
|
+
const source = { content: [{ type: 'text' as const, text: '{"pages":[{"title":"Evidence","score":0.8}]}' }, image], details: { citation: 'ev_original' } }
|
|
108
|
+
const rows = plain(hooks.renderResult!(source, { expanded: true, isPartial: false }, theme, context()).render(80))
|
|
109
|
+
expect(rows.join('\n')).toContain('title: "Evidence"')
|
|
110
|
+
expect(rows.join('\n')).toContain('score: 0.8')
|
|
111
|
+
expect(source.content[1]).toBe(image)
|
|
112
|
+
expect(source.content[1]).toEqual(image)
|
|
113
|
+
expect(source.details.citation).toBe('ev_original')
|
|
114
|
+
expect(plain(hooks.renderResult!({ content: [image], details: undefined }, { expanded: false, isPartial: false }, theme, context()).render(80))).toEqual(['(image output)'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('bounds every row by terminal cells while rejecting terminal commands and retaining safe output colors', () => {
|
|
118
|
+
const hooks = generic()
|
|
119
|
+
const source = { content: [{ type: 'text' as const, text: '\x1b]2;do not rename\x07\x1b[2J\x1b[31m日本語 🧠 知識é\x1b[0m\n\tsecond\rline' }], details: undefined }
|
|
120
|
+
for (let width = 0; width < 100; width++) {
|
|
121
|
+
const call = hooks.renderCall!({ 'query\n\x07': '日本語 🧠 知識'.repeat(15) }, theme, context())
|
|
122
|
+
const output = hooks.renderResult!(source, { expanded: false, isPartial: true }, theme, context())
|
|
123
|
+
for (const component of [call, output]) {
|
|
124
|
+
for (const row of component.render(width)) {
|
|
125
|
+
expect(visibleWidth(row), `width ${width}`).toBeLessThanOrEqual(width)
|
|
126
|
+
expect(row).not.toContain('\x1b]')
|
|
127
|
+
expect(row).not.toContain('\x1b[2J')
|
|
128
|
+
expect(stripTerminalSequences(row)).not.toMatch(/[\x00-\x1f\x7f]/)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const colored = hooks.renderResult!(source, { expanded: true, isPartial: false }, theme, context()).render(90)
|
|
133
|
+
expect(colored[0]).toContain('\x1b[31m')
|
|
134
|
+
expect(plain(colored)[0]).toBe('日本語 🧠 知識é')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('bounds deep and cyclic argument trees without hiding their existence', () => {
|
|
138
|
+
const cyclic: Record<string, unknown> = { count: 1 }
|
|
139
|
+
cyclic.self = cyclic
|
|
140
|
+
const rows = plain(generic().renderCall!(cyclic, theme, context({ expanded: true })).render(80))
|
|
141
|
+
expect(rows.join('\n')).toContain('[circular]')
|
|
142
|
+
const many = Object.fromEntries(Array.from({ length: 1000 }, (_, index) => [`key${index}`, index]))
|
|
143
|
+
expect(generic().renderCall!(many, theme, context({ expanded: true })).render(80).length).toBeLessThanOrEqual(205)
|
|
144
|
+
})
|
|
145
|
+
})
|