Skip to content

Commit be60365

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@5e536272ee4cb2fedc20afabd21a811dd5762362
1 parent f5b29c2 commit be60365

13 files changed

Lines changed: 658 additions & 117 deletions

File tree

bun.lock

Lines changed: 27 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/__tests__/helpers/terminal-watchdog-fixture.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
* - mode "clean": start the watchdog, then stop it and exit (clean shutdown).
77
*
88
* Prints "ready" once the watchdog is armed so the test knows when to kill.
9+
* On Windows, arming is asynchronous (a PowerShell bootstrap has to launch
10+
* the real watchdog outside Bun's kill-on-close job object), so we wait for
11+
* the `<ttyPath>.armed` marker before printing "ready" — killing earlier
12+
* would take the bootstrap down before the watchdog exists.
913
*/
14+
import { existsSync } from 'fs'
15+
1016
import {
1117
startTerminalWatchdog,
1218
stopTerminalWatchdog,
@@ -19,13 +25,26 @@ if (!mode || !ttyPath) {
1925
process.exit(2)
2026
}
2127

28+
async function waitForArmed(): Promise<void> {
29+
if (process.platform !== 'win32') return
30+
const deadline = Date.now() + 30_000
31+
while (Date.now() < deadline) {
32+
if (existsSync(`${ttyPath}.armed`)) return
33+
await new Promise((r) => setTimeout(r, 50))
34+
}
35+
console.error('watchdog never armed')
36+
process.exit(3)
37+
}
38+
2239
startTerminalWatchdog({ ttyPath })
2340

2441
if (mode === 'clean') {
42+
await waitForArmed()
2543
stopTerminalWatchdog()
2644
console.log('ready')
2745
process.exit(0)
2846
}
2947

48+
await waitForArmed()
3049
console.log('ready')
3150
setInterval(() => {}, 1_000)

cli/src/__tests__/terminal-watchdog.test.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function waitForReady(child: ChildProcess): Promise<void> {
3131
out += chunk.toString()
3232
if (out.includes('ready')) resolve()
3333
})
34-
child.on('exit', () => resolve()) // "clean" mode exits immediately
34+
child.on('exit', () => resolve()) // "clean" mode exits after arming
3535
child.on('error', reject)
3636
})
3737
}
@@ -61,9 +61,10 @@ async function pollForContent(ttyPath: string, timeoutMs: number): Promise<strin
6161
return readTty(ttyPath)
6262
}
6363

64-
// The watchdog is POSIX-only (sh + /dev/tty); on Windows the npm wrapper is
65-
// the safety net instead.
66-
describe.skipIf(process.platform === 'win32')('terminal watchdog', () => {
64+
// POSIX uses a detached sh blocking on pipe EOF; Windows uses a PowerShell
65+
// grandchild (outside Bun's kill-on-close job object) blocking on
66+
// Wait-Process. Both then write the reset sequences to the injected ttyPath.
67+
describe('terminal watchdog', () => {
6768
test('writes reset sequences to the tty when the process dies uncleanly', async () => {
6869
const ttyPath = join(tempDir, 'unclean.out')
6970
const child = spawnFixture('hang', ttyPath)
@@ -72,17 +73,21 @@ describe.skipIf(process.platform === 'win32')('terminal watchdog', () => {
7273
child.kill('SIGKILL')
7374
await waitForExit(child)
7475

75-
const written = await pollForContent(ttyPath, 5_000)
76+
// Wait-Process wakeup + write can take a few seconds under CI load.
77+
const written = await pollForContent(ttyPath, 15_000)
7678
expect(written).toBe(TERMINAL_RESET_SEQUENCES)
77-
}, 15_000)
79+
}, 60_000)
7880

7981
test('stays silent when the process shuts down cleanly', async () => {
8082
const ttyPath = join(tempDir, 'clean.out')
8183
const child = spawnFixture('clean', ttyPath)
8284
await waitForExit(child)
8385

84-
// Give a killed-too-late watchdog time to (incorrectly) fire.
85-
await new Promise((r) => setTimeout(r, 500))
86+
// Give a disarmed-too-late watchdog time to (incorrectly) fire. Windows
87+
// gets longer since the watchdog wakes asynchronously via Wait-Process.
88+
await new Promise((r) =>
89+
setTimeout(r, process.platform === 'win32' ? 3_000 : 500),
90+
)
8691
expect(readTty(ttyPath)).toBe('')
87-
}, 15_000)
92+
}, 60_000)
8893
})

