Skip to content

Commit bcac3e4

Browse files
committed
fix: restore cache-hint dialog and evaluation modules
Replace oauth-strip stubs with the real CacheHintDialog, evaluateCacheHint, and cache-hint-config so idle intercept and cold-fetch dialogs work again.
1 parent b868d83 commit bcac3e4

3 files changed

Lines changed: 221 additions & 12 deletions

File tree

‎apps/pythinker-code/src/tui/components/dialogs/cache-hint-dialog.ts‎

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,121 @@
1+
/**
2+
* CacheHintDialog — shown when a resumed (or long-idle) session's context
3+
* cache has almost certainly expired, so the next turn re-sends the whole
4+
* history uncached. Offers compact / new session / continue / never.
5+
*
6+
* Layout mirrors the list-dialog spec (DESIGN.md): top border, title, hint,
7+
* body line, then options with right-column descriptions.
8+
*/
19

2-
export type CacheHintAction = string;
3-
export class CacheHintDialogComponent {
4-
constructor(..._args: any[]) {}
5-
render(): string[] { return []; }
10+
import {
11+
Container,
12+
matchesKey,
13+
Key,
14+
truncateToWidth,
15+
visibleWidth,
16+
type Focusable,
17+
} from '@pymodel/pi-tui';
18+
import { SELECT_POINTER } from '#/tui/constant/symbols';
19+
import { currentTheme } from '#/tui/theme';
20+
import { formatIdleDuration } from '#/tui/utils/cache-hint';
21+
import { SearchableList } from '#/tui/utils/searchable-list';
22+
import { formatTokenCount } from '#/utils/usage/usage-format';
23+
24+
export type CacheHintAction = 'compact' | 'new' | 'continue' | 'never';
25+
26+
interface CacheHintOption {
27+
readonly value: CacheHintAction;
28+
readonly label: string;
29+
readonly description?: string;
30+
}
31+
32+
const OPTIONS: readonly CacheHintOption[] = [
33+
{
34+
value: 'compact',
35+
label: 'Compact and continue',
36+
description: 'one-time compact cost · cheapest way to keep this topic',
37+
},
38+
{
39+
value: 'new',
40+
label: 'Start a new session',
41+
description: 'zero context cost · best for a new task',
42+
},
43+
{
44+
value: 'continue',
45+
label: 'Continue as-is',
46+
description: 'full history kept · highest cost per turn',
47+
},
48+
{ value: 'never', label: "Don't ask me again" },
49+
];
50+
51+
export interface CacheHintDialogOptions {
52+
readonly idleSeconds: number;
53+
readonly totalTokens: number;
54+
readonly onSelect: (action: CacheHintAction) => void;
55+
readonly onCancel: () => void;
56+
}
57+
58+
export class CacheHintDialogComponent extends Container implements Focusable {
59+
focused = false;
60+
private readonly opts: CacheHintDialogOptions;
61+
private readonly list: SearchableList<CacheHintOption>;
62+
63+
constructor(opts: CacheHintDialogOptions) {
64+
super();
65+
this.opts = opts;
66+
this.list = new SearchableList({
67+
items: OPTIONS,
68+
toSearchText: (o) => o.label,
69+
initialIndex: 0,
70+
searchable: false,
71+
});
72+
}
73+
74+
handleInput(data: string): void {
75+
if (matchesKey(data, Key.escape)) {
76+
this.opts.onCancel();
77+
return;
78+
}
79+
if (matchesKey(data, Key.enter)) {
80+
const chosen = this.list.selected();
81+
if (chosen !== undefined) this.opts.onSelect(chosen.value);
82+
return;
83+
}
84+
this.list.handleKey(data);
85+
}
86+
87+
override render(width: number): string[] {
88+
const view = this.list.view();
89+
const title = `This session has been idle for ${formatIdleDuration(this.opts.idleSeconds)} and is ~${formatTokenCount(this.opts.totalTokens)} tokens.`;
90+
const lines: string[] = [
91+
currentTheme.fg('primary', '─'.repeat(width)),
92+
currentTheme.boldFg('primary', ` ${title}`),
93+
currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'),
94+
'',
95+
currentTheme.fg(
96+
'text',
97+
' Cache expired — the next message re-sends the entire history at full price.',
98+
),
99+
];
100+
101+
const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label)));
102+
for (let i = view.page.start; i < view.page.end; i++) {
103+
const opt = view.items[i]!;
104+
const isSelected = i === view.selectedIndex;
105+
const pointer = isSelected ? SELECT_POINTER : ' ';
106+
let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `);
107+
line += isSelected
108+
? currentTheme.boldFg('primary', opt.label)
109+
: currentTheme.fg('text', opt.label);
110+
if (opt.description !== undefined) {
111+
const gap = maxLabelWidth - visibleWidth(opt.label) + 4;
112+
line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description);
113+
}
114+
lines.push(line);
115+
}
116+
117+
lines.push('');
118+
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
119+
return lines.map((line) => truncateToWidth(line, width));
120+
}
6121
}
7-
export class CacheHintDialog extends CacheHintDialogComponent {}
Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,52 @@
1+
import type { CacheHintConfig } from '#/utils/cache-hint-config';
12

2-
export function shouldShowCacheHint(..._args: any[]): boolean { return false; }
3-
export function evaluateCacheHint(..._args: any[]): any { return { show: false }; }
3+
export interface CacheHintInput {
4+
/** Current time, epoch ms. */
5+
readonly now: number;
6+
/** Last session activity, epoch ms. Missing → skip. */
7+
readonly lastActiveAt?: number;
8+
/** Current context size in tokens. Missing → skip (no local estimation). */
9+
readonly totalTokens?: number;
10+
/** Upstream model ID used to look up the rule. Missing/unconfigured → skip. */
11+
readonly modelId?: string;
12+
/** Fetch failure → undefined → skip. */
13+
readonly config?: CacheHintConfig;
14+
/** User chose "Don't ask me again" (tui.toml). */
15+
readonly dismissed: boolean;
16+
}
17+
18+
export type CacheHintDecision =
19+
| { readonly kind: 'skip' }
20+
| { readonly kind: 'hint'; readonly idleSeconds: number; readonly totalTokens: number };
21+
22+
/**
23+
* Shared trigger rule for both the resume and the idle scenarios. Every
24+
* missing-data branch skips — false negatives are acceptable, false positives
25+
* are not.
26+
*/
27+
export function evaluateCacheHint(input: CacheHintInput): CacheHintDecision {
28+
if (input.dismissed) return { kind: 'skip' };
29+
const { config, modelId, lastActiveAt, totalTokens } = input;
30+
if (config === undefined || modelId === undefined) return { kind: 'skip' };
31+
if (lastActiveAt === undefined || totalTokens === undefined) return { kind: 'skip' };
32+
const rule = config.config[modelId];
33+
if (rule === undefined) return { kind: 'skip' };
34+
const idleMs = input.now - lastActiveAt;
35+
if (idleMs <= rule.cache_duration * 1000) return { kind: 'skip' };
36+
if (totalTokens < rule.min_tokens_to_hint) return { kind: 'skip' };
37+
return { kind: 'hint', idleSeconds: Math.floor(idleMs / 1000), totalTokens };
38+
}
39+
40+
/** `45m` / `3h 20m` / `26d 22h`. */
41+
export function formatIdleDuration(idleSeconds: number): string {
42+
const minutes = Math.max(1, Math.floor(idleSeconds / 60));
43+
if (minutes < 60) return `${minutes}m`;
44+
const hours = Math.floor(minutes / 60);
45+
if (hours < 24) {
46+
const restMinutes = minutes % 60;
47+
return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`;
48+
}
49+
const days = Math.floor(hours / 24);
50+
const restHours = hours % 24;
51+
return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`;
52+
}
Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,52 @@
1+
import { z } from 'zod';
12

2-
export type CacheHintConfig = Record<string, unknown>;
3-
export function getCacheHintConfig(..._args: any[]): CacheHintConfig { return {}; }
4-
export function peekCacheHintConfig(..._args: any[]): CacheHintConfig { return {}; }
5-
export function loadCacheHintConfig(..._args: any[]): CacheHintConfig { return {}; }
6-
export function saveCacheHintConfig(..._args: any[]): void {}
3+
import {
4+
getClientConfig,
5+
peekClientConfig,
6+
resetClientConfigCache,
7+
type ClientConfigFetchOptions,
8+
} from '#/utils/client-configs';
9+
10+
/** The cache-hint rules are one named config on the client-configs endpoint. */
11+
const CONFIG_NAME = 'estimated_cache_duration';
12+
13+
const cacheHintModelRuleSchema = z.object({
14+
min_tokens_to_hint: z.number(),
15+
cache_duration: z.number(),
16+
});
17+
18+
const cacheHintConfigSchema = z.object({
19+
version: z.literal(1),
20+
config: z.record(z.string(), cacheHintModelRuleSchema),
21+
});
22+
23+
export type CacheHintConfig = z.infer<typeof cacheHintConfigSchema>;
24+
export type CacheHintConfigFetchOptions = ClientConfigFetchOptions;
25+
26+
/**
27+
* Returns the cache-hint config, preferring the cache (1 day, persisted
28+
* across restarts). Any failure resolves to `undefined` — callers treat
29+
* that as "do not hint".
30+
*/
31+
export async function getCacheHintConfig(
32+
options: CacheHintConfigFetchOptions = {},
33+
): Promise<CacheHintConfig | undefined> {
34+
return getClientConfig(CONFIG_NAME, cacheHintConfigSchema, options);
35+
}
36+
37+
/** Fire-and-forget refresh, e.g. on new-session creation. Never throws. */
38+
export function refreshCacheHintConfigInBackground(
39+
options: CacheHintConfigFetchOptions = {},
40+
): void {
41+
void getCacheHintConfig(options).catch(() => undefined);
42+
}
43+
44+
/** Synchronous peek at the fresh cache; undefined when missing or stale. */
45+
export function peekCacheHintConfig(now?: number): CacheHintConfig | undefined {
46+
return peekClientConfig(CONFIG_NAME, cacheHintConfigSchema, now);
47+
}
48+
49+
/** Test hook: drop the in-process cache. */
50+
export function resetCacheHintConfigCache(): void {
51+
resetClientConfigCache(CONFIG_NAME);
52+
}

0 commit comments

Comments
 (0)