Skip to content

Commit 3bf3520

Browse files
committed
feat: /compact, thinking display, transient retry, context windows (v1.3.0)
New: /compact command to manually compress history with token count display New: Ink UI shows thinking content preview (last line) instead of just spinner New: Transient error retry with exponential backoff (429, 500, timeout, etc.) New: First-run tips for new users, GLM promo auto-expiry fallback Fix: System prompt documents all 11 tools with constraints Fix: 16 missing model shortcuts synced to terminal picker Fix: Token estimation adds 16-token tool_use framing overhead Fix: Router detects code blocks, uses byte-length token estimation Fix: Context window registry expanded (20+ models + pattern fallback) Fix: Glob only recurses on **, not on / patterns Fix: WebSearch parser fallback + DDG internal link filtering Fix: Tool descriptions document key limits in schemas
1 parent 4ce9d42 commit 3bf3520

File tree

14 files changed

+225
-18
lines changed

14 files changed

+225
-18
lines changed

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Changelog
22

3+
## 1.3.0 (2026-04-04)
4+
5+
### New Features
6+
7+
- **`/compact` command**: Manually compress conversation history to save tokens. Shows before/after token count.
8+
- **Thinking content display**: Ink UI now shows last line of model's thinking process (was only showing spinner).
9+
- **Transient error retry**: Network timeouts, 429 rate limits, and server errors now auto-retry with exponential backoff (up to 3 attempts) instead of terminating the session.
10+
- **First-run tips**: New users see helpful tip about `/model`, `/compact`, and `/help` on first launch.
11+
- **GLM promo auto-expiry**: Default model automatically switches from GLM-5 to Gemini Flash after promo ends.
12+
13+
### Bug Fixes
14+
15+
- **System prompt completeness**: All 11 tools now documented with constraints (was missing 5 tools)
16+
- **Model shortcuts synced**: 16 missing shortcuts added to terminal picker (was out of sync with proxy)
17+
- **Token estimation**: tool_use overhead now includes 16-token framing cost
18+
- **Router code block detection**: Triple backtick code blocks now boost complexity score
19+
- **Router token estimation**: Uses byte length for better accuracy with CJK/Unicode
20+
- **Context window registry**: Added 20+ missing models (xAI, GLM, Minimax, etc.) + pattern-based fallback
21+
- **Glob recursion**: Only `**` triggers full recursion (was over-recursing on `/` patterns)
22+
- **WebSearch parser**: Fallback regex when DuckDuckGo updates HTML; skip internal DDG links
23+
- **Tool descriptions**: Read, Grep, Glob schemas now document key limits and defaults
24+
- **Terminal UI commands**: `/model`, `/cost`, `/help`, `/compact` now work in piped/non-TTY mode
25+
- **Escape to abort**: Press Esc during generation to cancel current turn
26+
- **Per-turn cost display**: Session cost shown after every response
27+
328
## 1.2.0 (2026-04-04)
429

530
### Bug Fixes (36 fixes across 5 rounds)