cli/src/chat.tsx

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
22
import type { FeedbackCategory } from '@codebuff/common/constants/feedback'
33
import { safeOpen } from './utils/open-url'
44
import {
5+
Fragment,
56
useCallback,
67
useEffect,
78
useLayoutEffect,
@@ -13,7 +14,7 @@ import { useShallow } from 'zustand/react/shallow'
1314

1415
import { getAdsEnabled } from './commands/ads'
1516
import { routeUserPrompt, addBashMessageToHistory } from './commands/router'
16-
import { SingleAdBanner } from './components/ad-banner'
17+
import { InlineAdBanner } from './components/ad-banner'
1718
import { ChatInputBar } from './components/chat-input-bar'
1819
import { FreebuffActiveSessionSummary } from './components/freebuff-active-session-summary'
1920
import { LoadPreviousButton } from './components/load-previous-button'
@@ -190,11 +191,22 @@ export const Chat = ({
190191
})
191192
const hasSubscription = subscriptionData?.hasSubscription ?? false
192193

193-
const { ads, recordClick, recordImpression } = useGravityAd({
194+
const { placedAds, recordClick, recordImpression } = useGravityAd({
194195
enabled: IS_FREEBUFF || !hasSubscription,
195196
provider: 'gravity',
197+
inline: true,
198+
surface: 'cli_chat',
196199
})
197200

201+
// Index placed ads by the message they're anchored below, so the transcript
202+
// render can drop each ad in right after its anchor message.
203+
const adByAnchor = useMemo(() => {
204+
const map = new Map<string, (typeof placedAds)[number]>()
205+
for (const placed of placedAds) map.set(placed.afterMessageId, placed)
206+
return map
207+
}, [placedAds])
208+
const showInlineAds = IS_FREEBUFF || getAdsEnabled()
209+
198210
// Set initial mode from CLI flag on mount
199211
useEffect(() => {
200212
if (initialMode) {
@@ -1514,14 +1526,23 @@ export const Chat = ({
15141526
)}
15151527
{visibleTopLevelMessages.map((message, idx) => {
15161528
const isLast = idx === visibleTopLevelMessages.length - 1
1529+
const anchoredAd = adByAnchor.get(message.id)
15171530
return (
1518-
<MessageWithAgents
1519-
key={message.id}
1520-
message={message}
1521-
depth={0}
1522-
isLastMessage={isLast}
1523-
availableWidth={messageAvailableWidth}
1524-
/>
1531+
<Fragment key={message.id}>
1532+
<MessageWithAgents
1533+
message={message}
1534+
depth={0}
1535+
isLastMessage={isLast}
1536+
availableWidth={messageAvailableWidth}
1537+
/>
1538+
{showInlineAds && anchoredAd && (
1539+
<InlineAdBanner
1540+
ad={anchoredAd.ad}
1541+
onClick={recordClick}
1542+
onImpression={recordImpression}
1543+
/>
1544+
)}
1545+
</Fragment>
15251546
)
15261547
})}
15271548
{/* Pending bash messages as ghost messages (only show those not already in history) */}
@@ -1563,14 +1584,6 @@ export const Chat = ({
15631584
/>
15641585
)}
15651586

1566-
{ads?.[0] && (IS_FREEBUFF || getAdsEnabled()) && (
1567-
<SingleAdBanner
1568-
ad={ads[0]}
1569-
onClick={recordClick}
1570-
onImpression={recordImpression}
1571-
/>
1572-
)}
1573-
15741587
{reviewMode ? (
15751588
// Review and ask_user take precedence over the session-ended banner:
15761589
// during the grace window the agent may still be asking to run tools

cli/src/components/ad-banner.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ export const SingleAdBanner: React.FC<{
161161
)
162162
}
163163

164+
/**
165+
* A single ad embedded inside the chat transcript, anchored below a message so
166+
* it stays in scrollback. Same card as {@link SingleAdBanner} but with a bottom
167+
* spacer so it reads as its own block between messages; fires its impression on
168+
* mount (deduped per impUrl in the hook, so scroll churn won't double-count).
169+
*/
170+
export const InlineAdBanner: React.FC<{
171+
ad: AdResponse
172+
onClick?: (ad: AdResponse) => void
173+
onImpression?: (ad: AdResponse) => void
174+
}> = ({ ad, onClick, onImpression }) => {
175+
const { terminalWidth } = useTerminalDimensions()
176+
177+
// Full width minus left/right margin of 1 each.
178+
const width = terminalWidth - 2
179+
180+
useEffect(() => {
181+
onImpression?.(ad)
182+
}, [ad, onImpression])
183+
184+
return (
185+
<box style={{ marginLeft: 1, marginRight: 1, paddingBottom: 1 }}>
186+
<AdCard ad={ad} width={width} onClick={onClick} />
187+
</box>
188+
)
189+
}
190+
164191
/**
165192
* Up to four ads shown in a row. Still used by the freebuff landing screen,
166193
* which intentionally fills the space with multiple ads.

0 commit comments

Comments
 (0)