@xingular/harness 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,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
+ })