@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.
Files changed (69) hide show
  1. package/.prettierrc +7 -0
  2. package/AGENTS.md +1 -0
  3. package/README.md +85 -0
  4. package/README.zh-CN.md +85 -0
  5. package/agents/explorer.md +10 -0
  6. package/agents/planner.md +16 -0
  7. package/agents/researcher.md +22 -0
  8. package/agents/reviewer.md +10 -0
  9. package/eslint.config.mjs +14 -0
  10. package/example-prompts/coordinator.md +21 -0
  11. package/package.json +48 -0
  12. package/pm-subagents-prompts/coordinator.md +16 -0
  13. package/pnpm-workspace.yaml +5 -0
  14. package/src/bash-readonly.test.ts +331 -0
  15. package/src/bash-readonly.ts +205 -0
  16. package/src/coordinator/coordinator.test.ts +28 -0
  17. package/src/coordinator/coordinator.ts +275 -0
  18. package/src/custom-select.test.ts +91 -0
  19. package/src/custom-select.ts +209 -0
  20. package/src/index.ts +87 -0
  21. package/src/models-config/models-config.test.ts +88 -0
  22. package/src/models-config/models-config.ts +205 -0
  23. package/src/models-config/scoped-models-editor.test.ts +189 -0
  24. package/src/models-config/scoped-models-editor.ts +412 -0
  25. package/src/models-config/subagent-model-constants.ts +2 -0
  26. package/src/models-config/subagent-model-cycle.ts +53 -0
  27. package/src/models-config/subagent-model-utils.test.ts +250 -0
  28. package/src/models-config/subagent-model-utils.ts +52 -0
  29. package/src/pm-mode.test.ts +324 -0
  30. package/src/pm-mode.ts +142 -0
  31. package/src/prompts/mode.test.ts +289 -0
  32. package/src/prompts/mode.ts +31 -0
  33. package/src/prompts/roles.test.ts +724 -0
  34. package/src/prompts/roles.ts +119 -0
  35. package/src/subagent/activity.test.ts +230 -0
  36. package/src/subagent/activity.ts +60 -0
  37. package/src/subagent/batcher.test.ts +198 -0
  38. package/src/subagent/batcher.ts +51 -0
  39. package/src/subagent/consts.ts +1 -0
  40. package/src/subagent/demo.ts +773 -0
  41. package/src/subagent/fleet.test.ts +1758 -0
  42. package/src/subagent/fleet.ts +376 -0
  43. package/src/subagent/identity.test.ts +31 -0
  44. package/src/subagent/identity.ts +16 -0
  45. package/src/subagent/manager.test.ts +392 -0
  46. package/src/subagent/manager.ts +277 -0
  47. package/src/subagent/tools.ts +314 -0
  48. package/src/subagent/viewer.ts +305 -0
  49. package/src/types.ts +15 -0
  50. package/src/ui/border-view.ts +50 -0
  51. package/src/ui/review-pager.ts +146 -0
  52. package/src/ui/scroll-view.test.ts +190 -0
  53. package/src/ui/scroll-view.ts +155 -0
  54. package/src/utils/format.test.ts +76 -0
  55. package/src/utils/format.ts +67 -0
  56. package/src/utils/fs.ts +9 -0
  57. package/src/utils/markdown.test.ts +442 -0
  58. package/src/utils/markdown.ts +79 -0
  59. package/src/utils/messages.test.ts +436 -0
  60. package/src/utils/messages.ts +131 -0
  61. package/src/utils/model-ref.test.ts +42 -0
  62. package/src/utils/model-ref.ts +44 -0
  63. package/src/utils/state.test.ts +98 -0
  64. package/src/utils/state.ts +45 -0
  65. package/src/utils/tools.ts +48 -0
  66. package/src/utils/truncate.test.ts +41 -0
  67. package/src/utils/truncate.ts +59 -0
  68. package/tsconfig.json +24 -0
  69. 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
+ }
@@ -0,0 +1,9 @@
1
+ import { readFile } from 'node:fs/promises'
2
+
3
+ export async function readOptional(path: string): Promise<string | undefined> {
4
+ try {
5
+ return (await readFile(path, 'utf8')).trim()
6
+ } catch {
7
+ return undefined
8
+ }
9
+ }