@raidou/pi-pm-subagents 0.1.1
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/.prettierrc +7 -0
- package/AGENTS.md +1 -0
- package/README.md +85 -0
- package/README.zh-CN.md +85 -0
- package/agents/explorer.md +10 -0
- package/agents/planner.md +16 -0
- package/agents/researcher.md +22 -0
- package/agents/reviewer.md +10 -0
- package/eslint.config.mjs +14 -0
- package/example-prompts/coordinator.md +21 -0
- package/package.json +48 -0
- package/pm-subagents-prompts/coordinator.md +16 -0
- package/pnpm-workspace.yaml +5 -0
- package/src/bash-readonly.test.ts +331 -0
- package/src/bash-readonly.ts +205 -0
- package/src/coordinator/coordinator.test.ts +28 -0
- package/src/coordinator/coordinator.ts +275 -0
- package/src/custom-select.test.ts +91 -0
- package/src/custom-select.ts +209 -0
- package/src/index.ts +87 -0
- package/src/models-config/models-config.test.ts +88 -0
- package/src/models-config/models-config.ts +205 -0
- package/src/models-config/scoped-models-editor.test.ts +189 -0
- package/src/models-config/scoped-models-editor.ts +412 -0
- package/src/models-config/subagent-model-constants.ts +2 -0
- package/src/models-config/subagent-model-cycle.ts +53 -0
- package/src/models-config/subagent-model-utils.test.ts +250 -0
- package/src/models-config/subagent-model-utils.ts +52 -0
- package/src/pm-mode.test.ts +324 -0
- package/src/pm-mode.ts +142 -0
- package/src/prompts/mode.test.ts +289 -0
- package/src/prompts/mode.ts +31 -0
- package/src/prompts/roles.test.ts +724 -0
- package/src/prompts/roles.ts +119 -0
- package/src/subagent/activity.test.ts +230 -0
- package/src/subagent/activity.ts +60 -0
- package/src/subagent/batcher.test.ts +198 -0
- package/src/subagent/batcher.ts +51 -0
- package/src/subagent/consts.ts +1 -0
- package/src/subagent/demo.ts +773 -0
- package/src/subagent/fleet.test.ts +1758 -0
- package/src/subagent/fleet.ts +376 -0
- package/src/subagent/identity.test.ts +31 -0
- package/src/subagent/identity.ts +16 -0
- package/src/subagent/manager.test.ts +392 -0
- package/src/subagent/manager.ts +277 -0
- package/src/subagent/tools.ts +314 -0
- package/src/subagent/viewer.ts +305 -0
- package/src/types.ts +15 -0
- package/src/ui/border-view.ts +50 -0
- package/src/ui/review-pager.ts +146 -0
- package/src/ui/scroll-view.test.ts +190 -0
- package/src/ui/scroll-view.ts +155 -0
- package/src/utils/format.test.ts +76 -0
- package/src/utils/format.ts +67 -0
- package/src/utils/fs.ts +9 -0
- package/src/utils/markdown.test.ts +442 -0
- package/src/utils/markdown.ts +79 -0
- package/src/utils/messages.test.ts +436 -0
- package/src/utils/messages.ts +131 -0
- package/src/utils/model-ref.test.ts +42 -0
- package/src/utils/model-ref.ts +44 -0
- package/src/utils/state.test.ts +98 -0
- package/src/utils/state.ts +45 -0
- package/src/utils/tools.ts +48 -0
- package/src/utils/truncate.test.ts +41 -0
- package/src/utils/truncate.ts +59 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionContext,
|
|
3
|
+
getMarkdownTheme,
|
|
4
|
+
} from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Markdown, matchesKey } from '@earendil-works/pi-tui'
|
|
6
|
+
|
|
7
|
+
import { truncateText } from '../utils/truncate.js'
|
|
8
|
+
import { BorderView } from './border-view.js'
|
|
9
|
+
import { ScrollView } from './scroll-view.js'
|
|
10
|
+
|
|
11
|
+
const VIEWPORT_HEIGHT_PCT = 80
|
|
12
|
+
const OVERLAY_WIDTH_PCT = '90%'
|
|
13
|
+
|
|
14
|
+
export interface ReviewChoice {
|
|
15
|
+
readonly id: string
|
|
16
|
+
readonly label: string
|
|
17
|
+
readonly action: () => Promise<void> | void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReviewPagerOptions {
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly plan: string
|
|
23
|
+
readonly choices: readonly ReviewChoice[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderReviewPager(
|
|
27
|
+
ctx: ExtensionContext,
|
|
28
|
+
options: ReviewPagerOptions,
|
|
29
|
+
): Promise<string | undefined> {
|
|
30
|
+
return ctx.ui.custom<string | undefined>(
|
|
31
|
+
(tui, theme, _keybindings, done) => {
|
|
32
|
+
const { title, plan, choices } = options
|
|
33
|
+
const chromeLines = choices.length + 3
|
|
34
|
+
let selected = 0
|
|
35
|
+
const markdown = new Markdown(plan, 0, 0, getMarkdownTheme())
|
|
36
|
+
|
|
37
|
+
const scroll = new ScrollView(tui, theme, {
|
|
38
|
+
child: markdown,
|
|
39
|
+
viewportHeight: () =>
|
|
40
|
+
Math.max(
|
|
41
|
+
3,
|
|
42
|
+
Math.floor((tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100) -
|
|
43
|
+
chromeLines -
|
|
44
|
+
3,
|
|
45
|
+
),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const component = {
|
|
49
|
+
render(width: number) {
|
|
50
|
+
const rows = scroll.render(width)
|
|
51
|
+
return [
|
|
52
|
+
theme.fg('accent', theme.bold(title)),
|
|
53
|
+
...rows,
|
|
54
|
+
theme.fg('muted', '─'.repeat(width)),
|
|
55
|
+
...choices.map((choice, index) =>
|
|
56
|
+
index === selected
|
|
57
|
+
? theme.fg('accent', `→ ${choice.label}`)
|
|
58
|
+
: ` ${choice.label}`,
|
|
59
|
+
),
|
|
60
|
+
theme.fg('muted', '─'.repeat(width)),
|
|
61
|
+
this.footerLine(width),
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
footerLine(width: number) {
|
|
65
|
+
const th = theme
|
|
66
|
+
const sep = th.fg('dim', ' · ')
|
|
67
|
+
const keys: [string, string][] = []
|
|
68
|
+
keys.push(
|
|
69
|
+
['↑↓', 'select'],
|
|
70
|
+
[`1-${choices.length}`, 'jump'],
|
|
71
|
+
['j/k line', 'line'],
|
|
72
|
+
['u/d ␣', 'PageUp/Dn page'],
|
|
73
|
+
['g/G', 'Home/End jump'],
|
|
74
|
+
['Enter', 'confirm'],
|
|
75
|
+
['q/esc', 'cancel'],
|
|
76
|
+
)
|
|
77
|
+
return truncateText(
|
|
78
|
+
keys
|
|
79
|
+
.map(
|
|
80
|
+
([key, desc]) =>
|
|
81
|
+
`${th.fg('syntaxKeyword', key)} ${th.fg('success', desc)}`,
|
|
82
|
+
)
|
|
83
|
+
.join(sep),
|
|
84
|
+
width,
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
handleInput(data: string) {
|
|
88
|
+
const index = /^[1-9]$/.test(data) ? Number(data) - 1 : -1
|
|
89
|
+
if (index >= 0 && choices[index] !== undefined) {
|
|
90
|
+
done(choices[index].id)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (matchesKey(data, 'up')) {
|
|
94
|
+
selected = Math.max(0, selected - 1)
|
|
95
|
+
tui.requestRender()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
if (matchesKey(data, 'down')) {
|
|
99
|
+
selected = Math.min(choices.length - 1, selected + 1)
|
|
100
|
+
tui.requestRender()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (matchesKey(data, 'enter')) {
|
|
104
|
+
const chosen = choices[selected]
|
|
105
|
+
if (chosen) done(chosen.id)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (matchesKey(data, 'escape') || data === 'q') {
|
|
109
|
+
done(undefined)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
scroll.handleInput(data)
|
|
113
|
+
},
|
|
114
|
+
invalidate() {
|
|
115
|
+
scroll.invalidate()
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return new BorderView(theme, { child: component })
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
overlay: true,
|
|
123
|
+
overlayOptions: {
|
|
124
|
+
anchor: 'center',
|
|
125
|
+
width: OVERLAY_WIDTH_PCT,
|
|
126
|
+
maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function askHowToProceed(
|
|
133
|
+
ctx: ExtensionContext,
|
|
134
|
+
options: ReviewPagerOptions,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
ctx.ui.setWorkingVisible(false)
|
|
137
|
+
try {
|
|
138
|
+
const choiceId = await renderReviewPager(ctx, options)
|
|
139
|
+
const choice = options.choices.find((c) => c.id === choiceId)
|
|
140
|
+
if (choice) {
|
|
141
|
+
await choice.action()
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
ctx.ui.setWorkingVisible(true)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { ScrollView } from './scroll-view.js'
|
|
6
|
+
|
|
7
|
+
const UP = '\x1b[A'
|
|
8
|
+
const DOWN = '\x1b[B'
|
|
9
|
+
const HOME = '\x1b[H'
|
|
10
|
+
const END = '\x1b[F'
|
|
11
|
+
const PAGE_UP = '\x1b[5~'
|
|
12
|
+
const PAGE_DOWN = '\x1b[6~'
|
|
13
|
+
const CTRL_D = '\x04'
|
|
14
|
+
const CTRL_U = '\x15'
|
|
15
|
+
|
|
16
|
+
const theme = {
|
|
17
|
+
fg: (color: string, text: string) => `[${color}]${text}`,
|
|
18
|
+
} as unknown as Theme
|
|
19
|
+
|
|
20
|
+
const makeTui = (rows = 30): TUI =>
|
|
21
|
+
({ terminal: { rows }, requestRender: () => {} }) as unknown as TUI
|
|
22
|
+
|
|
23
|
+
const flat = (lines: string[]) => lines.join('\n')
|
|
24
|
+
|
|
25
|
+
const many = (n: number) => Array.from({ length: n }, (_, i) => `l${i}`)
|
|
26
|
+
|
|
27
|
+
/** Build a scroll view wrapping fixed `content` with a fixed viewport height. */
|
|
28
|
+
const wrap = (content: string[], viewportHeight: number, autoFollow = false) =>
|
|
29
|
+
new ScrollView(makeTui(), theme, {
|
|
30
|
+
child: { render: () => content, invalidate: () => {} },
|
|
31
|
+
viewportHeight: () => viewportHeight,
|
|
32
|
+
autoFollow,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('ScrollView render', () => {
|
|
36
|
+
it('shows every line when content fits the viewport', () => {
|
|
37
|
+
const v = wrap(['a', 'b', 'c'], 10)
|
|
38
|
+
const out = v.render(20)
|
|
39
|
+
expect(out).toHaveLength(3)
|
|
40
|
+
expect(flat(out)).toContain('a')
|
|
41
|
+
expect(flat(out)).toContain('c')
|
|
42
|
+
expect(flat(out)).not.toContain('[muted]█')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('caps the window at the viewport when content overflows', () => {
|
|
46
|
+
const v = wrap(many(50), 5)
|
|
47
|
+
const out = v.render(20)
|
|
48
|
+
expect(out).toHaveLength(5)
|
|
49
|
+
expect(flat(out)).toContain('l0')
|
|
50
|
+
expect(flat(out)).not.toContain('l5')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('truncates over-wide lines to width - 2', () => {
|
|
54
|
+
const v = wrap(['x'.repeat(100)], 5)
|
|
55
|
+
const body = v.render(20).find((l) => l.includes('x'))
|
|
56
|
+
expect(body).toBeTruthy()
|
|
57
|
+
expect(body).not.toContain('x'.repeat(19))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('exposes offset / total / viewportHeight after rendering', () => {
|
|
61
|
+
const v = wrap(many(50), 5)
|
|
62
|
+
v.render(20)
|
|
63
|
+
expect(v.total).toBe(50)
|
|
64
|
+
expect(v.viewportHeight).toBe(5)
|
|
65
|
+
expect(v.offset).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('ScrollView scrolling', () => {
|
|
70
|
+
it('scrolls line by line with j / k and arrows', () => {
|
|
71
|
+
const v = wrap(many(50), 5)
|
|
72
|
+
v.render(20)
|
|
73
|
+
v.handleInput('j')
|
|
74
|
+
expect(flat(v.render(20))).not.toContain('l0')
|
|
75
|
+
v.handleInput('k')
|
|
76
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
77
|
+
v.handleInput(DOWN)
|
|
78
|
+
expect(flat(v.render(20))).not.toContain('l0')
|
|
79
|
+
v.handleInput(UP)
|
|
80
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('jumps to top and bottom with g / G and home / end', () => {
|
|
84
|
+
const v = wrap(many(50), 5)
|
|
85
|
+
v.render(20)
|
|
86
|
+
v.handleInput(END)
|
|
87
|
+
expect(flat(v.render(20))).toContain('l49')
|
|
88
|
+
v.handleInput(HOME)
|
|
89
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
90
|
+
v.handleInput('G')
|
|
91
|
+
expect(flat(v.render(20))).toContain('l49')
|
|
92
|
+
v.handleInput('g')
|
|
93
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('pages with d / u, space, ctrl-d / ctrl-u and PgUp / PgDn', () => {
|
|
97
|
+
const v = wrap(many(50), 5)
|
|
98
|
+
v.render(20)
|
|
99
|
+
v.handleInput('d')
|
|
100
|
+
expect(flat(v.render(20))).not.toContain('l0')
|
|
101
|
+
v.handleInput('u')
|
|
102
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
103
|
+
v.handleInput(' ')
|
|
104
|
+
expect(flat(v.render(20))).not.toContain('l0')
|
|
105
|
+
v.handleInput(CTRL_U)
|
|
106
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
107
|
+
v.handleInput(CTRL_D)
|
|
108
|
+
v.handleInput(PAGE_UP)
|
|
109
|
+
expect(flat(v.render(20))).toContain('l0')
|
|
110
|
+
v.handleInput(PAGE_DOWN)
|
|
111
|
+
expect(flat(v.render(20))).not.toContain('l0')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('clamps at both ends', () => {
|
|
115
|
+
const v = wrap(many(50), 5)
|
|
116
|
+
v.render(20)
|
|
117
|
+
v.handleInput(UP)
|
|
118
|
+
expect(v.offset).toBe(0)
|
|
119
|
+
v.handleInput('G')
|
|
120
|
+
v.handleInput('j')
|
|
121
|
+
expect(v.offset).toBe(45)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('ScrollView input forwarding', () => {
|
|
126
|
+
it('forwards non-scroll input to the child', () => {
|
|
127
|
+
const received: string[] = []
|
|
128
|
+
const v = new ScrollView(makeTui(), theme, {
|
|
129
|
+
child: {
|
|
130
|
+
render: () => [],
|
|
131
|
+
invalidate: () => {},
|
|
132
|
+
handleInput: (d: string) => {
|
|
133
|
+
received.push(d)
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
viewportHeight: () => 5,
|
|
137
|
+
})
|
|
138
|
+
v.render(20)
|
|
139
|
+
v.handleInput('z')
|
|
140
|
+
v.handleInput('\r')
|
|
141
|
+
expect(received).toEqual(['z', '\r'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('keeps scroll keys for itself (not forwarded)', () => {
|
|
145
|
+
const received: string[] = []
|
|
146
|
+
const v = new ScrollView(makeTui(), theme, {
|
|
147
|
+
child: {
|
|
148
|
+
render: () => many(50),
|
|
149
|
+
invalidate: () => {},
|
|
150
|
+
handleInput: (d: string) => {
|
|
151
|
+
received.push(d)
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
viewportHeight: () => 5,
|
|
155
|
+
})
|
|
156
|
+
v.render(20)
|
|
157
|
+
v.handleInput('j')
|
|
158
|
+
v.handleInput('G')
|
|
159
|
+
expect(received).toEqual([])
|
|
160
|
+
expect(v.offset).toBe(45)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('ScrollView auto-follow', () => {
|
|
165
|
+
it('pins to the latest content while following', () => {
|
|
166
|
+
const content = ['a', 'b']
|
|
167
|
+
const v = new ScrollView(makeTui(), theme, {
|
|
168
|
+
child: { render: () => content, invalidate: () => {} },
|
|
169
|
+
viewportHeight: () => 5,
|
|
170
|
+
autoFollow: true,
|
|
171
|
+
})
|
|
172
|
+
v.render(20)
|
|
173
|
+
content.splice(0, content.length, ...many(50))
|
|
174
|
+
expect(flat(v.render(20))).toContain('l49')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('stops following once you scroll above the bottom', () => {
|
|
178
|
+
const content = many(50)
|
|
179
|
+
const v = new ScrollView(makeTui(), theme, {
|
|
180
|
+
child: { render: () => content, invalidate: () => {} },
|
|
181
|
+
viewportHeight: () => 5,
|
|
182
|
+
autoFollow: true,
|
|
183
|
+
})
|
|
184
|
+
v.render(20)
|
|
185
|
+
v.handleInput(UP)
|
|
186
|
+
v.render(20)
|
|
187
|
+
content.splice(0, content.length, ...many(80))
|
|
188
|
+
expect(flat(v.render(20))).not.toContain('l79')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { Component, TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { Key, matchesKey } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
import { truncateText } from '../utils/truncate.js'
|
|
6
|
+
|
|
7
|
+
export interface ScrollViewOptions {
|
|
8
|
+
/** The content this view wraps: any pi-tui Component (Markdown, Container, …). */
|
|
9
|
+
readonly child: Component
|
|
10
|
+
/** Rows available to the scroll area — the parent reserves chrome outside. */
|
|
11
|
+
readonly viewportHeight: () => number
|
|
12
|
+
/** Pin to new content as it arrives (streaming viewers). Default false. */
|
|
13
|
+
readonly autoFollow?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A scrollable viewport that wraps a content source. Implements the pi-tui
|
|
18
|
+
* `Component` interface: {@link ScrollView} owns the offset (with auto-follow),
|
|
19
|
+
* a proportional scrollbar, and the scroll keybindings, and renders nothing
|
|
20
|
+
* but the visible window of its child plus the bar. Headers, footers, choice
|
|
21
|
+
* lists and hints live outside, in the parent component that holds this one.
|
|
22
|
+
*/
|
|
23
|
+
export class ScrollView implements Component {
|
|
24
|
+
#offset = 0
|
|
25
|
+
#autoFollow: boolean
|
|
26
|
+
#total = 0
|
|
27
|
+
#viewportHeight = 0
|
|
28
|
+
readonly #tui: TUI
|
|
29
|
+
readonly #theme: Theme
|
|
30
|
+
readonly #child: Component
|
|
31
|
+
readonly #viewportHeightFor: () => number
|
|
32
|
+
|
|
33
|
+
constructor(tui: TUI, theme: Theme, options: ScrollViewOptions) {
|
|
34
|
+
this.#tui = tui
|
|
35
|
+
this.#theme = theme
|
|
36
|
+
this.#child = options.child
|
|
37
|
+
this.#viewportHeightFor = options.viewportHeight
|
|
38
|
+
this.#autoFollow = options.autoFollow ?? false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Top-most visible line after the last render. */
|
|
42
|
+
get offset(): number {
|
|
43
|
+
return this.#offset
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Total content lines after the last render. */
|
|
47
|
+
get total(): number {
|
|
48
|
+
return this.#total
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Viewport height used by the last render. */
|
|
52
|
+
get viewportHeight(): number {
|
|
53
|
+
return this.#viewportHeight
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
render(width: number): string[] {
|
|
57
|
+
if (width < 4) return []
|
|
58
|
+
const contentWidth = Math.max(1, width - 2)
|
|
59
|
+
const lines = this.#child.render(contentWidth)
|
|
60
|
+
this.#total = lines.length
|
|
61
|
+
this.#viewportHeight = Math.max(0, this.#viewportHeightFor())
|
|
62
|
+
this.#offset = this.#clamp(this.#offset)
|
|
63
|
+
if (this.#autoFollow) this.#offset = this.#maxOffset
|
|
64
|
+
|
|
65
|
+
const rows = Math.min(
|
|
66
|
+
this.#viewportHeight,
|
|
67
|
+
Math.max(0, lines.length - this.#offset),
|
|
68
|
+
)
|
|
69
|
+
const bar = this.#column(rows)
|
|
70
|
+
const track = this.#theme.fg('dim', '│')
|
|
71
|
+
const out: string[] = []
|
|
72
|
+
for (let i = 0; i < rows; i++) {
|
|
73
|
+
const line = truncateText(
|
|
74
|
+
lines[this.#offset + i] ?? '',
|
|
75
|
+
contentWidth,
|
|
76
|
+
undefined,
|
|
77
|
+
true,
|
|
78
|
+
)
|
|
79
|
+
out.push(`${line} ${bar[i] ?? track}`)
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
handleInput(data: string): void {
|
|
85
|
+
if (this.#viewportHeight === 0) return // not laid out yet
|
|
86
|
+
const before = this.#offset
|
|
87
|
+
if (matchesKey(data, Key.up) || data === 'k') this.#moveTo(this.#offset - 1)
|
|
88
|
+
else if (matchesKey(data, Key.down) || data === 'j')
|
|
89
|
+
this.#moveTo(this.#offset + 1)
|
|
90
|
+
else if (
|
|
91
|
+
data === 'u' ||
|
|
92
|
+
matchesKey(data, Key.ctrl('u')) ||
|
|
93
|
+
matchesKey(data, Key.pageUp)
|
|
94
|
+
)
|
|
95
|
+
this.#moveTo(this.#offset - this.#viewportHeight)
|
|
96
|
+
else if (
|
|
97
|
+
data === 'd' ||
|
|
98
|
+
data === ' ' ||
|
|
99
|
+
matchesKey(data, Key.ctrl('d')) ||
|
|
100
|
+
matchesKey(data, Key.pageDown)
|
|
101
|
+
)
|
|
102
|
+
this.#moveTo(this.#offset + this.#viewportHeight)
|
|
103
|
+
else if (data === 'g' || matchesKey(data, Key.home)) this.#moveTo(0)
|
|
104
|
+
else if (data === 'G' || matchesKey(data, Key.end))
|
|
105
|
+
this.#moveTo(this.#maxOffset)
|
|
106
|
+
else if (this.#child.handleInput) {
|
|
107
|
+
// non-scroll input goes to the wrapped child (it may be interactive)
|
|
108
|
+
this.#child.handleInput(data)
|
|
109
|
+
this.#tui.requestRender()
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
if (this.#offset !== before) this.#tui.requestRender()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
invalidate(): void {
|
|
116
|
+
this.#child.invalidate()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get #maxOffset(): number {
|
|
120
|
+
return Math.max(0, this.#total - this.#viewportHeight)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#clamp(value: number): number {
|
|
124
|
+
return Math.max(0, Math.min(value, this.#maxOffset))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#moveTo(value: number): void {
|
|
128
|
+
this.#offset = this.#clamp(value)
|
|
129
|
+
this.#autoFollow = this.#offset >= this.#maxOffset
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#column(rows: number): string[] {
|
|
133
|
+
const max = this.#maxOffset
|
|
134
|
+
const size =
|
|
135
|
+
max > 0
|
|
136
|
+
? Math.max(
|
|
137
|
+
1,
|
|
138
|
+
Math.round(
|
|
139
|
+
(this.#viewportHeight / this.#total) * this.#viewportHeight,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
: 0
|
|
143
|
+
const start =
|
|
144
|
+
max > 0
|
|
145
|
+
? Math.round((this.#offset / max) * (this.#viewportHeight - size))
|
|
146
|
+
: 0
|
|
147
|
+
const thumb = this.#theme.fg('muted', '█')
|
|
148
|
+
const track = this.#theme.fg('dim', '│')
|
|
149
|
+
const column: string[] = []
|
|
150
|
+
for (let i = 0; i < rows; i++) {
|
|
151
|
+
column.push(i >= start && i < start + size ? thumb : track)
|
|
152
|
+
}
|
|
153
|
+
return column
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { truncateToBytes } from './format.js'
|
|
4
|
+
|
|
5
|
+
describe('truncateToBytes', () => {
|
|
6
|
+
const defaultSuffix = '\n\n[Output truncated.]'
|
|
7
|
+
|
|
8
|
+
it('returns original text when under byte limit', () => {
|
|
9
|
+
const text = 'Hello world'
|
|
10
|
+
const result = truncateToBytes(text, 100)
|
|
11
|
+
expect(result).toBe(text)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('truncates text when over byte limit and adds suffix', () => {
|
|
15
|
+
const text = 'a'.repeat(100)
|
|
16
|
+
const result = truncateToBytes(text, 50)
|
|
17
|
+
const suffixBytes = Buffer.byteLength(defaultSuffix, 'utf8')
|
|
18
|
+
const budget = Math.max(0, 50 - suffixBytes)
|
|
19
|
+
expect(result.length).toBe(budget + defaultSuffix.length)
|
|
20
|
+
expect(result.endsWith(defaultSuffix)).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('truncates text to budget bytes when limit is small', () => {
|
|
24
|
+
const text = 'Hello world'
|
|
25
|
+
const maxBytes = 5
|
|
26
|
+
const suffixBytes = Buffer.byteLength(defaultSuffix, 'utf8')
|
|
27
|
+
const budget = Math.max(0, maxBytes - suffixBytes)
|
|
28
|
+
const result = truncateToBytes(text, maxBytes)
|
|
29
|
+
expect(result.length).toBe(budget + defaultSuffix.length)
|
|
30
|
+
expect(result.endsWith(defaultSuffix)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles multibyte characters correctly', () => {
|
|
34
|
+
const text = '你好世界你好世界你好世界你好世界你好世界'
|
|
35
|
+
const maxBytes = 30
|
|
36
|
+
const suffixBytes = Buffer.byteLength(defaultSuffix, 'utf8')
|
|
37
|
+
const result = truncateToBytes(text, maxBytes)
|
|
38
|
+
expect(result.endsWith(defaultSuffix)).toBe(true)
|
|
39
|
+
const headBytes = Buffer.byteLength(
|
|
40
|
+
result.slice(0, result.length - defaultSuffix.length),
|
|
41
|
+
'utf8',
|
|
42
|
+
)
|
|
43
|
+
expect(headBytes).toBe(Math.max(0, maxBytes - suffixBytes))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('handles mixed ASCII and multibyte characters', () => {
|
|
47
|
+
const text = 'Hello你好World你好Test你好Data'
|
|
48
|
+
const maxBytes = 20
|
|
49
|
+
const suffixBytes = Buffer.byteLength(defaultSuffix, 'utf8')
|
|
50
|
+
const result = truncateToBytes(text, maxBytes)
|
|
51
|
+
expect(result.endsWith(defaultSuffix)).toBe(true)
|
|
52
|
+
const headBytes = Buffer.byteLength(
|
|
53
|
+
result.slice(0, result.length - defaultSuffix.length),
|
|
54
|
+
'utf8',
|
|
55
|
+
)
|
|
56
|
+
expect(headBytes).toBe(Math.max(0, maxBytes - suffixBytes))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('uses custom suffix', () => {
|
|
60
|
+
const text = 'a'.repeat(100)
|
|
61
|
+
const customSuffix = ' [truncated]'
|
|
62
|
+
const result = truncateToBytes(text, 50, customSuffix)
|
|
63
|
+
expect(result).toContain(customSuffix)
|
|
64
|
+
expect(result).not.toContain(defaultSuffix)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('handles empty string', () => {
|
|
68
|
+
expect(truncateToBytes('', 100)).toBe('')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('handles budget exactly zero when suffix bytes exceed limit', () => {
|
|
72
|
+
const text = 'Hello'
|
|
73
|
+
const result = truncateToBytes(text, 1)
|
|
74
|
+
expect(result).toBe(defaultSuffix)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ContextUsage } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
|
|
4
|
+
import { truncateText } from './truncate.js'
|
|
5
|
+
|
|
6
|
+
export function strInline(s: string): string {
|
|
7
|
+
return s.split('\n').join('⮒ ')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function rightAlign(left: string, right: string, width: number): string {
|
|
11
|
+
const rightW = visibleWidth(right)
|
|
12
|
+
const maxLeft = Math.max(0, width - rightW - 1)
|
|
13
|
+
const leftClamped = truncateText(left, maxLeft)
|
|
14
|
+
const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW)
|
|
15
|
+
return truncateText(leftClamped + ' '.repeat(gap) + right, width)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatTokens(count: number): string {
|
|
19
|
+
if (count < 1000) return count.toString()
|
|
20
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`
|
|
21
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`
|
|
22
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`
|
|
23
|
+
return `${Math.round(count / 1000000)}M`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatContextUsage(usage: ContextUsage | undefined): string {
|
|
27
|
+
if (
|
|
28
|
+
usage === undefined ||
|
|
29
|
+
usage.contextWindow === 0 ||
|
|
30
|
+
usage.tokens === null
|
|
31
|
+
) {
|
|
32
|
+
return '?'
|
|
33
|
+
}
|
|
34
|
+
return `${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatElapsed(item: {
|
|
38
|
+
startedAt: number
|
|
39
|
+
completedAt?: number
|
|
40
|
+
}): string {
|
|
41
|
+
const end = item.completedAt ?? Date.now()
|
|
42
|
+
const ms = Math.max(0, end - item.startedAt)
|
|
43
|
+
return formatElapsedMs(ms)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatElapsedMs(ms: number): string {
|
|
47
|
+
const seconds = Math.floor(ms / 1000)
|
|
48
|
+
if (seconds < 60) return `${seconds}s`
|
|
49
|
+
const minutes = Math.floor(seconds / 60)
|
|
50
|
+
const remainingSeconds = seconds % 60
|
|
51
|
+
if (minutes < 60) return `${minutes}m${remainingSeconds}s`
|
|
52
|
+
const hours = Math.floor(minutes / 60)
|
|
53
|
+
const remainingMinutes = minutes % 60
|
|
54
|
+
return `${hours}h${remainingMinutes}m${remainingSeconds}s`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function truncateToBytes(
|
|
58
|
+
text: string,
|
|
59
|
+
maxBytes: number,
|
|
60
|
+
suffix = '\n\n[Output truncated.]',
|
|
61
|
+
): string {
|
|
62
|
+
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
|
|
63
|
+
const suffixBytes = Buffer.byteLength(suffix, 'utf8')
|
|
64
|
+
const budget = Math.max(0, maxBytes - suffixBytes)
|
|
65
|
+
const head = Buffer.from(text, 'utf8').subarray(0, budget).toString('utf8')
|
|
66
|
+
return `${head}${suffix}`
|
|
67
|
+
}
|