@xingular/harness 2.1.1 → 3.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 +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -3,7 +3,17 @@ import { stripTerminalSequences } from '@earendil-works/pi-tui'
|
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
5
|
vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
|
|
6
|
+
vi.mock('./piAdapter', async importOriginal => ({
|
|
7
|
+
...await importOriginal<typeof import('./piAdapter')>(),
|
|
8
|
+
loadPiAdapter: vi.fn()
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('./clipboardFiles', async importOriginal => ({
|
|
11
|
+
...await importOriginal<typeof import('./clipboardFiles')>(),
|
|
12
|
+
stageClipboardFiles: vi.fn()
|
|
13
|
+
}))
|
|
6
14
|
import { controlCall } from '../control'
|
|
15
|
+
import { loadPiAdapter, type PiAdapter } from './piAdapter'
|
|
16
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
7
17
|
import { TerminalApplication } from './application'
|
|
8
18
|
import { createTerminalCommands } from './commands'
|
|
9
19
|
import type { TerminalDecision } from './runtime'
|
|
@@ -62,6 +72,54 @@ function setup() {
|
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
describe('the owned terminal application', () => {
|
|
75
|
+
it('pastes staged Finder image paths and removes their temporary files on shutdown', async () => {
|
|
76
|
+
const { application, app, ui } = setup()
|
|
77
|
+
const paths = ['/Photos/portrait.heic', '/work/notes.md']
|
|
78
|
+
const pasteToEditor = vi.fn()
|
|
79
|
+
const shutdownOrder: string[] = []
|
|
80
|
+
const cleanup = vi.fn(async () => { shutdownOrder.push('clipboard files removed') })
|
|
81
|
+
const adapter = {
|
|
82
|
+
builtins: [], setTheme: vi.fn(), withBuiltInRenderers: vi.fn(),
|
|
83
|
+
readClipboardText: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => paths),
|
|
84
|
+
readClipboardImage: vi.fn(async () => null), extensionForImageMimeType: vi.fn(() => null)
|
|
85
|
+
}
|
|
86
|
+
Object.assign(ui, { pasteToEditor })
|
|
87
|
+
vi.mocked(loadPiAdapter).mockResolvedValue(adapter as unknown as PiAdapter)
|
|
88
|
+
vi.mocked(stageClipboardFiles).mockResolvedValue({ paths: ['/tmp/prepared.png', paths[1]!], cleanup })
|
|
89
|
+
app.runtime.dispose.mockImplementation(async () => { shutdownOrder.push('runtime disposed') })
|
|
90
|
+
|
|
91
|
+
await (application as unknown as { pasteClipboard(): Promise<void> }).pasteClipboard()
|
|
92
|
+
|
|
93
|
+
expect(stageClipboardFiles).toHaveBeenCalledWith(paths)
|
|
94
|
+
expect(adapter.readClipboardImage).not.toHaveBeenCalled()
|
|
95
|
+
expect(pasteToEditor).toHaveBeenCalledWith(`/tmp/prepared.png\n${paths[1]}`)
|
|
96
|
+
await application.shutdown()
|
|
97
|
+
expect(cleanup).toHaveBeenCalledOnce()
|
|
98
|
+
expect(shutdownOrder).toEqual(['runtime disposed', 'clipboard files removed'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('leaves the prompt unchanged and reports an error when clipboard staging fails', async () => {
|
|
102
|
+
const { application, app, ui } = setup()
|
|
103
|
+
const message = 'Could not prepare HEIC image "portrait.heic" for the terminal.'
|
|
104
|
+
const adapter = {
|
|
105
|
+
builtins: [], setTheme: vi.fn(), withBuiltInRenderers: vi.fn(),
|
|
106
|
+
readClipboardText: vi.fn(async () => null), readClipboardFilePaths: vi.fn(async () => ['/Photos/portrait.heic']),
|
|
107
|
+
readClipboardImage: vi.fn(async () => null), extensionForImageMimeType: vi.fn(() => null)
|
|
108
|
+
}
|
|
109
|
+
const pasteToEditor = vi.fn()
|
|
110
|
+
const notice = vi.spyOn(app.transcript, 'notice')
|
|
111
|
+
Object.assign(ui, { pasteToEditor })
|
|
112
|
+
vi.mocked(loadPiAdapter).mockResolvedValue(adapter as unknown as PiAdapter)
|
|
113
|
+
vi.mocked(stageClipboardFiles).mockRejectedValueOnce(new Error(message))
|
|
114
|
+
const pasteClipboard = (application as unknown as { pasteClipboard(): Promise<void> }).pasteClipboard.bind(application)
|
|
115
|
+
;(application as unknown as { attempt(action: () => Promise<void>): void }).attempt(() => pasteClipboard())
|
|
116
|
+
|
|
117
|
+
await vi.waitFor(() => expect(notice).toHaveBeenCalledWith(message, 'error'))
|
|
118
|
+
|
|
119
|
+
expect(pasteToEditor).not.toHaveBeenCalled()
|
|
120
|
+
await application.shutdown()
|
|
121
|
+
})
|
|
122
|
+
|
|
65
123
|
it('shows a stable animated verb, then keeps completion status until the next turn', async () => {
|
|
66
124
|
const { application, app } = setup()
|
|
67
125
|
let now = 1000
|
|
@@ -24,7 +24,9 @@ import { createTerminalUi, type TerminalUi } from './ui'
|
|
|
24
24
|
import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from './workingStatus'
|
|
25
25
|
import { HarnessWelcome } from './welcome'
|
|
26
26
|
import { answerTerminalQuestion } from './questions'
|
|
27
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
27
28
|
import type { QuestionAnswer } from '../../sessions/registry'
|
|
29
|
+
import { readHarnessLogSnapshot, type HarnessLogChunk, type HarnessLogInfo } from '../logs'
|
|
28
30
|
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
29
31
|
|
|
30
32
|
const exec = promisify(execFile)
|
|
@@ -113,7 +115,7 @@ export class TerminalApplication {
|
|
|
113
115
|
private failure?: Error
|
|
114
116
|
private finish!: () => void
|
|
115
117
|
private done = new Promise<void>(resolve => { this.finish = resolve })
|
|
116
|
-
private cleanup: (() => void)[] = []
|
|
118
|
+
private cleanup: (() => void | Promise<void>)[] = []
|
|
117
119
|
private finalCleanup: (() => void)[] = []
|
|
118
120
|
private draftSync = Promise.resolve()
|
|
119
121
|
private lastInput = ''
|
|
@@ -190,6 +192,10 @@ export class TerminalApplication {
|
|
|
190
192
|
plan: brief => this.enqueuePlan(brief),
|
|
191
193
|
copyText: copyToClipboard, openExternal,
|
|
192
194
|
changed: this.change,
|
|
195
|
+
logs: { read: () => readHarnessLogSnapshot(
|
|
196
|
+
() => controlCall<HarnessLogInfo>('terminal.logs.info'),
|
|
197
|
+
(snapshotSize, offset) => controlCall<HarnessLogChunk>('terminal.logs.read', { snapshotSize, offset })
|
|
198
|
+
) },
|
|
193
199
|
trajectory: {
|
|
194
200
|
list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
|
|
195
201
|
read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
|
|
@@ -643,7 +649,9 @@ export class TerminalApplication {
|
|
|
643
649
|
const files = await adapter.readClipboardFilePaths()
|
|
644
650
|
if (files?.length) {
|
|
645
651
|
if (files.some(file => /[\x00-\x1f\x7f]/.test(file))) throw new Error('Clipboard file paths contain control characters.')
|
|
646
|
-
|
|
652
|
+
const staged = await stageClipboardFiles(files)
|
|
653
|
+
if (staged.cleanup) this.cleanup.push(staged.cleanup)
|
|
654
|
+
this.ui.pasteToEditor(staged.paths.join('\n'))
|
|
647
655
|
return
|
|
648
656
|
}
|
|
649
657
|
const image = await adapter.readClipboardImage()
|
|
@@ -651,7 +659,7 @@ export class TerminalApplication {
|
|
|
651
659
|
const directory = await mkdtemp(join(tmpdir(), 'harness-clipboard-'))
|
|
652
660
|
const file = join(directory, `${randomUUID()}.${adapter.extensionForImageMimeType(image.mimeType) ?? 'png'}`)
|
|
653
661
|
await writeFile(file, image.bytes, { mode: 0o600 })
|
|
654
|
-
this.cleanup.push(() =>
|
|
662
|
+
this.cleanup.push(() => rm(directory, { recursive: true, force: true }))
|
|
655
663
|
this.ui.pasteToEditor(file)
|
|
656
664
|
return
|
|
657
665
|
}
|
|
@@ -684,12 +692,15 @@ export class TerminalApplication {
|
|
|
684
692
|
this.stopped = true
|
|
685
693
|
protect(() => this.pendingAbort?.abort())
|
|
686
694
|
protect(() => this.ui?.dispose())
|
|
687
|
-
for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
|
|
688
695
|
// Captured while the runtime and its components still exist; written only after the alternate screen is left.
|
|
689
696
|
const scrollback = this.exitScrollback()
|
|
690
697
|
try { await this.runtime?.dispose() }
|
|
691
698
|
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
692
699
|
finally {
|
|
700
|
+
for (const cleanup of this.cleanup.splice(0)) {
|
|
701
|
+
try { await cleanup() }
|
|
702
|
+
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
703
|
+
}
|
|
693
704
|
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
694
705
|
protect(() => this.tui.stop())
|
|
695
706
|
if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { stageClipboardFiles } from './clipboardFiles'
|
|
8
|
+
|
|
9
|
+
const exec = promisify(execFile)
|
|
10
|
+
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
11
|
+
|
|
12
|
+
async function createRoot() {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'harness-heic-test-'))
|
|
14
|
+
return root
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('clipboard image files', () => {
|
|
18
|
+
it('converts HEIC and HEIF paths to private PNGs while preserving order and other paths', async () => {
|
|
19
|
+
const root = await createRoot()
|
|
20
|
+
const convert = vi.fn(async (_source: string, output: string) => { await writeFile(output, pngSignature) })
|
|
21
|
+
const staged = await stageClipboardFiles([
|
|
22
|
+
'/Photos/first photo.HEIC',
|
|
23
|
+
'/work/notes.md',
|
|
24
|
+
'/Photos/second.heif'
|
|
25
|
+
], { platform: 'darwin', tempRoot: root, convert })
|
|
26
|
+
|
|
27
|
+
expect(staged.paths).toHaveLength(3)
|
|
28
|
+
expect(staged.paths[0]).toMatch(/\.png$/)
|
|
29
|
+
expect(staged.paths[1]).toBe('/work/notes.md')
|
|
30
|
+
expect(staged.paths[2]).toMatch(/\.png$/)
|
|
31
|
+
expect(convert.mock.calls.map(([source]) => source)).toEqual(['/Photos/first photo.HEIC', '/Photos/second.heif'])
|
|
32
|
+
expect(await readFile(staged.paths[0])).toEqual(pngSignature)
|
|
33
|
+
expect(await readFile(staged.paths[2])).toEqual(pngSignature)
|
|
34
|
+
|
|
35
|
+
await staged.cleanup?.()
|
|
36
|
+
await expect(readdir(dirname(staged.paths[0]))).rejects.toThrow()
|
|
37
|
+
await rm(root, { recursive: true, force: true })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('leaves clipboard paths unchanged on non-macOS platforms', async () => {
|
|
41
|
+
const convert = vi.fn(async () => {})
|
|
42
|
+
const paths = ['/Photos/portrait.heic', '/work/notes.md']
|
|
43
|
+
|
|
44
|
+
const staged = await stageClipboardFiles(paths, { platform: 'linux', convert })
|
|
45
|
+
|
|
46
|
+
expect(staged.paths).toEqual(paths)
|
|
47
|
+
expect(staged.cleanup).toBeUndefined()
|
|
48
|
+
expect(convert).not.toHaveBeenCalled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('cleans staged files and rejects the whole paste if conversion fails', async () => {
|
|
52
|
+
const root = await createRoot()
|
|
53
|
+
const convert = vi.fn(async (source: string, output: string) => {
|
|
54
|
+
if (source.endsWith('broken.heic')) throw new Error('decode failed')
|
|
55
|
+
await writeFile(output, pngSignature)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
await expect(stageClipboardFiles([
|
|
59
|
+
'/Photos/valid.heic',
|
|
60
|
+
'/Photos/broken.heic'
|
|
61
|
+
], { platform: 'darwin', tempRoot: root, convert })).rejects.toThrow('Could not prepare HEIC image "broken.heic" for the terminal.')
|
|
62
|
+
expect(await readdir(root)).toEqual([])
|
|
63
|
+
await rm(root, { recursive: true, force: true })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('rejects an empty or non-PNG conversion result', async () => {
|
|
67
|
+
const root = await createRoot()
|
|
68
|
+
await expect(stageClipboardFiles(['/Photos/invalid.heic'], {
|
|
69
|
+
platform: 'darwin',
|
|
70
|
+
tempRoot: root,
|
|
71
|
+
convert: async (_source, output) => { await writeFile(output, 'not a PNG') }
|
|
72
|
+
})).rejects.toThrow('Could not prepare HEIC image "invalid.heic" for the terminal.')
|
|
73
|
+
expect(await readdir(root)).toEqual([])
|
|
74
|
+
await rm(root, { recursive: true, force: true })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it.skipIf(process.platform !== 'darwin')('converts a real HEIC file with macOS sips without changing the original', async () => {
|
|
78
|
+
const root = await createRoot()
|
|
79
|
+
const sourcePng = join(root, 'pixel.png')
|
|
80
|
+
const sourceHeic = join(root, 'portrait.heic')
|
|
81
|
+
await writeFile(sourcePng, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'))
|
|
82
|
+
await exec('/usr/bin/sips', ['-s', 'format', 'heic', sourcePng, '--out', sourceHeic])
|
|
83
|
+
const original = await readFile(sourceHeic)
|
|
84
|
+
|
|
85
|
+
const staged = await stageClipboardFiles([sourceHeic], { tempRoot: root })
|
|
86
|
+
expect(staged.paths[0]).not.toBe(sourceHeic)
|
|
87
|
+
expect((await readFile(staged.paths[0]!)).subarray(0, 8)).toEqual(pngSignature)
|
|
88
|
+
expect(await readFile(sourceHeic)).toEqual(original)
|
|
89
|
+
|
|
90
|
+
await staged.cleanup?.()
|
|
91
|
+
expect((await readdir(root)).sort()).toEqual(['pixel.png', 'portrait.heic'])
|
|
92
|
+
await rm(root, { recursive: true, force: true })
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { chmod, mkdtemp, open, rm, stat } from 'node:fs/promises'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { basename, join } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile)
|
|
9
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
10
|
+
|
|
11
|
+
export interface ClipboardFileOptions {
|
|
12
|
+
platform?: NodeJS.Platform
|
|
13
|
+
tempRoot?: string
|
|
14
|
+
convert?: (source: string, output: string) => Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StagedClipboardFiles {
|
|
18
|
+
paths: string[]
|
|
19
|
+
cleanup?: () => Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isHeicPath(path: string): boolean {
|
|
23
|
+
return /\.hei[cf]$/i.test(path)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function convertWithSips(source: string, output: string): Promise<void> {
|
|
27
|
+
await exec('/usr/bin/sips', ['-s', 'format', 'png', source, '--out', output], {
|
|
28
|
+
timeout: 30_000,
|
|
29
|
+
maxBuffer: 1024 * 1024
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function assertPng(path: string): Promise<void> {
|
|
34
|
+
const info = await stat(path)
|
|
35
|
+
if (!info.isFile() || info.size < PNG_SIGNATURE.length) throw new Error('Conversion did not create an image.')
|
|
36
|
+
|
|
37
|
+
const file = await open(path, 'r')
|
|
38
|
+
try {
|
|
39
|
+
const signature = Buffer.alloc(PNG_SIGNATURE.length)
|
|
40
|
+
const { bytesRead } = await file.read(signature, 0, signature.length, 0)
|
|
41
|
+
if (bytesRead !== PNG_SIGNATURE.length || !signature.equals(PNG_SIGNATURE)) throw new Error('Conversion did not create a PNG image.')
|
|
42
|
+
} finally {
|
|
43
|
+
await file.close()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert Finder-copied HEIC/HEIF paths to images that Pi's terminal reader can consume. */
|
|
48
|
+
export async function stageClipboardFiles(paths: string[], options: ClipboardFileOptions = {}): Promise<StagedClipboardFiles> {
|
|
49
|
+
const platform = options.platform ?? process.platform
|
|
50
|
+
if (platform !== 'darwin' || !paths.some(isHeicPath)) return { paths }
|
|
51
|
+
|
|
52
|
+
let directory: string | undefined
|
|
53
|
+
try {
|
|
54
|
+
directory = await mkdtemp(join(options.tempRoot ?? tmpdir(), 'harness-clipboard-'))
|
|
55
|
+
const stagedPaths: string[] = []
|
|
56
|
+
for (const [index, path] of paths.entries()) {
|
|
57
|
+
if (!isHeicPath(path)) {
|
|
58
|
+
stagedPaths.push(path)
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const output = join(directory, `${index}-${randomUUID()}.png`)
|
|
63
|
+
try {
|
|
64
|
+
await (options.convert ?? convertWithSips)(path, output)
|
|
65
|
+
await chmod(output, 0o600)
|
|
66
|
+
await assertPng(output)
|
|
67
|
+
} catch {
|
|
68
|
+
throw new Error(`Could not prepare HEIC image "${basename(path)}" for the terminal.`)
|
|
69
|
+
}
|
|
70
|
+
stagedPaths.push(output)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let cleaned = false
|
|
74
|
+
return {
|
|
75
|
+
paths: stagedPaths,
|
|
76
|
+
cleanup: async () => {
|
|
77
|
+
if (cleaned || !directory) return
|
|
78
|
+
cleaned = true
|
|
79
|
+
await rm(directory, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {})
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -33,6 +33,7 @@ function setup() {
|
|
|
33
33
|
refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
34
34
|
plan: vi.fn(),
|
|
35
35
|
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
|
|
36
|
+
logs: { read: vi.fn(async () => '[harness] diagnostic') },
|
|
36
37
|
trajectory: {
|
|
37
38
|
list: vi.fn(async () => [trajectoryRun]),
|
|
38
39
|
read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
|
|
@@ -59,7 +60,7 @@ describe('owned terminal commands', () => {
|
|
|
59
60
|
it('populates completion from builtins and loaded resources', () => {
|
|
60
61
|
const { commands } = setup()
|
|
61
62
|
const names = commands.completions().map(item => item.name)
|
|
62
|
-
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
|
|
63
|
+
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'log', 'permissions']))
|
|
63
64
|
expect(names).not.toContain('thinking')
|
|
64
65
|
expect(names).not.toContain('skill:diagnose')
|
|
65
66
|
expect(new Set(names).size).toBe(names.length)
|
|
@@ -172,6 +173,17 @@ describe('owned terminal commands', () => {
|
|
|
172
173
|
expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
|
|
173
174
|
})
|
|
174
175
|
|
|
176
|
+
it('opens a full-screen snapshot combining diagnostics and current-conversation run events', async () => {
|
|
177
|
+
const { commands, host, ui } = setup()
|
|
178
|
+
await commands.execute('/log')
|
|
179
|
+
expect(host.logs.read).toHaveBeenCalledOnce()
|
|
180
|
+
expect(host.trajectory.list).toHaveBeenCalledOnce()
|
|
181
|
+
expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
|
|
182
|
+
expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), {
|
|
183
|
+
overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' }
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
175
187
|
it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
|
|
176
188
|
const { commands, host, runtime, ui } = setup()
|
|
177
189
|
vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
|
|
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
|
|
6
6
|
import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
7
7
|
import { SettingsPanel, type TerminalSettingItem } from './settings'
|
|
8
8
|
import { DialogPanel, SearchableSelect } from './dialogs'
|
|
9
|
+
import { LogPanel, formatRunEventLog } from './logPanel'
|
|
9
10
|
import { TrajectoryPanel } from './trajectoryPanel'
|
|
10
11
|
import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
|
|
11
12
|
import manifest from '../../../package.json'
|
|
@@ -30,6 +31,7 @@ export interface TerminalCommandHost {
|
|
|
30
31
|
suspend?(): Promise<void>
|
|
31
32
|
hotkeys?(): string
|
|
32
33
|
changelog?(): Promise<string>
|
|
34
|
+
logs: { read(): Promise<string> }
|
|
33
35
|
trajectory: {
|
|
34
36
|
list(): Promise<TerminalTrajectoryRun[]>
|
|
35
37
|
read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
|
|
@@ -54,6 +56,7 @@ const BUILTINS: TerminalCommandCompletion[] = [
|
|
|
54
56
|
{ name: 'name', description: 'Name the current session' },
|
|
55
57
|
{ name: 'session', description: 'Current session statistics' },
|
|
56
58
|
{ name: 'trajectory', description: 'Inspect recorded runs for this native session' },
|
|
59
|
+
{ name: 'log', description: 'View Harness diagnostics and this conversation run events' },
|
|
57
60
|
{ name: 'compact', description: 'Compact the current context' },
|
|
58
61
|
{ name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
|
|
59
62
|
{ name: 'trust', description: 'Review plugin code and project trust' },
|
|
@@ -230,6 +233,18 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
230
233
|
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
async function logs() {
|
|
237
|
+
const [diagnostics, runs] = await Promise.all([host.logs.read(), host.trajectory.list()])
|
|
238
|
+
const snapshots = await Promise.all(runs.map(run => host.trajectory.read(run.id)))
|
|
239
|
+
const sections = [
|
|
240
|
+
{ title: 'Harness diagnostics', text: diagnostics || 'No Harness diagnostics have been captured by this host.' },
|
|
241
|
+
{ title: 'Conversation run events', text: formatRunEventLog(snapshots) }
|
|
242
|
+
]
|
|
243
|
+
await host.ui.custom<void>((tui, theme, _keys, done) => new LogPanel({
|
|
244
|
+
tui, theme, sections, close: () => done(undefined), changed: host.changed
|
|
245
|
+
}), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' } })
|
|
246
|
+
}
|
|
247
|
+
|
|
233
248
|
async function permissions() {
|
|
234
249
|
for (;;) {
|
|
235
250
|
const runtime = host.runtime()
|
|
@@ -387,6 +402,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
387
402
|
}
|
|
388
403
|
case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
|
|
389
404
|
case 'trajectory': await trajectory(argument); break
|
|
405
|
+
case 'log': await logs(); break
|
|
390
406
|
case 'compact': await session.compact(argument || undefined); break
|
|
391
407
|
case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
|
|
392
408
|
case 'trust': {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import type { RunEvent } from '../../sessions/contracts'
|
|
6
|
+
import type { TerminalTrajectorySnapshot } from '../trajectory'
|
|
7
|
+
import { formatRunEventLog, LogPanel } from './logPanel'
|
|
8
|
+
import { createHarnessTheme } from './theme'
|
|
9
|
+
|
|
10
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
11
|
+
const event = (sequence: number, type: string, payload: unknown): RunEvent => ({
|
|
12
|
+
id: `event-${sequence}`, runId: 'run-1', sessionId: 'terminal-1', sequence,
|
|
13
|
+
timestamp: `2026-10-18T00:00:0${sequence}.000Z`, type: type as RunEvent['type'], payload
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function plain(panel: LogPanel): string {
|
|
17
|
+
return panel.render(80).map(stripTerminalSequences).join('\n')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('terminal log viewer', () => {
|
|
21
|
+
it('shows diagnostic run events without prompt or successful response content', () => {
|
|
22
|
+
const snapshot: TerminalTrajectorySnapshot = {
|
|
23
|
+
run: { id: 'run-1', mode: 'code', goal: 'Private prompt', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:02.000Z', status: 'failed' },
|
|
24
|
+
events: [
|
|
25
|
+
event(0, 'context.snapshot', { systemPrompt: 'Private prompt' }),
|
|
26
|
+
event(1, 'tool.completed', { name: 'web_fetch', ok: true, output: JSON.stringify({ url: 'https://example.com/?token=private', status: 404, error: 'Page missing', text: 'Private response body' }) }),
|
|
27
|
+
event(2, 'failed', { code: 'network', message: 'Failed at https://example.com/path?token=private' })
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
const text = formatRunEventLog([snapshot])
|
|
31
|
+
expect(text).toContain('HTTP 404: Page missing')
|
|
32
|
+
expect(text).toContain('network')
|
|
33
|
+
expect(text).not.toContain('Private prompt')
|
|
34
|
+
expect(text).not.toContain('Private response body')
|
|
35
|
+
expect(text).not.toContain('private')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('opens at the newest lines, pages through the snapshot, switches sources, and closes on Escape', () => {
|
|
39
|
+
const close = vi.fn()
|
|
40
|
+
const changed = vi.fn()
|
|
41
|
+
const panel = new LogPanel({
|
|
42
|
+
tui: { terminal: { rows: 8 } } as unknown as TUI,
|
|
43
|
+
theme,
|
|
44
|
+
sections: [
|
|
45
|
+
{ title: 'Harness diagnostics', text: Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n') },
|
|
46
|
+
{ title: 'Conversation run events', text: 'run event' }
|
|
47
|
+
],
|
|
48
|
+
close, changed
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(plain(panel)).toContain('line 20')
|
|
52
|
+
panel.handleInput('\x1b[5~')
|
|
53
|
+
expect(plain(panel)).toContain('line 11')
|
|
54
|
+
expect(plain(panel)).not.toContain('line 20')
|
|
55
|
+
panel.handleInput('\t')
|
|
56
|
+
expect(plain(panel)).toContain('Conversation run events')
|
|
57
|
+
panel.handleInput('\x1b')
|
|
58
|
+
expect(close).toHaveBeenCalledOnce()
|
|
59
|
+
expect(changed).toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { matchesKey, stripTerminalSequences, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
|
|
4
|
+
import { redactLogText } from '../../core/bus'
|
|
5
|
+
import type { RunEvent } from '../../sessions/contracts'
|
|
6
|
+
import type { TerminalTrajectorySnapshot } from '../trajectory'
|
|
7
|
+
|
|
8
|
+
export interface LogSection {
|
|
9
|
+
title: string
|
|
10
|
+
text: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const payloadOf = (event: RunEvent): Record<string, unknown> =>
|
|
14
|
+
event.payload && typeof event.payload === 'object' ? event.payload as Record<string, unknown> : {}
|
|
15
|
+
const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
|
|
16
|
+
const numberOf = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
17
|
+
|
|
18
|
+
function safe(text: string): string {
|
|
19
|
+
return redactLogText(stripTerminalSequences(text)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').replace(/\t/g, ' ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function timeOf(value: string): string {
|
|
23
|
+
const parsed = Date.parse(value)
|
|
24
|
+
return Number.isFinite(parsed) ? new Date(parsed).toLocaleTimeString() : value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function webError(output: string): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(output) as Record<string, unknown>
|
|
30
|
+
if (typeof value.error !== 'string') return undefined
|
|
31
|
+
const status = numberOf(value.status)
|
|
32
|
+
return `${status === undefined ? '' : `HTTP ${status}: `}${value.error}`
|
|
33
|
+
} catch {
|
|
34
|
+
return /^(?:The page could not be read:|The search could not be completed:)/.test(output) ? output : undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function eventDetail(event: RunEvent): string[] {
|
|
39
|
+
const payload = payloadOf(event)
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case 'context.snapshot':
|
|
42
|
+
return ['prompt and tool definitions omitted']
|
|
43
|
+
case 'message.delta':
|
|
44
|
+
case 'message.completed':
|
|
45
|
+
case 'message.steered':
|
|
46
|
+
return ['message content omitted']
|
|
47
|
+
case 'tool.started':
|
|
48
|
+
return [`tool ${stringOf(payload.name) ?? 'unknown'} started; inputs omitted`]
|
|
49
|
+
case 'tool.completed': {
|
|
50
|
+
const name = stringOf(payload.name) ?? 'unknown'
|
|
51
|
+
const duration = numberOf(payload.durationMs)
|
|
52
|
+
const result = [`tool ${name} ${payload.ok === false ? 'failed' : 'completed'}${duration === undefined ? '' : ` · ${duration} ms`}`]
|
|
53
|
+
const output = stringOf(payload.output)
|
|
54
|
+
const webFailure = (name === 'web_fetch' || name === 'web_search') && output ? webError(output) : undefined
|
|
55
|
+
if (webFailure) result.push(` ${webFailure}`)
|
|
56
|
+
else if (payload.ok === false && output) result.push(` ${output}`)
|
|
57
|
+
if (payload.outputTruncated === true) result.push(' recorded output was truncated')
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
case 'retry.started':
|
|
61
|
+
case 'retry.ended':
|
|
62
|
+
return [`attempt ${numberOf(payload.attempt) ?? '?'}${numberOf(payload.maxAttempts) === undefined ? '' : ` of ${numberOf(payload.maxAttempts)}`}${numberOf(payload.delayMs) === undefined ? '' : ` · retry delay ${numberOf(payload.delayMs)} ms`}`, ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
|
|
63
|
+
case 'compaction.ended':
|
|
64
|
+
return [payload.aborted === true ? 'compaction aborted' : 'compaction completed', ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
|
|
65
|
+
case 'extension.notice':
|
|
66
|
+
return payload.kind === 'error' || payload.level === 'error' ? [stringOf(payload.text) ?? 'extension error'] : ['non-error extension notice omitted']
|
|
67
|
+
case 'account.status': {
|
|
68
|
+
const message = stringOf(payload.message)
|
|
69
|
+
return [stringOf(payload.category) ?? 'account status', ...(message ? [message] : [])]
|
|
70
|
+
}
|
|
71
|
+
case 'status.changed': {
|
|
72
|
+
const reason = stringOf(payload.reason)
|
|
73
|
+
return [stringOf(payload.status) ?? 'status changed', ...(reason ? [reason] : [])]
|
|
74
|
+
}
|
|
75
|
+
case 'failed': {
|
|
76
|
+
const message = stringOf(payload.message)
|
|
77
|
+
return [stringOf(payload.code) ?? 'run failed', ...(message ? [message] : [])]
|
|
78
|
+
}
|
|
79
|
+
case 'usage.updated': {
|
|
80
|
+
const request = payload.request && typeof payload.request === 'object' ? payload.request as Record<string, unknown> : {}
|
|
81
|
+
return [`${stringOf(request.provider) ?? stringOf(payload.provider) ?? 'provider'}/${stringOf(request.model) ?? stringOf(payload.model) ?? 'model'} · request ${numberOf(request.index) ?? numberOf(payload.requests) ?? '?'}`]
|
|
82
|
+
}
|
|
83
|
+
case 'approval.requested':
|
|
84
|
+
case 'approval.resolved':
|
|
85
|
+
return [`tool ${stringOf(payload.target) ?? ''} · decision ${stringOf(payload.decision) ?? 'pending'}; action details omitted`]
|
|
86
|
+
case 'question.requested':
|
|
87
|
+
case 'question.answered':
|
|
88
|
+
return ['question content omitted']
|
|
89
|
+
default:
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatRunEventLog(snapshots: TerminalTrajectorySnapshot[]): string {
|
|
95
|
+
if (!snapshots.length) return 'No run events recorded for this conversation.'
|
|
96
|
+
const sections: string[] = []
|
|
97
|
+
for (const snapshot of snapshots) {
|
|
98
|
+
sections.push(`Run ${snapshot.run.id} · ${snapshot.run.status} · ${snapshot.run.mode}`)
|
|
99
|
+
const events = snapshot.events.slice().sort((left, right) => left.sequence - right.sequence)
|
|
100
|
+
if (!events.length) sections.push(' No recorded events')
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
sections.push(`${timeOf(event.timestamp)} #${event.sequence} ${event.type}`)
|
|
103
|
+
sections.push(...eventDetail(event).map(detail => ` ${detail}`))
|
|
104
|
+
}
|
|
105
|
+
sections.push('')
|
|
106
|
+
}
|
|
107
|
+
return safe(sections.join('\n').trimEnd())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LogPanelOptions {
|
|
111
|
+
tui: TUI
|
|
112
|
+
theme: Theme
|
|
113
|
+
sections: LogSection[]
|
|
114
|
+
close(): void
|
|
115
|
+
changed(): void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A static, full-screen view of the diagnostic snapshot taken when /log was opened. */
|
|
119
|
+
export class LogPanel implements Component, Focusable {
|
|
120
|
+
private source = 0
|
|
121
|
+
private offset = 0
|
|
122
|
+
private atEnd = true
|
|
123
|
+
private maxOffset = 0
|
|
124
|
+
private focusedValue = false
|
|
125
|
+
|
|
126
|
+
constructor(private readonly options: LogPanelOptions) {}
|
|
127
|
+
|
|
128
|
+
get focused(): boolean { return this.focusedValue }
|
|
129
|
+
set focused(value: boolean) { this.focusedValue = value }
|
|
130
|
+
invalidate(): void {}
|
|
131
|
+
|
|
132
|
+
private changeSource(direction: number): void {
|
|
133
|
+
const count = this.options.sections.length
|
|
134
|
+
if (!count) return
|
|
135
|
+
this.source = (this.source + direction + count) % count
|
|
136
|
+
this.offset = 0
|
|
137
|
+
this.atEnd = true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleInput(data: string): void {
|
|
141
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.options.close(); return }
|
|
142
|
+
if (matchesKey(data, 'left') || matchesKey(data, 'shift+tab')) this.changeSource(-1)
|
|
143
|
+
else if (matchesKey(data, 'right') || matchesKey(data, 'tab')) this.changeSource(1)
|
|
144
|
+
else if (matchesKey(data, 'home')) { this.offset = 0; this.atEnd = false }
|
|
145
|
+
else if (matchesKey(data, 'end')) this.atEnd = true
|
|
146
|
+
else if (matchesKey(data, 'up')) { this.offset = Math.max(0, this.currentOffset() - 1); this.atEnd = false }
|
|
147
|
+
else if (matchesKey(data, 'down')) { this.offset = this.currentOffset() + 1; this.atEnd = false }
|
|
148
|
+
else if (matchesKey(data, 'pageUp')) { this.offset = Math.max(0, this.currentOffset() - this.pageSize()); this.atEnd = false }
|
|
149
|
+
else if (matchesKey(data, 'pageDown')) { this.offset = this.currentOffset() + this.pageSize(); this.atEnd = false }
|
|
150
|
+
this.options.changed()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private pageSize(): number { return Math.max(1, this.options.tui.terminal.rows - 4) }
|
|
154
|
+
private currentOffset(): number { return this.atEnd ? this.maxOffset : this.offset }
|
|
155
|
+
|
|
156
|
+
render(width: number): string[] {
|
|
157
|
+
width = Math.max(1, Math.trunc(width))
|
|
158
|
+
const rows = Math.max(1, Math.trunc(this.options.tui.terminal.rows))
|
|
159
|
+
const section = this.options.sections[this.source] ?? { title: 'Logs', text: 'No log sources are available.' }
|
|
160
|
+
const bodyRows = Math.max(0, rows - 2)
|
|
161
|
+
const contentWidth = Math.max(1, width)
|
|
162
|
+
const raw = safe(section.text).split('\n')
|
|
163
|
+
const lines = raw.flatMap(line => line ? wrapTextWithAnsi(line, contentWidth) : [''])
|
|
164
|
+
const maxOffset = Math.max(0, lines.length - bodyRows)
|
|
165
|
+
this.maxOffset = maxOffset
|
|
166
|
+
if (this.atEnd) this.offset = maxOffset
|
|
167
|
+
else this.offset = Math.max(0, Math.min(maxOffset, this.offset))
|
|
168
|
+
const first = this.offset
|
|
169
|
+
const last = Math.min(lines.length, first + bodyRows)
|
|
170
|
+
const position = `${this.source + 1}/${Math.max(1, this.options.sections.length)} · lines ${lines.length ? first + 1 : 0}-${last}/${lines.length}`
|
|
171
|
+
const title = this.options.theme.bold(this.options.theme.fg('accent', `${section.title} · ${position}`))
|
|
172
|
+
const footer = this.options.theme.fg('dim', '↑/↓ scroll · PgUp/PgDn page · ←/→ source · Esc close')
|
|
173
|
+
return [title, ...lines.slice(first, last), footer].slice(0, rows).map(line => truncateToWidth(line, width, ''))
|
|
174
|
+
}
|
|
175
|
+
}
|