dist/agent/loop.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
214214
break; // User wants to exit
215215
if (input === '')
216216
continue; // Empty input → re-prompt
217+
// Handle /compact command — force compaction without sending to model
218+
if (input === '/compact') {
219+
const beforeTokens = estimateHistoryTokens(history);
220+
const { history: compacted, compacted: didCompact } = await autoCompactIfNeeded(history, config.model, client, config.debug);
221+
if (didCompact) {
222+
history.length = 0;
223+
history.push(...compacted);
224+
}
225+
const afterTokens = estimateHistoryTokens(history);
226+
onEvent({ kind: 'text_delta', text: didCompact
227+
? `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens\n`
228+
: `History is ${beforeTokens.toLocaleString()} tokens — no compaction needed.\n`
229+
});
230+
onEvent({ kind: 'turn_done', reason: 'completed' });
231+
continue;
232+
}
217233
history.push({ role: 'user', content: input });
218234
const abort = new AbortController();
219235
onAbortReady?.(() => abort.abort());
@@ -287,18 +303,33 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
287303
}
288304
catch (err) {
289305
const errMsg = err.message || '';
306+
const errLower = errMsg.toLowerCase();
290307
// ── Prompt too long recovery ──
291-
if (errMsg.toLowerCase().includes('prompt is too long') && recoveryAttempts < 3) {
308+
if (errLower.includes('prompt is too long') && recoveryAttempts < 3) {
292309
recoveryAttempts++;
293310
if (config.debug) {
294311
console.error(`[runcode] Prompt too long — forcing compact (attempt ${recoveryAttempts})`);
295312
}
296-
// Force compaction by reducing history
297313
const { history: compactedAgain } = await autoCompactIfNeeded(history, config.model, client, config.debug);
298314
history.length = 0;
299315
history.push(...compactedAgain);
300316
continue; // Retry
301317
}
318+
// ── Transient error recovery (network, rate limit, server errors) ──
319+
const isTransient = errLower.includes('429') || errLower.includes('rate')
320+
|| errLower.includes('500') || errLower.includes('502') || errLower.includes('503')
321+
|| errLower.includes('timeout') || errLower.includes('econnrefused')
322+
|| errLower.includes('econnreset') || errLower.includes('fetch failed');
323+
if (isTransient && recoveryAttempts < 3) {
324+
recoveryAttempts++;
325+
const backoffMs = Math.pow(2, recoveryAttempts) * 1000;
326+
if (config.debug) {
327+
console.error(`[runcode] Transient error — retrying in ${backoffMs / 1000}s (attempt ${recoveryAttempts}): ${errMsg.slice(0, 100)}`);
328+
}
329+
onEvent({ kind: 'text_delta', text: `\n*Retrying (${recoveryAttempts}/3)...*\n` });
330+
await new Promise(r => setTimeout(r, backoffMs));
331+
continue;
332+
}
302333
onEvent({ kind: 'turn_done', reason: 'error', error: errMsg });
303334
break;
304335
}

dist/agent/tokens.js

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,23 +60,56 @@ export function estimateHistoryTokens(history) {
6060
* Context window sizes for known models.
6161
*/
6262
const MODEL_CONTEXT_WINDOWS = {
63+
// Anthropic
6364
'anthropic/claude-opus-4.6': 200_000,
6465
'anthropic/claude-sonnet-4.6': 200_000,
6566
'anthropic/claude-sonnet-4': 200_000,
6667
'anthropic/claude-haiku-4.5': 200_000,
68+
'anthropic/claude-haiku-4.5-20251001': 200_000,
69+
// OpenAI
6770
'openai/gpt-5.4': 128_000,
6871
'openai/gpt-5.4-pro': 128_000,
72+
'openai/gpt-5.3': 128_000,
73+
'openai/gpt-5.3-codex': 128_000,
74+
'openai/gpt-5.2': 128_000,
6975
'openai/gpt-5-mini': 128_000,
76+
'openai/gpt-5-nano': 128_000,
77+
'openai/gpt-4.1': 1_000_000,
78+
'openai/o3': 200_000,
79+
'openai/o4-mini': 200_000,
80+
// Google
7081
'google/gemini-2.5-pro': 1_000_000,
7182
'google/gemini-2.5-flash': 1_000_000,
83+
'google/gemini-2.5-flash-lite': 1_000_000,
84+
'google/gemini-3.1-pro': 1_000_000,
85+
// DeepSeek
7286
'deepseek/deepseek-chat': 64_000,
7387
'deepseek/deepseek-reasoner': 64_000,
88+
// xAI
89+
'xai/grok-3': 131_072,
90+
'xai/grok-4-0709': 131_072,
91+
'xai/grok-4-1-fast-reasoning': 131_072,
92+
// Others
93+
'zai/glm-5': 128_000,
94+
'moonshot/kimi-k2.5': 128_000,
95+
'minimax/minimax-m2.7': 128_000,
7496
};
7597
/**
7698
* Get the context window size for a model, with a conservative default.
7799
*/
78100
export function getContextWindow(model) {
79-
return MODEL_CONTEXT_WINDOWS[model] ?? 128_000;
101+
if (MODEL_CONTEXT_WINDOWS[model])
102+
return MODEL_CONTEXT_WINDOWS[model];
103+
// Pattern-based inference for unknown models
104+
if (model.includes('gemini'))
105+
return 1_000_000;
106+
if (model.includes('claude'))
107+
return 200_000;
108+
if (model.includes('gpt-4.1'))
109+
return 1_000_000;
110+
if (model.includes('nemotron') || model.includes('qwen'))
111+
return 128_000;
112+
return 128_000;
80113
}
81114
/**
82115
* Reserved tokens for the compaction summary output.

dist/commands/start.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export async function startCommand(options) {
2424
model = configModel;
2525
}
2626
else {
27-
model = 'zai/glm-5'; // Default: GLM-5 promo ($0.001/call)
27+
// Default: GLM-5 promo if still active, otherwise Gemini Flash (cheap & reliable)
28+
const promoExpiry = new Date('2026-04-15');
29+
model = Date.now() < promoExpiry.getTime() ? 'zai/glm-5' : 'google/gemini-2.5-flash';
2830
}
2931
// Auto-create wallet if needed (no interruption — free models work without funding)
3032
let walletAddress = '';
@@ -52,6 +54,10 @@ export async function startCommand(options) {
5254
console.log(chalk.dim(` Model: ${model}`));
5355
console.log(chalk.dim(` Wallet: ${walletAddress || 'not set'}`));
5456
console.log(chalk.dim(` Dir: ${workDir}`));
57+
// First-run tip: show if no config file exists yet
58+
if (!configModel && !options.model) {
59+
console.log(chalk.dim(`\n Tip: /model to switch models · /compact to save tokens · /help for all commands`));
60+
}
5561
console.log('');
5662
// Fetch balance in background (don't block startup)
5763
const walletInfo = {

dist/tools/websearch.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,16 @@ async function execute(input, _ctx) {
4444
}
4545
function parseDuckDuckGoResults(html, maxResults) {
4646
const results = [];
47-
// Match result blocks: <a class="result__a" href="...">title</a>
47+
// Primary parser: match result blocks by class names
4848
const linkRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
4949
const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
50-
const links = [...html.matchAll(linkRegex)];
51-
const snippets = [...html.matchAll(snippetRegex)];
50+
let links = [...html.matchAll(linkRegex)];
51+
let snippets = [...html.matchAll(snippetRegex)];
52+
// Fallback parser if primary finds nothing (DDG may have updated HTML)
53+
if (links.length === 0) {
54+
const fallbackLink = /<a[^>]*class="[^"]*result[^"]*"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
55+
links = [...html.matchAll(fallbackLink)];
56+
}
5257
for (let i = 0; i < Math.min(links.length, maxResults); i++) {
5358
const link = links[i];
5459
const snippet = snippets[i];
@@ -58,6 +63,9 @@ function parseDuckDuckGoResults(html, maxResults) {
5863
if (uddgMatch) {
5964
url = decodeURIComponent(uddgMatch[1]);
6065
}
66+
// Skip internal DDG links
67+
if (url.startsWith('/') || url.includes('duckduckgo.com'))
68+
continue;
6169
results.push({
6270
title: stripTags(link[2] || '').trim(),
6371
url,

dist/ui/app.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
5151
const [showHelp, setShowHelp] = useState(false);
5252
const [showWallet, setShowWallet] = useState(false);
5353
const [balance, setBalance] = useState(walletBalance);
54+
const [thinkingText, setThinkingText] = useState('');
5455
// Key handler for picker + esc + abort
5556
const isPickerOrEsc = mode === 'model-picker' || (mode === 'input' && ready && !input) || !ready;
5657
useInput((ch, key) => {
@@ -151,6 +152,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
151152
setStatusMsg('Conversation cleared');
152153
setTimeout(() => setStatusMsg(''), 3000);
153154
return;
155+
case '/compact':
156+
onSubmit('/compact');
157+
return;
154158
default:
155159
setStatusMsg(`Unknown command: ${cmd}. Try /help`);
156160
setTimeout(() => setStatusMsg(''), 3000);
@@ -161,6 +165,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
161165
setInput('');
162166
setStreamText('');
163167
setThinking(false);
168+
setThinkingText('');
164169
setTools(new Map());
165170
setReady(false);
166171
setWaiting(true);
@@ -184,6 +189,11 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
184189
case 'thinking_delta':
185190
setWaiting(false);
186191
setThinking(true);
192+
setThinkingText(prev => {
193+
// Keep last 500 chars of thinking for display
194+
const updated = prev + event.text;
195+
return updated.length > 500 ? updated.slice(-500) : updated;
196+
});
187197
break;
188198
case 'capability_start':
189199
setWaiting(false);
@@ -239,9 +249,9 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
239249
}), _jsx(Text, { children: " " })] }));
240250
}
241251
// ── Normal Mode ──
242-
return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "green", children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation display"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), Array.from(tools.values()).map((tool, i) => (_jsx(Box, { marginLeft: 1, children: tool.done ? (tool.error
252+
return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "green", children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation display"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), Array.from(tools.values()).map((tool, i) => (_jsx(Box, { marginLeft: 1, children: tool.done ? (tool.error
243253
? _jsxs(Text, { color: "red", children: [" \u2717 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms"] })] })
244-
: _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms \u2014 ", tool.preview.slice(0, 60), tool.preview.length > 60 ? '...' : ''] })] })) : (_jsxs(Text, { color: "cyan", children: [" ", _jsx(Spinner, { type: "dots" }), " ", tool.name, "..."] })) }, i))), thinking && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking..."] }) })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: currentModel })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { children: streamText }) })), ready && (turnTokens.input > 0 || turnTokens.output > 0) && streamText && (_jsx(Box, { marginLeft: 1, marginTop: 0, children: _jsxs(Text, { dimColor: true, children: [turnTokens.input.toLocaleString(), " in / ", turnTokens.output.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)} session` : ''] }) })), ready && (_jsx(InputBox, { input: input, setInput: setInput, onSubmit: handleSubmit, model: currentModel, balance: balance, focused: mode === 'input' }))] }));
254+
: _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms \u2014 ", tool.preview.slice(0, 60), tool.preview.length > 60 ? '...' : ''] })] })) : (_jsxs(Text, { color: "cyan", children: [" ", _jsx(Spinner, { type: "dots" }), " ", tool.name, "..."] })) }, i))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking..."] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", thinkingText.split('\n').pop()?.slice(0, 80)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: currentModel })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { children: streamText }) })), ready && (turnTokens.input > 0 || turnTokens.output > 0) && streamText && (_jsx(Box, { marginLeft: 1, marginTop: 0, children: _jsxs(Text, { dimColor: true, children: [turnTokens.input.toLocaleString(), " in / ", turnTokens.output.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)} session` : ''] }) })), ready && (_jsx(InputBox, { input: input, setInput: setInput, onSubmit: handleSubmit, model: currentModel, balance: balance, focused: mode === 'input' }))] }));
245255
}
246256
export function launchInkUI(opts) {
247257
let resolveInput = null;

dist/ui/terminal.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export class TerminalUI {
260260
console.error(chalk.bold('\n Commands:'));
261261
console.error(' /model [name] — switch model (e.g. /model sonnet)');
262262
console.error(' /cost — session cost and tokens');
263+
console.error(' /compact — compress conversation history');
263264
console.error(' /exit — quit');
264265
console.error(' /help — this help\n');
265266
console.error(chalk.dim(' Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4\n'));

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blockrun/runcode",
3-
"version": "1.2.1",
3+
"version": "1.3.0",
44
"description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)