mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea352fdd8 | ||
|
|
544714b2ba | ||
|
|
7ae935335e | ||
|
|
77a7cd9c61 | ||
|
|
615cc81fce | ||
|
|
65c0d641ef | ||
|
|
4e136aad4e | ||
|
|
4de6321b10 | ||
|
|
152477205f | ||
|
|
6ac18cfbce | ||
|
|
fc85376c3f | ||
|
|
736deb1321 | ||
|
|
fa5f3a01c6 | ||
|
|
b5a2175c6e | ||
|
|
c7368be722 | ||
|
|
6ca3c94d21 | ||
|
|
900c68d638 | ||
|
|
7eecebe3ea | ||
|
|
b43dd4bb91 | ||
|
|
ca68ba07f9 | ||
|
|
f0f1ac60a5 | ||
|
|
72f2478f35 | ||
|
|
57a3624d13 | ||
|
|
cd8def12b6 | ||
|
|
b465b73178 | ||
|
|
8a8d7655e6 | ||
|
|
bb76851f95 | ||
|
|
78e7327ead | ||
|
|
c9f8bc1507 | ||
|
|
1024a0cc44 | ||
|
|
002a97fe91 | ||
|
|
45f9a65fd4 | ||
|
|
3cc72075f2 | ||
|
|
d807c1f9b8 | ||
|
|
acb1dca762 | ||
|
|
e5bb6b4a24 | ||
|
|
4e8a49408e | ||
|
|
245e13b245 | ||
|
|
be414940cb | ||
|
|
b6492b2677 |
No files matched your search
+110
@@ -3,6 +3,116 @@
|
||||
All notable changes to Reasonix. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
|
||||
this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.49.0] — 2026-05-22
|
||||
|
||||
**TUI — Static-history renderer.** The history pane is now rendered
|
||||
exclusively through Ink's `<Static>` (#1529, stages 1–4). The previous
|
||||
virtual-viewport machinery — viewport-budget row allocator, byte-virtual
|
||||
frame layer, manual scroll math — is gone. Net: less code on the hot
|
||||
path, no more "frame drift" on very long sessions, and the rewrite stays
|
||||
Ink-native instead of fighting it. Behind `REASONIX_STATIC_HISTORY` at
|
||||
stage 1, default-on by stage 2, scaffolding removed by stage 4.
|
||||
|
||||
**Chat — queued mid-turn steers.** Typing while the model is mid-turn
|
||||
no longer fights the live render or drops the keystroke. New input
|
||||
queues, surfaces a small "queued" hint, and feeds into the next user
|
||||
turn cleanly (#1501). Pairs with the static-history switch so the
|
||||
queued line stays visible across re-renders.
|
||||
|
||||
**Web search — Bing default, dashboard engine switcher.** Default web
|
||||
backend switches from Mojeek to Bing (#1558); the dashboard gains a
|
||||
visible engine switcher so you can flip between configured backends
|
||||
without editing config. Mojeek is dropped from the bundled set entirely
|
||||
in this release.
|
||||
|
||||
**Plans — lifecycle evidence summaries.** Plan acceptance now surfaces
|
||||
the evidence captured at each lifecycle step (#1500), so the "why this
|
||||
plan is ready to accept" question has a structured answer instead of
|
||||
implicit trust. Builds on the strict-lifecycle observability that
|
||||
landed in 0.48.1.
|
||||
|
||||
**Desktop — approval + completion notifications.** Native OS
|
||||
notifications for approval prompts and turn-complete events (#1519),
|
||||
so a long-running turn can land in the background and still surface.
|
||||
Uses `tauri-plugin-notification`.
|
||||
|
||||
**i18n — CLI command output.** `/mcp`, `/sessions`, `/prune`, `/theme`
|
||||
output is now translated (#1524), closing the remaining English-only
|
||||
gap in CLI command surfaces. Approval-prompt confirm titles and action
|
||||
labels also get zh-CN (#1560/#1561).
|
||||
|
||||
**Security.**
|
||||
- `web_fetch` now blocks SSRF — private/loopback/link-local targets are
|
||||
rejected at the boundary (#1544)
|
||||
- Edit snapshots can no longer read outside the workspace (#1454)
|
||||
- Shell redirects (`>`, `>>`, `<`) are constrained to the sandbox
|
||||
boundary (#1457)
|
||||
- New Task integrity guardrail catches objective simplification — i.e.
|
||||
the model trying to redefine a hard requirement away (#1516)
|
||||
|
||||
**Tools.** `run_command` description now actively discourages
|
||||
shell-based file edits in favor of the structured `edit` tool (#1514).
|
||||
A per-turn dispatch-rate limit lands for tool calls (#1356), keeping
|
||||
runaway loops from burning a quota slice in one turn.
|
||||
|
||||
**TUI polish.**
|
||||
- `Ctrl+Enter` now inserts a newline (#1513) — matching how every
|
||||
modern composer treats the modifier
|
||||
- `mouseWheelRows` config knob tunes scroll velocity per terminal
|
||||
(#1511) so trackpad vs. wheel mouse don't fight the default
|
||||
- `@mention` file search is restricted to the current directory prefix
|
||||
(#1548) so the picker doesn't dump the whole repo
|
||||
- Paste-sentinel label no longer claims `^O` expands the paste — the
|
||||
shortcut never existed (#1517)
|
||||
|
||||
**Context.** Pinned-constraint blocks are now preserved across context
|
||||
folds (#1515) and the fold tail captures all of them, not just the last
|
||||
(#1552). Memory constraints survive the fold instead of silently
|
||||
dropping out under pressure.
|
||||
|
||||
**Client.**
|
||||
- DeepSeek 429s are translated into an actionable "concurrency limit
|
||||
hit" hint instead of a raw HTTP error (#1526)
|
||||
- `timeoutMs` is now wired to `fetch` when the caller passes an
|
||||
`AbortSignal` (#1535) — previously the timeout was silently ignored
|
||||
- `--no-proxy` / `REASONIX_NO_PROXY` opt-out is honored for the
|
||||
DeepSeek direct-route default (#1507)
|
||||
|
||||
**Files.** Read/edit/restore now preserve the source file's encoding
|
||||
(GB18030, UTF-8 BOM, etc.) instead of normalizing to UTF-8 on write
|
||||
(#1518). Important for CJK Windows projects.
|
||||
|
||||
**Web/Dashboard.**
|
||||
- Path-access approval modal is bridged to the dashboard (#1538) so
|
||||
the browser surface can resolve the same prompts as CLI/Desktop
|
||||
- `@mention` file picker popover lands in the dashboard ChatPane
|
||||
(#1546), gated on attached mode (#1549)
|
||||
|
||||
**Config.** Atomic config writes restore the preset-write diagnostic
|
||||
trace (#1555), so when a preset write goes wrong you can actually see
|
||||
why.
|
||||
|
||||
**Refactor.** `lifecycle` extracts a standalone risk policy module
|
||||
(#1557), shrinking the orchestrator and unblocking risk policy reuse
|
||||
from non-lifecycle callers.
|
||||
|
||||
**Edit safety.** `read-before-edit` is now enforced at the dispatch
|
||||
gate, not as a per-tool check (#1563) — `edit` / `multi_edit` refuse
|
||||
to fire on a file the model hasn't read this session, closing the
|
||||
race where a SEARCH block could match stale bytes after an external
|
||||
write.
|
||||
|
||||
**Cache.** Fold-summary requests now reuse the live agent's system
|
||||
prompt + tool list as their prefix, so the cached bytes the main
|
||||
agent just paid for carry over to the summary call (#1565). Measured
|
||||
on a 48.7K-token fold: 0% → 99.6% cache hit, ~89% input cost
|
||||
reduction per fold.
|
||||
|
||||
**Other:**
|
||||
- `fix(desktop)` drop `workspace:*` protocol for core-utils — desktop
|
||||
isn't a workspace member
|
||||
- `fix(desktop)` tsc errors blocking desktop bundle build
|
||||
|
||||
## [0.48.1] — 2026-05-21
|
||||
|
||||
**`dsnix` — short alias.** A new `dsnix` shim package lands alongside
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Evaluation harness — runs scenarios and collects results. */
|
||||
|
||||
import type { EvalReport, EvalResult, EvalScenario } from "./types.js";
|
||||
|
||||
export async function runScenarios(
|
||||
scenarios: EvalScenario[],
|
||||
opts: {
|
||||
onResult?: (r: EvalResult) => void;
|
||||
} = {},
|
||||
): Promise<EvalReport> {
|
||||
const results: EvalResult[] = [];
|
||||
for (const sc of scenarios) {
|
||||
const t0 = Date.now();
|
||||
let result: EvalResult;
|
||||
try {
|
||||
result = await sc.run();
|
||||
result.durationMs = Date.now() - t0;
|
||||
} catch (err) {
|
||||
result = {
|
||||
scenarioId: sc.id,
|
||||
pass: false,
|
||||
durationMs: Date.now() - t0,
|
||||
details: `UNEXPECTED ERROR: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
results.push(result);
|
||||
opts.onResult?.(result);
|
||||
}
|
||||
|
||||
// Dynamic import avoids bundling VERSION into the harness module.
|
||||
const { VERSION } = await import("../../src/version.js");
|
||||
return {
|
||||
meta: {
|
||||
date: new Date().toISOString(),
|
||||
reasonixVersion: VERSION,
|
||||
},
|
||||
results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Render behavior-stability results.json → markdown report. */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { EvalReport } from "./types.js";
|
||||
|
||||
interface CliArgs {
|
||||
input: string;
|
||||
outPath: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = { input: "", outPath: "benchmarks/behavior-stability/report.md" };
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--out") out.outPath = argv[++i] ?? out.outPath;
|
||||
else if (a && !a.startsWith("--")) positional.push(a);
|
||||
}
|
||||
out.input = positional[0] ?? "";
|
||||
if (!out.input) {
|
||||
throw new Error(
|
||||
"usage: npx tsx benchmarks/behavior-stability/report.ts <results.json> [--out report.md]",
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderHeader(report: EvalReport): string {
|
||||
return `# Reasonix Behavior-Stability Evaluation
|
||||
|
||||
**Date:** ${report.meta.date}
|
||||
**Version:** ${report.meta.reasonixVersion}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSummary(report: EvalReport): string {
|
||||
const passed = report.results.filter((r) => r.pass).length;
|
||||
const total = report.results.length;
|
||||
const pct = total > 0 ? ((passed / total) * 100).toFixed(0) : "0";
|
||||
|
||||
const rows = report.results.map((r) => {
|
||||
const icon = r.pass ? "✅" : "❌";
|
||||
return `| ${icon} | \`${r.scenarioId}\` | ${r.durationMs}ms | ${r.details} |`;
|
||||
});
|
||||
|
||||
return `## Summary
|
||||
|
||||
**${passed}/${total} passed (${pct}%)**
|
||||
|
||||
| status | scenario | time | details |
|
||||
|---|---:|---:|:---|
|
||||
${rows.join("\n")}
|
||||
`;
|
||||
}
|
||||
|
||||
function render(report: EvalReport): string {
|
||||
return [renderHeader(report), renderSummary(report)].join("\n\n");
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const raw = readFileSync(args.input, "utf8");
|
||||
const report = JSON.parse(raw) as EvalReport;
|
||||
const md = render(report);
|
||||
writeFileSync(args.outPath, md, "utf8");
|
||||
console.log(`Wrote ${args.outPath} (${report.results.length} results)`);
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** CLI runner for behavior-stability evaluation. */
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { loadDotenv } from "../../src/index.js";
|
||||
import { ALL_SCENARIOS, API_SCENARIOS, LOCAL_SCENARIOS } from "./scenarios.js";
|
||||
import { runScenarios } from "./harness.js";
|
||||
import type { EvalScenario } from "./types.js";
|
||||
|
||||
loadDotenv();
|
||||
|
||||
interface CliArgs {
|
||||
local: boolean;
|
||||
api: boolean;
|
||||
outPath: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const out: CliArgs = { local: false, api: false, outPath: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--local") out.local = true;
|
||||
else if (a === "--api") out.api = true;
|
||||
else if (a === "--out") out.outPath = argv[++i] ?? null;
|
||||
}
|
||||
// Default to local-only when neither flag is provided.
|
||||
if (!out.local && !out.api) out.local = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickScenarios(args: CliArgs): EvalScenario[] {
|
||||
const scenarios: EvalScenario[] = [];
|
||||
if (args.local) scenarios.push(...LOCAL_SCENARIOS);
|
||||
if (args.api) scenarios.push(...API_SCENARIOS);
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
function isMain(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
return import.meta.url === pathToFileURL(entry).href;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const scenarios = pickScenarios(args);
|
||||
|
||||
if (scenarios.length === 0) {
|
||||
console.log("No scenarios selected. Use --local, --api, or both.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const categories = new Set(scenarios.map((s) => s.category));
|
||||
console.log(`Running ${scenarios.length} scenario(s) [${[...categories].join(", ")}] …\n`);
|
||||
|
||||
const report = await runScenarios(scenarios, {
|
||||
onResult: (r) => {
|
||||
const icon = r.pass ? "✅" : "❌";
|
||||
console.log(`${icon} ${r.scenarioId} (${r.durationMs}ms)`);
|
||||
console.log(` ${r.details}\n`);
|
||||
},
|
||||
});
|
||||
|
||||
const passed = report.results.filter((r) => r.pass).length;
|
||||
const total = report.results.length;
|
||||
console.log(`────────────────────────────────────────`);
|
||||
console.log(`Results: ${passed}/${total} passed`);
|
||||
console.log(`Version: ${report.meta.reasonixVersion}`);
|
||||
console.log(`Date: ${report.meta.date}`);
|
||||
|
||||
if (args.outPath) {
|
||||
const dir = dirname(args.outPath);
|
||||
if (dir !== ".") mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(args.outPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
console.log(`Wrote ${args.outPath}`);
|
||||
}
|
||||
|
||||
if (passed < total) process.exit(1);
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Behavior-stability scenario for PR #1462 — constraint persistence across context folds. */
|
||||
|
||||
import { AppendOnlyLog, SessionStats } from "../../src/index.js";
|
||||
import { ContextManager } from "../../src/context-manager.js";
|
||||
import { Usage } from "../../src/client.js";
|
||||
import type { EvalResult, EvalScenario } from "./types.js";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function withTiming(fn: () => Promise<EvalResult>): Promise<EvalResult> {
|
||||
const t0 = Date.now();
|
||||
return fn().then((r) => ({ ...r, durationMs: Date.now() - t0 }));
|
||||
}
|
||||
|
||||
function pass(id: string, details: string): EvalResult {
|
||||
return { scenarioId: id, pass: true, durationMs: 0, details };
|
||||
}
|
||||
|
||||
function fail(id: string, details: string): EvalResult {
|
||||
return { scenarioId: id, pass: false, durationMs: 0, details };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock client — returns a fixed summary for fold testing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class MockDeepSeekClient {
|
||||
private _replyContent: string;
|
||||
constructor(replyContent: string) {
|
||||
this._replyContent = replyContent;
|
||||
}
|
||||
async chat(): Promise<{
|
||||
content: string;
|
||||
reasoningContent: string;
|
||||
usage: Usage;
|
||||
}> {
|
||||
return {
|
||||
content: this._replyContent,
|
||||
reasoningContent: "",
|
||||
usage: new Usage(100, 50, 150, 0, 100),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* #1462 — Constraint persistence across folds (local) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const constraintPersistence: EvalScenario = {
|
||||
id: "constraint-persistence",
|
||||
name: "Context fold preserves pinned constraints from system prompt",
|
||||
category: "context",
|
||||
requiresApi: false,
|
||||
run: () =>
|
||||
withTiming(async () => {
|
||||
const systemPrompt = `You are a coding assistant.
|
||||
|
||||
# HIGH PRIORITY constraints
|
||||
|
||||
- DO NOT use npm install without asking.
|
||||
- Never delete files without confirmation.
|
||||
|
||||
# User memory
|
||||
|
||||
The user prefers TypeScript over JavaScript.
|
||||
|
||||
# Project memory
|
||||
|
||||
This project uses pnpm instead of npm.`;
|
||||
|
||||
const log = new AppendOnlyLog();
|
||||
// Fill log with synthetic long history to trigger a fold boundary > 0.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
log.append({
|
||||
role: "user",
|
||||
content: `Turn ${i}: Please review the codebase and tell me what you found.`,
|
||||
});
|
||||
log.append({
|
||||
role: "assistant",
|
||||
content: `Turn ${i} summary: I reviewed the codebase and found several issues including unused imports, missing type annotations, and inconsistent formatting.`,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new MockDeepSeekClient("Summary of all prior turns.");
|
||||
const stats = new SessionStats();
|
||||
const ctx = new ContextManager({
|
||||
client: client as any,
|
||||
log,
|
||||
stats,
|
||||
sessionName: null,
|
||||
getAbortSignal: () => new AbortController().signal,
|
||||
getCurrentTurn: () => 1,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
|
||||
// Use a tiny tail budget so almost everything becomes head and gets summarized.
|
||||
const result = await ctx.fold("deepseek-v4-flash", { keepRecentTokens: 200 });
|
||||
|
||||
if (!result.folded) {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Fold did not happen (before=${result.beforeMessages}, after=${result.afterMessages}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const foldedSystem = log.toMessages()[0];
|
||||
if (foldedSystem?.role !== "assistant") {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Expected first folded message to be assistant summary, got ${foldedSystem?.role}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const content = foldedSystem.content as string;
|
||||
const checks = [
|
||||
{ text: "DO NOT use npm install without asking", label: "HIGH PRIORITY constraint" },
|
||||
{ text: "Never delete files without confirmation", label: "HIGH PRIORITY constraint 2" },
|
||||
{ text: "TypeScript over JavaScript", label: "User memory" },
|
||||
{ text: "pnpm instead of npm", label: "Project memory" },
|
||||
];
|
||||
|
||||
const missing = checks.filter((c) => !content.includes(c.text));
|
||||
if (missing.length > 0) {
|
||||
return fail(
|
||||
"constraint-persistence",
|
||||
`Folded summary missing ${missing.length} constraint(s): ${missing.map((m) => m.label).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return pass(
|
||||
"constraint-persistence",
|
||||
`Fold reduced ${result.beforeMessages} → ${result.afterMessages} messages and preserved all ${checks.length} pinned constraints.`,
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Exports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const LOCAL_SCENARIOS: EvalScenario[] = [constraintPersistence];
|
||||
|
||||
export const API_SCENARIOS: EvalScenario[] = [];
|
||||
|
||||
export const ALL_SCENARIOS: EvalScenario[] = [...LOCAL_SCENARIOS, ...API_SCENARIOS];
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Behavior-stability evaluation types — lightweight, local-first regression suite. */
|
||||
|
||||
export interface EvalScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
category: "shell" | "context" | "integrity";
|
||||
/** True when the scenario calls the live LLM API (costs money). */
|
||||
requiresApi: boolean;
|
||||
run: () => Promise<EvalResult>;
|
||||
}
|
||||
|
||||
export interface EvalResult {
|
||||
scenarioId: string;
|
||||
pass: boolean;
|
||||
durationMs: number;
|
||||
details: string;
|
||||
}
|
||||
|
||||
export interface EvalReport {
|
||||
meta: {
|
||||
date: string;
|
||||
reasonixVersion: string;
|
||||
model?: string;
|
||||
};
|
||||
results: EvalResult[];
|
||||
}
|
||||
@@ -57,6 +57,14 @@ interface ShellModalSpec {
|
||||
shellKind?: string;
|
||||
}
|
||||
|
||||
interface PathModalSpec {
|
||||
path: string;
|
||||
intent: "read" | "write";
|
||||
toolName: string;
|
||||
sandboxRoot: string;
|
||||
allowPrefix: string;
|
||||
}
|
||||
|
||||
interface ChoiceOption {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -372,6 +380,34 @@ export function ShellModal({ modal, onResolve }: { modal: ShellModalSpec; onReso
|
||||
`;
|
||||
}
|
||||
|
||||
export function PathModal({ modal, onResolve }: { modal: PathModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const intentLabel = modal.intent === "write" ? t("modal.pathWrite") : t("modal.pathRead");
|
||||
return html`
|
||||
<${ModalCard}
|
||||
accent="#fbbf24"
|
||||
icon="📂"
|
||||
title=${t("modal.pathTitle")}
|
||||
subtitle=${t("modal.pathSubtitle", { intent: intentLabel, tool: modal.toolName })}
|
||||
>
|
||||
<div class="modal-cmd"><code>${modal.path}</code></div>
|
||||
<div class="modal-meta">
|
||||
<div><span class="modal-meta-label">${t("modal.pathSandboxLabel")}</span> <code>${modal.sandboxRoot}</code></div>
|
||||
${
|
||||
modal.allowPrefix && modal.allowPrefix !== modal.path
|
||||
? html`<div><span class="modal-meta-label">${t("modal.pathAllowPrefixLabel")}</span> <code>${modal.allowPrefix}</code></div>`
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary" onClick=${() => onResolve("path", "run_once")}>${t("modal.runOnce")}</button>
|
||||
<button onClick=${() => onResolve("path", "always_allow")}>${t("modal.alwaysAllow", { prefix: modal.allowPrefix ?? modal.path })}</button>
|
||||
<button class="danger" onClick=${() => onResolve("path", "deny")}>${t("modal.deny")}</button>
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
export function ChoiceModal({ modal, onResolve }: { modal: ChoiceModalSpec; onResolve: OnResolve }) {
|
||||
useLang();
|
||||
const [custom, setCustom] = useState("");
|
||||
|
||||
@@ -118,6 +118,14 @@ export const en = {
|
||||
effortHigh: "high (cheaper / faster)",
|
||||
webSearch: "web search",
|
||||
webSearchNote: "web_fetch + web_search tools",
|
||||
webSearchEngine: "search engine",
|
||||
webSearchEngineBing: "bing — default, works from CN without proxy",
|
||||
webSearchEngineSearxng: "searxng — self-hosted (set endpoint via /se searxng <url>)",
|
||||
webSearchEngineMetaso: "metaso — 100/day free (CN-friendly, free key at metaso.cn)",
|
||||
webSearchEngineTavily: "tavily — 1000/mo free (set TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI-native (set PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI-native 1000/mo free (set EXA_API_KEY)",
|
||||
webSearchEngineNote: "applies to the next web_search call",
|
||||
sectionCompute: "Compute",
|
||||
proNext: "/pro one-shot",
|
||||
proArm: "Arm for next turn",
|
||||
@@ -164,6 +172,8 @@ export const en = {
|
||||
modeView: "session view",
|
||||
placeholder: "Type a prompt — Enter sends, Shift+Enter for a newline · / @ for pickers",
|
||||
placeholderBusy: "wait for the current turn to finish…",
|
||||
placeholderSteerBusy: "type to steer — commands and pickers are disabled while busy",
|
||||
steerHint: "message steers the current turn (added as instruction)",
|
||||
send: "Send",
|
||||
new: "New",
|
||||
clear: "Clear",
|
||||
@@ -694,6 +704,12 @@ export const en = {
|
||||
shellBgTitle: "background process",
|
||||
shellSubtitle: "model wants to run a shell command",
|
||||
shellBgSubtitle: "long-running — keeps running after approval",
|
||||
pathTitle: "file outside sandbox",
|
||||
pathSubtitle: "{intent} via {tool}",
|
||||
pathRead: "read",
|
||||
pathWrite: "write",
|
||||
pathSandboxLabel: "sandbox",
|
||||
pathAllowPrefixLabel: "allow prefix",
|
||||
runOnce: "Run once",
|
||||
alwaysAllow: 'Always allow "{prefix}"',
|
||||
deny: "Deny",
|
||||
|
||||
@@ -118,6 +118,14 @@ export const zhCN = {
|
||||
effortHigh: "high(更便宜 / 更快)",
|
||||
webSearch: "网页搜索",
|
||||
webSearchNote: "web_fetch + web_search 工具",
|
||||
webSearchEngine: "搜索引擎",
|
||||
webSearchEngineBing: "bing — 默认,国内裸 IP 直连,无需代理",
|
||||
webSearchEngineSearxng: "searxng — 自托管(端点用 /se searxng <url> 配置)",
|
||||
webSearchEngineMetaso: "metaso — 每日 100 次免费(国内友好,metaso.cn 可领免费 key)",
|
||||
webSearchEngineTavily: "tavily — 每月 1000 次免费(设置 TAVILY_API_KEY)",
|
||||
webSearchEnginePerplexity: "perplexity — AI 直接回答(设置 PERPLEXITY_API_KEY)",
|
||||
webSearchEngineExa: "exa — AI 直接回答,每月 1000 次免费(设置 EXA_API_KEY)",
|
||||
webSearchEngineNote: "下一次 web_search 调用生效",
|
||||
sectionCompute: "计算",
|
||||
proNext: "/pro 单轮",
|
||||
proArm: "为下一轮装备",
|
||||
@@ -164,6 +172,8 @@ export const zhCN = {
|
||||
modeView: "会话视图",
|
||||
placeholder: "输入提示词 — Enter 发送,Shift+Enter 换行 · / @ 打开选择器",
|
||||
placeholderBusy: "请等待当前轮次完成…",
|
||||
placeholderSteerBusy: "输入消息以引导当前任务 — 忙碌时不支持命令和选择器",
|
||||
steerHint: "消息将作为附加指示引导当前轮次",
|
||||
send: "发送",
|
||||
new: "新建",
|
||||
clear: "清除",
|
||||
@@ -667,6 +677,12 @@ export const zhCN = {
|
||||
shellBgTitle: "后台进程",
|
||||
shellSubtitle: "模型想要运行一条 Shell 命令",
|
||||
shellBgSubtitle: "长时间运行 — 批准后继续运行",
|
||||
pathTitle: "沙箱外文件访问",
|
||||
pathSubtitle: "{intent} · 通过 {tool}",
|
||||
pathRead: "读取",
|
||||
pathWrite: "写入",
|
||||
pathSandboxLabel: "沙箱",
|
||||
pathAllowPrefixLabel: "允许前缀",
|
||||
runOnce: "运行一次",
|
||||
alwaysAllow: '始终允许 "{prefix}"',
|
||||
deny: "拒绝",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { h } from "preact";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { ChatMessage, type ChatMsg, ToolCard, parseToolArgs } from "../components/chat-internals.js";
|
||||
import { t, useLang } from "../i18n/index.js";
|
||||
import { TOKEN, api } from "../lib/api.js";
|
||||
import { MODE, TOKEN, api } from "../lib/api.js";
|
||||
import { showToast } from "../lib/bus.js";
|
||||
import { parseHunks } from "../lib/diff-parser.js";
|
||||
// ChatStatusBar — inlined (mirrors chat.ts pattern)
|
||||
@@ -1453,7 +1453,7 @@ interface PopoverItem {
|
||||
insert: string;
|
||||
}
|
||||
|
||||
type PopoverKind = "slash" | null;
|
||||
type PopoverKind = "slash" | "mention" | null;
|
||||
|
||||
interface ChatPaneProps {
|
||||
comments: LineComment[];
|
||||
@@ -1716,6 +1716,10 @@ function ChatPane(props: ChatPaneProps) {
|
||||
|
||||
const updatePopover = useCallback(
|
||||
async (text: string) => {
|
||||
if (busy) {
|
||||
setPopoverKind(null);
|
||||
return;
|
||||
}
|
||||
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1]!.toLowerCase();
|
||||
@@ -1732,15 +1736,42 @@ function ChatPane(props: ChatPaneProps) {
|
||||
setPopoverSel(0);
|
||||
return;
|
||||
}
|
||||
const mentionMatch = /(?:^|\s)@([^\s@]*)$/.exec(text);
|
||||
if (mentionMatch && MODE === "attached") {
|
||||
const prefix = mentionMatch[1] ?? "";
|
||||
try {
|
||||
const r = await api<{ files: string[] }>("/files", {
|
||||
method: "POST",
|
||||
body: { prefix },
|
||||
});
|
||||
const items: PopoverItem[] = r.files.slice(0, 12).map((f) => ({
|
||||
label: f,
|
||||
insert: `@${f} `,
|
||||
}));
|
||||
setPopoverKind("mention");
|
||||
setPopoverItems(items);
|
||||
setPopoverSel(0);
|
||||
} catch {
|
||||
setPopoverKind(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPopoverKind(null);
|
||||
},
|
||||
[slashCommands],
|
||||
[busy, slashCommands],
|
||||
);
|
||||
|
||||
const applyPopover = useCallback(() => {
|
||||
const item = popoverItems[popoverSel];
|
||||
if (!item) return false;
|
||||
setInput(item.insert);
|
||||
if (popoverKind === "slash") {
|
||||
setInput(item.insert);
|
||||
} else if (popoverKind === "mention") {
|
||||
const m = /(?:^|\s)@([^\s@]*)$/.exec(input);
|
||||
if (!m) return false;
|
||||
const start = input.length - m[0].length + (m[0].startsWith(" ") ? 1 : 0);
|
||||
setInput(`${input.slice(0, start)}${item.insert}`);
|
||||
}
|
||||
setPopoverKind(null);
|
||||
return true;
|
||||
}, [popoverItems, popoverSel, popoverKind, input]);
|
||||
@@ -1757,7 +1788,6 @@ function ChatPane(props: ChatPaneProps) {
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (busy) return;
|
||||
if (!text && props.comments.length === 0) return;
|
||||
setError(null);
|
||||
|
||||
@@ -1783,7 +1813,7 @@ function ChatPane(props: ChatPaneProps) {
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}, [input, busy, props.comments]);
|
||||
}, [input, props.comments]);
|
||||
|
||||
const abort = useCallback(async () => {
|
||||
try {
|
||||
@@ -1949,7 +1979,7 @@ function ChatPane(props: ChatPaneProps) {
|
||||
popoverKind && popoverItems.length > 0
|
||||
? html`
|
||||
<div class="popover" style="position:absolute;bottom:calc(100% + 6px);left:0;width:380px;max-height:280px;overflow-y:auto;z-index:10">
|
||||
<div class="popover-h">${t("chat.slashCommands")}</div>
|
||||
<div class="popover-h">${popoverKind === "slash" ? t("chat.slashCommands") : t("chat.projectFiles")}</div>
|
||||
${popoverItems.map(
|
||||
(it, i) => html`
|
||||
<div
|
||||
@@ -1960,7 +1990,7 @@ function ChatPane(props: ChatPaneProps) {
|
||||
applyPopover();
|
||||
}}
|
||||
>
|
||||
<span class="g">/</span>
|
||||
<span class="g">${popoverKind === "slash" ? "/" : "@"}</span>
|
||||
<span class="name">${it.label}</span>
|
||||
${it.meta ? html`<span class="meta">${it.meta}</span>` : null}
|
||||
</div>
|
||||
@@ -1973,19 +2003,18 @@ function ChatPane(props: ChatPaneProps) {
|
||||
<textarea
|
||||
class="input"
|
||||
style=${{ width: "100%", resize: "none", minHeight: "36px", fontFamily: "inherit", fontSize: "13px", padding: "8px 10px", lineHeight: "1.4", background: "var(--bg-input)", border: "1px solid var(--bd)", borderRadius: "4px", color: "var(--fg-0)" }}
|
||||
placeholder=${busy ? t("chat.placeholderBusy") : props.comments.length > 0 ? "总结评论..." : t("changes.chatPlaceholder")}
|
||||
placeholder=${busy ? t("chat.placeholderSteerBusy") : props.comments.length > 0 ? "总结评论..." : t("changes.chatPlaceholder")}
|
||||
value=${input}
|
||||
onInput=${onInput}
|
||||
onKeyDown=${onKeyDown}
|
||||
onCompositionStart=${onCompositionStart}
|
||||
onCompositionEnd=${onCompositionEnd}
|
||||
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
|
||||
disabled=${busy}
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
<div style=${{ display: "flex", flexDirection: "column", gap: "6px", flexShrink: 0 }}>
|
||||
<button class="primary" onClick=${send} disabled=${busy || (!input.trim() && props.comments.length === 0)} style=${{ padding: "8px 12px", borderRadius: "4px" }}>${t("changes.chatSend")}</button>
|
||||
<button class="primary" onClick=${send} disabled=${!input.trim() && props.comments.length === 0} style=${{ padding: "8px 12px", borderRadius: "4px" }}>${t("changes.chatSend")}</button>
|
||||
<div style=${{ display: "flex", gap: "6px" }}>
|
||||
<button onClick=${newConversation} title=${t("changes.newTitle")}>${t("changes.newConversation")}</button>
|
||||
<button onClick=${clearScrollback} title=${t("changes.clearTitle")}>${t("changes.clearConversation")}</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChoiceModal,
|
||||
EditReviewModal,
|
||||
type OnResolve,
|
||||
PathModal,
|
||||
PickerModal,
|
||||
PlanModal,
|
||||
RevisionModal,
|
||||
@@ -626,6 +627,8 @@ export function ChatPanel() {
|
||||
modal
|
||||
? modal.kind === "shell"
|
||||
? html`<${ShellModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "path"
|
||||
? html`<${PathModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "choice"
|
||||
? html`<${ChoiceModal} modal=${modal} onResolve=${resolveModal} />`
|
||||
: modal.kind === "plan"
|
||||
@@ -705,6 +708,10 @@ const ChatInput = memo(function ChatInput({
|
||||
|
||||
const updatePopover = useCallback(
|
||||
async (text: string) => {
|
||||
if (busy) {
|
||||
setPopoverKind(null);
|
||||
return;
|
||||
}
|
||||
const slashMatch = /^\/([A-Za-z0-9_-]*)$/.exec(text);
|
||||
if (slashMatch) {
|
||||
const prefix = slashMatch[1]!.toLowerCase();
|
||||
@@ -743,7 +750,7 @@ const ChatInput = memo(function ChatInput({
|
||||
}
|
||||
setPopoverKind(null);
|
||||
},
|
||||
[slashCommands],
|
||||
[busy, slashCommands],
|
||||
);
|
||||
|
||||
const applyPopover = useCallback(() => {
|
||||
@@ -763,10 +770,10 @@ const ChatInput = memo(function ChatInput({
|
||||
|
||||
const send = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
if (!text) return;
|
||||
const res = await onSubmit(text);
|
||||
if (res.accepted) setInput("");
|
||||
}, [input, busy, onSubmit]);
|
||||
}, [input, onSubmit]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(e: Event) => {
|
||||
@@ -855,21 +862,20 @@ const ChatInput = memo(function ChatInput({
|
||||
: null
|
||||
}
|
||||
<textarea
|
||||
placeholder=${busy ? t("chat.placeholderBusy") : t("chat.placeholder")}
|
||||
placeholder=${busy ? t("chat.placeholderSteerBusy") : t("chat.placeholder")}
|
||||
value=${input}
|
||||
onInput=${onInput}
|
||||
onKeyDown=${onKeyDown}
|
||||
onCompositionStart=${onCompositionStart}
|
||||
onCompositionEnd=${onCompositionEnd}
|
||||
onBlur=${() => setTimeout(() => setPopoverKind(null), 150)}
|
||||
disabled=${busy}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div style="display: flex; flex-direction: column; gap: 6px; align-self: stretch; justify-content: flex-end;">
|
||||
<button
|
||||
class="primary"
|
||||
onClick=${send}
|
||||
disabled=${busy || !input.trim()}
|
||||
disabled=${!input.trim()}
|
||||
>${t("chat.send")}</button>
|
||||
<div style="display: flex; gap: 6px;">
|
||||
<button onClick=${onNew} title=${t("chat.newTitle")}>${t("chat.new")}</button>
|
||||
|
||||
@@ -23,6 +23,7 @@ interface SettingsData {
|
||||
preset?: string;
|
||||
reasoningEffort?: string;
|
||||
search?: boolean;
|
||||
webSearchEngine?: string;
|
||||
model?: string;
|
||||
editMode?: string;
|
||||
proNext?: boolean;
|
||||
@@ -631,6 +632,29 @@ export function SettingsPanel() {
|
||||
`,
|
||||
t("settings.webSearchNote"),
|
||||
)}
|
||||
${fieldRow(
|
||||
t("settings.webSearchEngine"),
|
||||
html`
|
||||
<select
|
||||
value=${["bing", "searxng", "metaso", "tavily", "perplexity", "exa"].includes(
|
||||
v.webSearchEngine ?? "",
|
||||
)
|
||||
? v.webSearchEngine
|
||||
: "bing"}
|
||||
onChange=${(e: Event) =>
|
||||
save({ webSearchEngine: (e.target as HTMLSelectElement).value })}
|
||||
disabled=${saving || !v.search}
|
||||
>
|
||||
<option value="bing">${t("settings.webSearchEngineBing")}</option>
|
||||
<option value="searxng">${t("settings.webSearchEngineSearxng")}</option>
|
||||
<option value="metaso">${t("settings.webSearchEngineMetaso")}</option>
|
||||
<option value="tavily">${t("settings.webSearchEngineTavily")}</option>
|
||||
<option value="perplexity">${t("settings.webSearchEnginePerplexity")}</option>
|
||||
<option value="exa">${t("settings.webSearchEngineExa")}</option>
|
||||
</select>
|
||||
`,
|
||||
t("settings.webSearchEngineNote"),
|
||||
)}
|
||||
</div>
|
||||
|
||||
${sectionH3(t("settings.sectionSkills"))}
|
||||
|
||||
Generated
+25
-2
@@ -1,18 +1,20 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"version": "0.43.0",
|
||||
"version": "0.49.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix-desktop",
|
||||
"version": "0.43.0",
|
||||
"version": "0.49.0",
|
||||
"dependencies": {
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@reasonix/core-utils": "file:../packages/core-utils",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
@@ -37,6 +39,14 @@
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
},
|
||||
"../packages/core-utils": {
|
||||
"name": "@reasonix/core-utils",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -787,6 +797,10 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@reasonix/core-utils": {
|
||||
"resolved": "../packages/core-utils",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1380,6 +1394,15 @@
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reasonix-desktop",
|
||||
"private": true,
|
||||
"version": "0.48.1",
|
||||
"version": "0.49.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,9 +14,10 @@
|
||||
"@fontsource/geist": "^5.2.8",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@reasonix/core-utils": "workspace:*",
|
||||
"@reasonix/core-utils": "file:../packages/core-utils",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-notification": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||
|
||||
Generated
+127
-2
@@ -2041,6 +2041,18 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2151,6 +2163,20 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.1"
|
||||
@@ -2600,7 +2626,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.39.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2660,6 +2686,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2738,6 +2773,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2768,6 +2812,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2776,7 +2849,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "reasonix-desktop"
|
||||
version = "0.48.1"
|
||||
version = "0.49.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"parking_lot",
|
||||
@@ -2785,6 +2858,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
@@ -3801,6 +3875,25 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-notification"
|
||||
version = "2.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
|
||||
dependencies = [
|
||||
"log",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@@ -3981,6 +4074,18 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5531,6 +5636,26 @@ dependencies = [
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "reasonix-desktop"
|
||||
version = "0.48.1"
|
||||
version = "0.49.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
@@ -13,6 +13,7 @@ tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"process:allow-restart",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"notification:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [{ "url": "https://*" }, { "url": "http://*" }]
|
||||
|
||||
@@ -217,6 +217,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(RpcState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Reasonix",
|
||||
"version": "0.48.1",
|
||||
"version": "0.49.0",
|
||||
"identifier": "dev.reasonix.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+10
-1
@@ -49,7 +49,16 @@ function initialState(): Parameters<typeof reduce>[0] {
|
||||
pendingCheckpoints: [],
|
||||
pendingRevisions: [],
|
||||
activePlan: null,
|
||||
usage: { totalCostUsd: 0, tokens: { input: 0, output: 0, cacheHit: 0, cacheMiss: 0 } },
|
||||
usage: {
|
||||
totalCostUsd: 0,
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
lastCallCacheHit: null,
|
||||
lastCallCacheMiss: null,
|
||||
reservedTokens: 0,
|
||||
},
|
||||
sessions: [],
|
||||
settings: null,
|
||||
qq: null,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
isPermissionGranted as isNotificationPermissionGranted,
|
||||
requestPermission as requestNotificationPermission,
|
||||
sendNotification,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { type Update, check } from "@tauri-apps/plugin-updater";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
||||
@@ -51,6 +56,7 @@ import { Sidebar } from "./ui/sidebar";
|
||||
import { Shortcut, localizeShortcutText, shortcutText } from "./ui/shortcut";
|
||||
import { Splash, shouldShowSplash } from "./ui/splash";
|
||||
import { StatusBar } from "./ui/statusbar";
|
||||
import { dispatchDesktopNotifications, deriveDesktopNotifications, type ApprovalSnapshot } from "./notifications";
|
||||
import {
|
||||
ActivePlanTaskCard,
|
||||
AssistantMsg,
|
||||
@@ -852,6 +858,7 @@ export function applyIncoming(state: State, ev: IncomingEvent): State {
|
||||
`Session "${ev.name}" loaded with no messages (${sizeNote}). ` +
|
||||
`The file ~/.reasonix/sessions/${ev.name}.jsonl exists but couldn't be parsed — ` +
|
||||
`start a new chat or restore from .jsonl.bak if you have one.`,
|
||||
id: nextErrorId(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1169,6 +1176,16 @@ function TabRuntime({
|
||||
const [settingsPage, setSettingsPage] = useState<SettingsPageId>("general");
|
||||
const [jobsOpen, setJobsOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const previousApprovalSnapshotRef = useRef<ApprovalSnapshot>({
|
||||
confirms: [],
|
||||
pathAccess: [],
|
||||
choices: [],
|
||||
plans: [],
|
||||
checkpoints: [],
|
||||
revisions: [],
|
||||
});
|
||||
const wasBusyRef = useRef(false);
|
||||
const busyStartedAtRef = useRef<number | null>(null);
|
||||
const openSettingsAt = useCallback((page: SettingsPageId = "general") => {
|
||||
setSettingsPage(page);
|
||||
setSettingsOpen(true);
|
||||
@@ -1386,6 +1403,54 @@ function TabRuntime({
|
||||
send(next);
|
||||
}, [state.busy, state.ready, state.queuedSends, send]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSnapshot: ApprovalSnapshot = {
|
||||
confirms: state.pendingConfirms.map((c) => ({ id: c.id, command: c.command })),
|
||||
pathAccess: state.pendingPathAccess.map((p) => ({ id: p.id, path: p.path, intent: p.intent })),
|
||||
choices: state.pendingChoices.map((c) => ({ id: c.id, question: c.question })),
|
||||
plans: state.pendingPlans.map((p) => ({ id: p.id, summary: p.summary, plan: p.plan })),
|
||||
checkpoints: state.pendingCheckpoints.map((c) => ({ id: c.id, title: c.title, result: c.result })),
|
||||
revisions: state.pendingRevisions.map((r) => ({ id: r.id, summary: r.summary, reason: r.reason })),
|
||||
};
|
||||
const previousSnapshot = previousApprovalSnapshotRef.current;
|
||||
const wasBusy = wasBusyRef.current;
|
||||
const busyDurationMs = wasBusy && !state.busy && busyStartedAtRef.current
|
||||
? Date.now() - busyStartedAtRef.current
|
||||
: 0;
|
||||
|
||||
if (state.busy && busyStartedAtRef.current === null) {
|
||||
busyStartedAtRef.current = Date.now();
|
||||
} else if (!state.busy) {
|
||||
busyStartedAtRef.current = null;
|
||||
}
|
||||
|
||||
previousApprovalSnapshotRef.current = currentSnapshot;
|
||||
wasBusyRef.current = state.busy;
|
||||
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: previousSnapshot,
|
||||
current: currentSnapshot,
|
||||
wasBusy,
|
||||
isBusy: state.busy,
|
||||
busyDurationMs,
|
||||
focused: false,
|
||||
});
|
||||
void dispatchDesktopNotifications(notifications, {
|
||||
isFocused: () => getCurrentWindow().isFocused(),
|
||||
isPermissionGranted: isNotificationPermissionGranted,
|
||||
requestPermission: requestNotificationPermission,
|
||||
sendNotification,
|
||||
});
|
||||
}, [
|
||||
state.busy,
|
||||
state.pendingChoices,
|
||||
state.pendingCheckpoints,
|
||||
state.pendingConfirms,
|
||||
state.pendingPathAccess,
|
||||
state.pendingPlans,
|
||||
state.pendingRevisions,
|
||||
]);
|
||||
|
||||
const resolveConfirm = useCallback(
|
||||
(id: number, response: ConfirmationChoice) => {
|
||||
sendRpc({ cmd: "confirm_response", id, response });
|
||||
|
||||
@@ -680,4 +680,16 @@ export const en = {
|
||||
panel: {
|
||||
renderError: "{panel} panel render error",
|
||||
},
|
||||
notifications: {
|
||||
approvalTitle: "Reasonix is waiting for approval",
|
||||
inputTitle: "Reasonix is waiting for input",
|
||||
planApprovalTitle: "Reasonix is waiting for plan approval",
|
||||
checkpointApprovalTitle: "Reasonix is waiting for checkpoint approval",
|
||||
revisionTitle: "Reasonix proposed a plan revision",
|
||||
commandBody: "Command: {command}",
|
||||
readBody: "Read: {path}",
|
||||
writeBody: "Write: {path}",
|
||||
turnCompleteTitle: "Reasonix task complete",
|
||||
turnCompleteBody: "The current task finished and is ready for review.",
|
||||
},
|
||||
};
|
||||
@@ -666,4 +666,16 @@ export const zhCN: typeof en = {
|
||||
panel: {
|
||||
renderError: "{panel} 面板渲染错误",
|
||||
},
|
||||
notifications: {
|
||||
approvalTitle: "\u7b49\u5f85\u6279\u51c6",
|
||||
inputTitle: "\u7b49\u5f85\u4f60\u8f93\u5165",
|
||||
planApprovalTitle: "\u7b49\u5f85\u8ba1\u5212\u6279\u51c6",
|
||||
checkpointApprovalTitle: "\u7b49\u5f85\u68c0\u67e5\u70b9\u6279\u51c6",
|
||||
revisionTitle: "\u5df2\u63d0\u51fa\u8ba1\u5212\u4fee\u8ba2",
|
||||
commandBody: "\u547d\u4ee4\uff1a{command}",
|
||||
readBody: "\u8bfb\u53d6\uff1a{path}",
|
||||
writeBody: "\u5199\u5165\uff1a{path}",
|
||||
turnCompleteTitle: "\u4efb\u52a1\u5df2\u5b8c\u6210",
|
||||
turnCompleteBody: "\u5f53\u524d\u4efb\u52a1\u5df2\u7ed3\u675f\uff0c\u53ef\u4ee5\u56de\u6765\u67e5\u770b\u7ed3\u679c\u4e86\u3002",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setLang } from "./i18n";
|
||||
import {
|
||||
COMPLETION_NOTIFY_MIN_MS,
|
||||
deriveDesktopNotifications,
|
||||
type ApprovalSnapshot,
|
||||
} from "./notifications";
|
||||
|
||||
function emptySnapshot(): ApprovalSnapshot {
|
||||
return {
|
||||
confirms: [],
|
||||
pathAccess: [],
|
||||
choices: [],
|
||||
plans: [],
|
||||
checkpoints: [],
|
||||
revisions: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("desktop notifications", () => {
|
||||
it("uses the active UI language for notification copy", () => {
|
||||
setLang("zh-CN");
|
||||
const next = emptySnapshot();
|
||||
next.confirms.push({ id: 1, command: "git push" });
|
||||
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: emptySnapshot(),
|
||||
current: next,
|
||||
wasBusy: true,
|
||||
isBusy: true,
|
||||
busyDurationMs: 1_000,
|
||||
focused: false,
|
||||
});
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{
|
||||
kind: "approval",
|
||||
title: "等待批准",
|
||||
body: "命令:git push",
|
||||
},
|
||||
]);
|
||||
|
||||
setLang("en");
|
||||
});
|
||||
|
||||
it("notifies when a new approval request appears while unfocused", () => {
|
||||
const next = emptySnapshot();
|
||||
next.confirms.push({ id: 1, command: "git push" });
|
||||
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: emptySnapshot(),
|
||||
current: next,
|
||||
wasBusy: true,
|
||||
isBusy: true,
|
||||
busyDurationMs: 1_000,
|
||||
focused: false,
|
||||
});
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{
|
||||
kind: "approval",
|
||||
title: "Reasonix is waiting for approval",
|
||||
body: "Command: git push",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not notify approvals when the window is focused", () => {
|
||||
const next = emptySnapshot();
|
||||
next.pathAccess.push({ id: 2, path: "/tmp/out.txt", intent: "write" });
|
||||
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: emptySnapshot(),
|
||||
current: next,
|
||||
wasBusy: true,
|
||||
isBusy: true,
|
||||
busyDurationMs: 1_000,
|
||||
focused: true,
|
||||
});
|
||||
|
||||
expect(notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it("notifies on long task completion when the window is unfocused", () => {
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: emptySnapshot(),
|
||||
current: emptySnapshot(),
|
||||
wasBusy: true,
|
||||
isBusy: false,
|
||||
busyDurationMs: COMPLETION_NOTIFY_MIN_MS,
|
||||
focused: false,
|
||||
});
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{
|
||||
kind: "turn_complete",
|
||||
title: "Reasonix task complete",
|
||||
body: "The current task finished and is ready for review.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not notify on short task completion", () => {
|
||||
const notifications = deriveDesktopNotifications({
|
||||
previous: emptySnapshot(),
|
||||
current: emptySnapshot(),
|
||||
wasBusy: true,
|
||||
isBusy: false,
|
||||
busyDurationMs: COMPLETION_NOTIFY_MIN_MS - 1,
|
||||
focused: false,
|
||||
});
|
||||
|
||||
expect(notifications).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { t } from "./i18n";
|
||||
|
||||
export type ApprovalSnapshot = {
|
||||
confirms: { id: number; command: string }[];
|
||||
pathAccess: { id: number; path: string; intent: "read" | "write" }[];
|
||||
choices: { id: number; question: string }[];
|
||||
plans: { id: number; summary?: string; plan: string }[];
|
||||
checkpoints: { id: number; title?: string; result: string }[];
|
||||
revisions: { id: number; summary?: string; reason: string }[];
|
||||
};
|
||||
|
||||
export type DesktopNotification =
|
||||
| { kind: "approval"; title: string; body: string }
|
||||
| { kind: "turn_complete"; title: string; body: string };
|
||||
|
||||
export const COMPLETION_NOTIFY_MIN_MS = 15_000;
|
||||
|
||||
function newestApproval(snapshot: ApprovalSnapshot): DesktopNotification | null {
|
||||
const confirm = snapshot.confirms.at(-1);
|
||||
if (confirm) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.approvalTitle"),
|
||||
body: t("notifications.commandBody", { command: confirm.command }),
|
||||
};
|
||||
}
|
||||
const pathAccess = snapshot.pathAccess.at(-1);
|
||||
if (pathAccess) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.approvalTitle"),
|
||||
body: t(
|
||||
pathAccess.intent === "write" ? "notifications.writeBody" : "notifications.readBody",
|
||||
{ path: pathAccess.path },
|
||||
),
|
||||
};
|
||||
}
|
||||
const choice = snapshot.choices.at(-1);
|
||||
if (choice) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.inputTitle"),
|
||||
body: choice.question,
|
||||
};
|
||||
}
|
||||
const plan = snapshot.plans.at(-1);
|
||||
if (plan) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.planApprovalTitle"),
|
||||
body: plan.summary ?? plan.plan,
|
||||
};
|
||||
}
|
||||
const checkpoint = snapshot.checkpoints.at(-1);
|
||||
if (checkpoint) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.checkpointApprovalTitle"),
|
||||
body: checkpoint.title ?? checkpoint.result,
|
||||
};
|
||||
}
|
||||
const revision = snapshot.revisions.at(-1);
|
||||
if (revision) {
|
||||
return {
|
||||
kind: "approval",
|
||||
title: t("notifications.revisionTitle"),
|
||||
body: revision.summary ?? revision.reason,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function totalPending(snapshot: ApprovalSnapshot): number {
|
||||
return (
|
||||
snapshot.confirms.length +
|
||||
snapshot.pathAccess.length +
|
||||
snapshot.choices.length +
|
||||
snapshot.plans.length +
|
||||
snapshot.checkpoints.length +
|
||||
snapshot.revisions.length
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveDesktopNotifications(args: {
|
||||
previous: ApprovalSnapshot;
|
||||
current: ApprovalSnapshot;
|
||||
wasBusy: boolean;
|
||||
isBusy: boolean;
|
||||
busyDurationMs: number;
|
||||
focused: boolean;
|
||||
}): DesktopNotification[] {
|
||||
const notifications: DesktopNotification[] = [];
|
||||
if (args.focused) return notifications;
|
||||
|
||||
if (totalPending(args.current) > totalPending(args.previous)) {
|
||||
const approval = newestApproval(args.current);
|
||||
if (approval) notifications.push(approval);
|
||||
}
|
||||
|
||||
if (args.wasBusy && !args.isBusy && args.busyDurationMs >= COMPLETION_NOTIFY_MIN_MS) {
|
||||
notifications.push({
|
||||
kind: "turn_complete",
|
||||
title: t("notifications.turnCompleteTitle"),
|
||||
body: t("notifications.turnCompleteBody"),
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
let notificationPermissionAttempted = false;
|
||||
|
||||
export async function dispatchDesktopNotifications(
|
||||
notifications: DesktopNotification[],
|
||||
deps: {
|
||||
isFocused: () => Promise<boolean>;
|
||||
isPermissionGranted: () => Promise<boolean>;
|
||||
requestPermission: () => Promise<"default" | "denied" | "granted">;
|
||||
sendNotification: (note: { title: string; body: string }) => void;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (notifications.length === 0) return;
|
||||
const focused = await deps.isFocused().catch(() => true);
|
||||
if (focused) return;
|
||||
|
||||
let granted = await deps.isPermissionGranted().catch(() => false);
|
||||
if (!granted && !notificationPermissionAttempted) {
|
||||
notificationPermissionAttempted = true;
|
||||
const permission = await deps.requestPermission().catch(() => "denied" as const);
|
||||
granted = permission === "granted";
|
||||
}
|
||||
if (!granted) return;
|
||||
|
||||
for (const note of notifications) {
|
||||
deps.sendNotification({ title: note.title, body: note.body });
|
||||
}
|
||||
}
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "reasonix",
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -15,6 +15,7 @@
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^7.0.2",
|
||||
"ink-text-input": "^6.0.0",
|
||||
@@ -4171,7 +4172,6 @@
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@@ -5403,9 +5403,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/reasonix": {
|
||||
"version": "0.48.0",
|
||||
"resolved": "https://registry.npmjs.org/reasonix/-/reasonix-0.48.0.tgz",
|
||||
"integrity": "sha512-Y7WRpqONVV+OrrvEecRRUs8U9LpvFhufmi2w4XXuZkR8rWN5n0NcosB8+Zn00YQFW2JI9N2lbHLXWJtBk+jZtw==",
|
||||
"version": "0.48.1",
|
||||
"resolved": "https://registry.npmjs.org/reasonix/-/reasonix-0.48.1.tgz",
|
||||
"integrity": "sha512-TLosbhvYtva8GUjoYdCFMmzsA00go5mMJSsRvidA7NBUUyNj40izkN3QysYDalphOqW02x2boOymoabjtE6imA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -5427,6 +5427,7 @@
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"bin": {
|
||||
"dsnix": "dist/cli/index.js",
|
||||
"reasonix": "dist/cli/index.js"
|
||||
},
|
||||
"engines": {
|
||||
@@ -5553,7 +5554,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
@@ -7954,10 +7954,10 @@
|
||||
}
|
||||
},
|
||||
"packages/dsnix": {
|
||||
"version": "0.48.0",
|
||||
"version": "0.48.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"reasonix": "0.48.0"
|
||||
"reasonix": "0.48.1"
|
||||
},
|
||||
"bin": {
|
||||
"dsnix": "bin.cjs"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.48.1",
|
||||
"version": "0.49.0",
|
||||
"description": "DeepSeek-native coding agent: cache-first loop, flash-first cost control, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -75,6 +75,7 @@
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^12.1.0",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"ignore": "^7.0.5",
|
||||
"ink": "^7.0.2",
|
||||
"ink-text-input": "^6.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsnix",
|
||||
"version": "0.48.0",
|
||||
"version": "0.48.1",
|
||||
"description": "Short alias for reasonix — DeepSeek-native coding agent. Forwards to the reasonix CLI.",
|
||||
"bin": {
|
||||
"dsnix": "bin.cjs"
|
||||
@@ -31,6 +31,6 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"reasonix": "0.48.0"
|
||||
"reasonix": "0.48.1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { loadApiKey, readConfig, searchEnabled } from "../../config.js";
|
||||
import { loadApiKey, loadToolRateLimit, readConfig, searchEnabled } from "../../config.js";
|
||||
import { loadDotenv } from "../../env.js";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import {
|
||||
@@ -261,7 +261,9 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// replacing. When no seed AND no MCP, tools stays undefined and
|
||||
// the loop runs as a bare chat.
|
||||
let tools: ToolRegistry | undefined = opts.seedTools;
|
||||
if (requestedSpecs.length > 0 && !tools) tools = new ToolRegistry();
|
||||
if (requestedSpecs.length > 0 && !tools) {
|
||||
tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
|
||||
}
|
||||
|
||||
const runtime = createMcpRuntime({
|
||||
getTools: () => tools,
|
||||
@@ -287,7 +289,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// backs them with no key required; the model invokes them whenever
|
||||
// a question needs info fresher than its training data.
|
||||
if (searchEnabled()) {
|
||||
if (!tools) tools = new ToolRegistry();
|
||||
if (!tools) tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
|
||||
registerWebTools(tools);
|
||||
}
|
||||
|
||||
@@ -299,7 +301,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
// exists) so it can wire the subagent runner for runAs:subagent
|
||||
// skills.
|
||||
if (!opts.seedTools) {
|
||||
if (!tools) tools = new ToolRegistry();
|
||||
if (!tools) tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
|
||||
registerMemoryTools(tools, {});
|
||||
// `ask_choice` — branching primitive, useful in chat too (stylistic
|
||||
// preferences, doc language, library picks). Independent of plan
|
||||
|
||||
@@ -91,7 +91,10 @@ function checkProxy(): Check[] {
|
||||
},
|
||||
];
|
||||
}
|
||||
const resolved = resolveNoProxy(process.env, { extraNoProxy: cfg.noProxy });
|
||||
const resolved = resolveNoProxy(process.env, {
|
||||
extraNoProxy: cfg.noProxy,
|
||||
bypassDeepSeekDirect: cfg.bypassDeepSeekDirect,
|
||||
});
|
||||
const total = resolved.all.length;
|
||||
const sourceSummary = [
|
||||
`defaults ${resolved.defaults.length}`,
|
||||
|
||||
+31
-24
@@ -1,4 +1,5 @@
|
||||
import { defaultConfigPath, readConfig, writeConfig } from "../../config.js";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { MCP_CATALOG, mcpCommandFor } from "../../mcp/catalog.js";
|
||||
import {
|
||||
type FetchProgress,
|
||||
@@ -95,7 +96,7 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
|
||||
console.log(JSON.stringify(MCP_CATALOG, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log("Bundled MCP servers (offline catalog):");
|
||||
console.log(t("mcpCli.bundledCatalog"));
|
||||
console.log("");
|
||||
for (const entry of MCP_CATALOG) {
|
||||
console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
|
||||
@@ -141,9 +142,9 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
|
||||
}
|
||||
|
||||
const ageStr = result.fromCache
|
||||
? `cached, ${fmtAge(Date.now() - result.fetchedAt)}`
|
||||
: "just fetched";
|
||||
const moreStr = result.hasMore ? "more available" : "all loaded";
|
||||
? t("mcpCli.cachedAge", { age: fmtAge(Date.now() - result.fetchedAt) })
|
||||
: t("mcpCli.justFetched");
|
||||
const moreStr = result.hasMore ? t("mcpCli.moreAvailable") : t("mcpCli.allLoaded");
|
||||
console.log(
|
||||
`MCP servers from ${result.source} registry (${result.entries.length} loaded, ${moreStr}, ${ageStr}):`,
|
||||
);
|
||||
@@ -153,15 +154,13 @@ export async function mcpListCommand(opts: McpListOptions = {}): Promise<void> {
|
||||
console.log("");
|
||||
for (const e of shown) printEntry(e);
|
||||
if (ranked.length > limit) {
|
||||
console.log(
|
||||
` … ${ranked.length - limit} more loaded — use \`reasonix mcp search <query>\` to filter`,
|
||||
);
|
||||
console.log(t("mcpCli.moreLoaded", { count: ranked.length - limit }));
|
||||
}
|
||||
if (result.hasMore) {
|
||||
console.log(" ▸ more pages available — `reasonix mcp list --pages <n>` or --all");
|
||||
console.log(t("mcpCli.morePagesAvailable"));
|
||||
}
|
||||
console.log("");
|
||||
console.log("Install: reasonix mcp install <name>");
|
||||
console.log(t("mcpCli.installHint"));
|
||||
}
|
||||
|
||||
function matchFilter(query: string): (e: RegistryEntry) => boolean {
|
||||
@@ -172,7 +171,7 @@ function matchFilter(query: string): (e: RegistryEntry) => boolean {
|
||||
export async function mcpSearchCommand(query: string, opts: McpSearchOptions = {}): Promise<void> {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
console.error("usage: reasonix mcp search <query>");
|
||||
console.error(t("mcpCli.usageSearch"));
|
||||
process.exit(1);
|
||||
}
|
||||
const handle = await openRegistry({ noCache: opts.refresh, onProgress: progressToStderr });
|
||||
@@ -212,18 +211,22 @@ export async function mcpSearchCommand(query: string, opts: McpSearchOptions = {
|
||||
|
||||
if (shown.length === 0) {
|
||||
console.log(
|
||||
`No matches for "${q}" across ${result.entries.length} loaded entries (${result.source}${
|
||||
result.hasMore ? ", more pages exist — try --refresh or `mcp list --all`" : ""
|
||||
}).`,
|
||||
t("mcpCli.noMatchesFor", { q, count: result.entries.length, source: result.source }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${matches.length} match(es) for "${q}" in ${result.source} registry (${result.entries.length} entries scanned):`,
|
||||
t("mcpCli.matchCount", {
|
||||
count: matches.length,
|
||||
q,
|
||||
source: result.source,
|
||||
loaded: result.entries.length,
|
||||
}),
|
||||
);
|
||||
console.log("");
|
||||
for (const e of shown) printEntry(e);
|
||||
if (matches.length > limit) console.log(` … ${matches.length - limit} more matches`);
|
||||
if (matches.length > limit)
|
||||
console.log(t("mcpCli.moreMatches", { count: matches.length - limit }));
|
||||
}
|
||||
|
||||
function findEntry(entries: RegistryEntry[], name: string): RegistryEntry | null {
|
||||
@@ -240,7 +243,7 @@ function findEntry(entries: RegistryEntry[], name: string): RegistryEntry | null
|
||||
export async function mcpInstallCommand(name: string, opts: McpInstallOptions = {}): Promise<void> {
|
||||
const target = name.trim();
|
||||
if (!target) {
|
||||
console.error("usage: reasonix mcp install <name>");
|
||||
console.error(t("mcpCli.usageInstall"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -263,10 +266,14 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
|
||||
const entry = findEntry(handle.cache.entries, target);
|
||||
if (!entry) {
|
||||
console.error(
|
||||
`No MCP server named "${target}" found after walking ${handle.cache.pagination.pagesLoaded} page(s) of the ${handle.source} registry.`,
|
||||
t("mcpCli.noServerFound", {
|
||||
target,
|
||||
pages: handle.cache.pagination.pagesLoaded,
|
||||
source: handle.source,
|
||||
}),
|
||||
);
|
||||
if (handle.cache.pagination.nextCursor !== null) {
|
||||
console.error(`Try: reasonix mcp install ${target} --max-pages 100`);
|
||||
console.error(t("mcpCli.noServerTryMore", { target }));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -278,9 +285,7 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
|
||||
}
|
||||
|
||||
if (!entry.install) {
|
||||
console.error(
|
||||
`Could not derive install metadata for "${entry.name}" — try \`npx -y @smithery/cli install ${entry.name}\` directly.`,
|
||||
);
|
||||
console.error(t("mcpCli.noInstallMeta", { name: entry.name }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -288,20 +293,22 @@ export async function mcpInstallCommand(name: string, opts: McpInstallOptions =
|
||||
try {
|
||||
spec = specStringFor(entry.name, entry.install);
|
||||
} catch (err) {
|
||||
console.error(`Cannot build install spec for ${entry.name}: ${(err as Error).message}`);
|
||||
console.error(
|
||||
t("mcpCli.buildSpecFailed", { name: entry.name, message: (err as Error).message }),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const cfg = readConfig();
|
||||
const existing = cfg.mcp ?? [];
|
||||
if (existing.includes(spec)) {
|
||||
console.log(`Already installed: ${spec}`);
|
||||
console.log(t("mcpCli.alreadyInstalled", { spec }));
|
||||
return;
|
||||
}
|
||||
const next = { ...cfg, mcp: [...existing, spec] };
|
||||
writeConfig(next);
|
||||
|
||||
console.log(`Installed: ${entry.name}`);
|
||||
console.log(t("mcpCli.installed", { spec: entry.name }));
|
||||
console.log(` spec: ${spec}`);
|
||||
const installedName = parseInstalledName(spec);
|
||||
if (entry.install.requiredEnv?.length) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { listSessions, pruneStaleSessions } from "../../memory/session.js";
|
||||
|
||||
export interface PruneSessionsOptions {
|
||||
@@ -8,30 +9,30 @@ export interface PruneSessionsOptions {
|
||||
export function pruneSessionsCommand(opts: PruneSessionsOptions): void {
|
||||
const days = opts.days ?? 90;
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
console.error(`--days must be a positive integer (got ${days}).`);
|
||||
console.error(t("sessions.daysInvalid", { days }));
|
||||
process.exit(1);
|
||||
}
|
||||
if (opts.dryRun) {
|
||||
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const stale = listSessions().filter((s) => s.mtime.getTime() < cutoff);
|
||||
if (stale.length === 0) {
|
||||
console.log(`no sessions idle ≥${days} days. Nothing would be pruned.`);
|
||||
console.log(t("sessions.noIdleSessions", { days }));
|
||||
return;
|
||||
}
|
||||
console.log(`would prune ${stale.length} session(s) idle ≥${days} days:`);
|
||||
console.log(t("sessions.wouldPrune", { count: stale.length, days }));
|
||||
for (const s of stale) {
|
||||
console.log(` ${s.name}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("re-run without --dry-run to actually delete.");
|
||||
console.log(t("sessions.dryRunHint"));
|
||||
return;
|
||||
}
|
||||
const removed = pruneStaleSessions(days);
|
||||
if (removed.length === 0) {
|
||||
console.log(`no sessions idle ≥${days} days. Nothing pruned.`);
|
||||
console.log(t("sessions.noIdleSessions", { days }));
|
||||
return;
|
||||
}
|
||||
console.log(`pruned ${removed.length} session(s) idle ≥${days} days:`);
|
||||
console.log(t("sessions.prunedCount", { count: removed.length, days }));
|
||||
for (const name of removed) {
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isPlausibleKey,
|
||||
loadApiKey,
|
||||
loadBaseUrl,
|
||||
loadToolRateLimit,
|
||||
normalizeMcpConfig,
|
||||
readConfig,
|
||||
saveApiKey,
|
||||
@@ -82,7 +83,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
let tools: ToolRegistry | undefined;
|
||||
let successCount = 0;
|
||||
if (normalizedSpecs.length > 0) {
|
||||
tools = new ToolRegistry();
|
||||
tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
|
||||
for (const spec of normalizedSpecs) {
|
||||
let label = "anon";
|
||||
let mcp: McpClient | undefined;
|
||||
|
||||
@@ -23,7 +23,7 @@ function listAll(): void {
|
||||
console.log(t("sessions.emptyHint"));
|
||||
return;
|
||||
}
|
||||
console.log("Saved sessions (~/.reasonix/sessions/):");
|
||||
console.log(t("sessions.listHeader"));
|
||||
console.log("");
|
||||
console.log(` ${"name".padEnd(22)} ${"msgs".padStart(6)} ${"size".padStart(8)} modified`);
|
||||
console.log(` ${"─".repeat(60)}`);
|
||||
@@ -37,16 +37,16 @@ function listAll(): void {
|
||||
if (details.length > 0) console.log(` ${details.join(" · ")}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log("Inspect: reasonix sessions <name>");
|
||||
console.log("Resume: reasonix chat --session <name>");
|
||||
console.log(t("sessions.inspectHint"));
|
||||
console.log(t("sessions.resumeHint"));
|
||||
}
|
||||
|
||||
function inspectSession(name: string, verbose: boolean): void {
|
||||
const path = sessionPath(name);
|
||||
const messages = loadSessionMessages(name);
|
||||
if (messages.length === 0) {
|
||||
console.error(`no session named "${name}" (or it's empty).`);
|
||||
console.error(`looked at: ${path}`);
|
||||
console.error(t("sessions.noSession", { name }));
|
||||
console.error(t("sessions.lookedAt", { path }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const cfgProxy = loadProxyConfig();
|
||||
installProxyIfConfigured(process.env, {
|
||||
disabled: cliNoProxy || cfgProxy.disabled === true,
|
||||
extraNoProxy: cfgProxy.noProxy,
|
||||
bypassDeepSeekDirect: cfgProxy.bypassDeepSeekDirect,
|
||||
});
|
||||
|
||||
markPhase("cli_module_loaded");
|
||||
|
||||
+488
-529
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
|
||||
import type { ChoiceOption } from "../../tools/choice.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
|
||||
export type ChoiceConfirmChoice =
|
||||
| { kind: "pick"; optionId: string }
|
||||
@@ -23,9 +22,6 @@ const CUSTOM_VALUE = "__custom__";
|
||||
const CANCEL_VALUE = "__cancel__";
|
||||
|
||||
function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: ChoiceConfirmProps) {
|
||||
const optionRows = options.length + (allowCustom ? 1 : 0) + 1; // +1 for cancel
|
||||
useReserveRows("modal", { min: 6, max: Math.max(10, optionRows + 6) });
|
||||
|
||||
const items: Array<{ value: string; label: string; hint?: string }> = options.map((opt) => ({
|
||||
value: opt.id,
|
||||
label: `${opt.id} · ${opt.title}`,
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* Extracted from App.tsx per #565 Phase 2.
|
||||
*/
|
||||
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import { Box, Text } from "ink";
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
import type { EditMode } from "../../config.js";
|
||||
import type { JobRegistry } from "../../tools/jobs.js";
|
||||
@@ -20,8 +19,6 @@ import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
|
||||
import { StatusRow } from "./layout/StatusRow.js";
|
||||
import { formatLoopStatus } from "./loop.js";
|
||||
import { useChatScrollState } from "./state/chat-scroll-provider.js";
|
||||
import { FG, SURFACE } from "./theme/tokens.js";
|
||||
|
||||
import type { StatusBarConfig } from "./layout/StatusRow.js";
|
||||
|
||||
@@ -48,6 +45,7 @@ export interface ComposerAreaProps {
|
||||
input: string;
|
||||
setInput: (next: string) => void;
|
||||
busy: boolean;
|
||||
steerBusy?: boolean;
|
||||
onSubmit: (raw: string) => Promise<void>;
|
||||
onHistoryPrev: () => void;
|
||||
onHistoryNext: () => void;
|
||||
@@ -72,25 +70,6 @@ export interface ComposerAreaProps {
|
||||
slashArgSelected: number;
|
||||
}
|
||||
|
||||
// ── History scroll hint ────────────────────────────────────────────
|
||||
|
||||
const HistoryHint: React.FC<{ children: React.ReactNode }> = React.memo(({ children }) => {
|
||||
const pinned = useChatScrollState((s: { pinned: boolean }) => s.pinned);
|
||||
const { stdout } = useStdout();
|
||||
if (!pinned) {
|
||||
const text = "scrolled up — reading history — End / PgDn to return — ↓ to advance one line";
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const pad = Math.max(0, cols - stringWidth(text));
|
||||
return (
|
||||
<Text color={FG.faint} backgroundColor={SURFACE.bgElev}>
|
||||
{text + " ".repeat(pad)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
});
|
||||
HistoryHint.displayName = "HistoryHint";
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────
|
||||
|
||||
export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
|
||||
@@ -109,6 +88,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
|
||||
input,
|
||||
setInput,
|
||||
busy,
|
||||
steerBusy,
|
||||
onSubmit,
|
||||
onHistoryPrev,
|
||||
onHistoryNext,
|
||||
@@ -155,6 +135,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
|
||||
onChange={setInput}
|
||||
onSubmit={onSubmit}
|
||||
disabled={busy}
|
||||
steerBusy={steerBusy}
|
||||
onHistoryPrev={onHistoryPrev}
|
||||
onHistoryNext={onHistoryNext}
|
||||
onOpenExternalEditor={onOpenExternalEditor}
|
||||
@@ -168,7 +149,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
|
||||
</Box>
|
||||
);
|
||||
|
||||
return <HistoryHint>{inputArea}</HistoryHint>;
|
||||
return inputArea;
|
||||
},
|
||||
);
|
||||
ComposerArea.displayName = "ComposerArea";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { formatEditBlockSplit } from "../../code/diff-preview.js";
|
||||
import type { EditBlock } from "../../code/edit-blocks.js";
|
||||
@@ -7,7 +7,6 @@ import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SplitDiff } from "./SplitDiff.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useReserveRows, useTotalRows } from "./layout/viewport-budget.js";
|
||||
|
||||
export type EditReviewChoice = "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto";
|
||||
|
||||
@@ -20,11 +19,9 @@ const MODAL_OVERHEAD_ROWS = 18;
|
||||
const MIN_DIFF_ROWS = 8;
|
||||
|
||||
export function EditConfirm({ block, onChoose }: EditConfirmProps) {
|
||||
const rows = useTotalRows();
|
||||
const allocated = useReserveRows("modal", {
|
||||
min: MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS,
|
||||
max: Math.max(MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS, rows - 4),
|
||||
});
|
||||
const { stdout } = useStdout();
|
||||
const rows = stdout?.rows ?? 40;
|
||||
const allocated = Math.max(MODAL_OVERHEAD_ROWS + MIN_DIFF_ROWS, rows - 4);
|
||||
const budget = Math.max(MIN_DIFF_ROWS, allocated - MODAL_OVERHEAD_ROWS);
|
||||
|
||||
const allRows = useMemo(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
|
||||
import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
|
||||
export type PathConfirmChoice = "run_once" | "always_allow" | "deny";
|
||||
@@ -16,8 +15,6 @@ export interface PathConfirmProps {
|
||||
}
|
||||
|
||||
export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
|
||||
useReserveRows("modal", { min: 8, max: 14 });
|
||||
|
||||
const [phase, setPhase] = useState<"pick" | "deny">("pick");
|
||||
|
||||
if (phase === "deny") {
|
||||
@@ -39,12 +36,26 @@ export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
|
||||
|
||||
const path = prompt.subtitle ?? "";
|
||||
const allowPrefix = String(prompt.data?.prefix ?? "");
|
||||
// prompt.kind collapses read/write into "path"; recover from data.intent.
|
||||
const intent = prompt.data?.intent === "write" ? "write" : "read";
|
||||
const localTitle =
|
||||
intent === "write" ? t("pathConfirm.promptTitleWrite") : t("pathConfirm.promptTitleRead");
|
||||
const localActionLabel = (id: string, fallback: string): string => {
|
||||
if (id === "run_once") {
|
||||
return intent === "write"
|
||||
? t("pathConfirm.actionAllowWrite")
|
||||
: t("pathConfirm.actionAllowRead");
|
||||
}
|
||||
if (id === "always_allow") return t("pathConfirm.actionAlwaysAllow", { prefix: allowPrefix });
|
||||
if (id === "deny") return t("pathConfirm.actionDeny");
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone={prompt.tone}
|
||||
glyph="!"
|
||||
title={prompt.title}
|
||||
title={localTitle}
|
||||
metaRight={t("pathConfirm.awaiting")}
|
||||
footerHint={t("pathConfirm.pickFooter")}
|
||||
>
|
||||
@@ -54,7 +65,7 @@ export function PathConfirm({ prompt, onChoose }: PathConfirmProps) {
|
||||
<InfoRows path={path} sandboxRoot={prompt.meta?.sandboxRoot} allowPrefix={allowPrefix} />
|
||||
<SingleSelect
|
||||
initialValue={prompt.actions[0]?.id ?? "run_once"}
|
||||
items={prompt.actions.map((a) => ({ value: a.id, label: a.label }))}
|
||||
items={prompt.actions.map((a) => ({ value: a.id, label: localActionLabel(a.id, a.label) }))}
|
||||
onSubmit={(v) => {
|
||||
const action = prompt.actions.find((a) => a.id === v);
|
||||
if (action?.secondaryInput) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { PlanStep } from "../../tools/plan.js";
|
||||
import { PlanStepList, type StepStatus } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
|
||||
export type CheckpointChoice = "continue" | "revise" | "stop";
|
||||
|
||||
@@ -32,9 +31,6 @@ function PlanCheckpointConfirmInner({
|
||||
completedStepIds,
|
||||
onChoose,
|
||||
}: PlanCheckpointConfirmProps) {
|
||||
const stepRows = steps?.length ?? 0;
|
||||
useReserveRows("modal", { min: 10, max: Math.max(14, stepRows + 12) });
|
||||
|
||||
const label = title ? `${stepId} · ${title}` : stepId;
|
||||
const counter = total > 0 ? `${completed}/${total}` : "";
|
||||
const isLast = total > 0 && completed >= total;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { PlanStepList } from "./PlanStepList.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useReserveRows, useTotalRows } from "./layout/viewport-budget.js";
|
||||
import { MarkdownView } from "./markdown-view.js";
|
||||
import { extractOpenQuestionsSection } from "./plan-open-questions.js";
|
||||
import type { KeyEvent } from "./stdin-reader.js";
|
||||
@@ -31,7 +30,7 @@ const EXPANDED_DETAIL_CHROME_ROWS = 4;
|
||||
|
||||
function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps) {
|
||||
const { stdout } = useStdout();
|
||||
const totalRows = useTotalRows();
|
||||
const totalRows = stdout?.rows ?? 40;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [detailOffset, setDetailOffset] = useState(0);
|
||||
const stepRows = steps?.length ?? 0;
|
||||
@@ -44,12 +43,9 @@ function PlanConfirmInner({ plan, steps, summary, onChoose }: PlanConfirmProps)
|
||||
);
|
||||
|
||||
const oqRows = openQuestions ? Math.min(openQuestions.split("\n").length, 8) : 0;
|
||||
const modalRows = useReserveRows("modal", {
|
||||
min: 10,
|
||||
max: expanded
|
||||
? Math.max(10, totalRows - EXPANDED_DETAIL_CHROME_ROWS)
|
||||
: Math.max(16, Math.min(32, (hasSteps ? stepRows + 2 : 2) + oqRows + 14)),
|
||||
});
|
||||
const modalRows = expanded
|
||||
? Math.max(10, totalRows - EXPANDED_DETAIL_CHROME_ROWS)
|
||||
: Math.max(16, Math.min(32, (hasSteps ? stepRows + 2 : 2) + oqRows + 14));
|
||||
const detailViewRows = expanded
|
||||
? Math.max(10, modalRows - EXPANDED_MODAL_OVERHEAD_ROWS)
|
||||
: Math.max(
|
||||
|
||||
+36
-54
@@ -2,7 +2,6 @@ import { Box, Text, useStdout } from "ink";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../../i18n/index.js";
|
||||
import { useKeystroke } from "./keystroke-context.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { type MultilineKey, lineAndColumn, processMultilineKey } from "./multiline-keys.js";
|
||||
import {
|
||||
PASTE_SENTINEL_RANGE,
|
||||
@@ -44,6 +43,7 @@ export interface PromptInputProps {
|
||||
onSubmit: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
steerBusy?: boolean;
|
||||
/** ↑/↓ / Ctrl+N hand off here when no in-buffer cursor move applies — parent walks history and swaps `value` via `onChange`. */
|
||||
onHistoryPrev?: () => void;
|
||||
onHistoryNext?: () => void;
|
||||
@@ -64,6 +64,7 @@ export function PromptInput({
|
||||
onSubmit,
|
||||
disabled,
|
||||
placeholder,
|
||||
steerBusy,
|
||||
onHistoryPrev,
|
||||
onHistoryNext,
|
||||
onOpenExternalEditor,
|
||||
@@ -72,14 +73,6 @@ export function PromptInput({
|
||||
mode,
|
||||
model,
|
||||
}: PromptInputProps) {
|
||||
// Cap at 24 — collapseLinesForDisplay hides content past ~20 logical lines.
|
||||
// Quantize spec.max to 4-row buckets so per-keystroke line-count changes
|
||||
// don't churn viewport-budget; without this every single character that
|
||||
// adds/removes a newline re-dispatches the allocator and reflows layout.
|
||||
const inputLineCount = value.length > 0 ? value.split("\n").length : 1;
|
||||
const reserveMax = Math.min(Math.ceil(inputLineCount / 4) * 4 + 3, 24);
|
||||
useReserveRows("input", { min: 1, max: reserveMax });
|
||||
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -126,8 +119,11 @@ export function PromptInput({
|
||||
setCursor(c + insertion.length);
|
||||
};
|
||||
|
||||
const inputFrozen = disabled && !steerBusy;
|
||||
const inputActive = !disabled || !!steerBusy;
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (disabled) return;
|
||||
if (inputFrozen) return;
|
||||
if (ev.paste) {
|
||||
// Bracketed-paste content delivered by the stdin reader.
|
||||
if (ev.input.length > 0) registerPaste(ev.input);
|
||||
@@ -184,7 +180,7 @@ export function PromptInput({
|
||||
if (action.historyHandoff === "prev") onHistoryPrev?.();
|
||||
if (action.historyHandoff === "next") onHistoryNext?.();
|
||||
if (action.openExternalEditor) onOpenExternalEditor?.();
|
||||
}, !disabled);
|
||||
}, inputActive);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -197,48 +193,20 @@ export function PromptInput({
|
||||
|
||||
// Hint avoids literal `/` and `@` glyphs — they render in the same row as
|
||||
// a just-cleared buffer and read as residual typed input on dim-poor terminals.
|
||||
const effectivePlaceholder = disabled
|
||||
? (placeholder ?? t("composer.waitingForResponse"))
|
||||
: (placeholder ?? t("composer.placeholder"));
|
||||
const effectivePlaceholder = steerBusy
|
||||
? t("composer.steerPlaceholder")
|
||||
: disabled
|
||||
? (placeholder ?? t("composer.waitingForResponse"))
|
||||
: (placeholder ?? t("composer.placeholder"));
|
||||
|
||||
const lines = value.length > 0 ? value.split("\n") : [""];
|
||||
const accentColor = disabled ? FG.faint : TONE.brand;
|
||||
const accentColor = steerBusy ? TONE.brand : disabled ? FG.faint : TONE.brand;
|
||||
const cursorVisible = true;
|
||||
const { line: cursorLine, col: cursorCol } = lineAndColumn(value, cursor);
|
||||
|
||||
const renderItems = collapseLinesForDisplay(lines, cursorLine);
|
||||
const showHugeBufferHints = lines.length > 20;
|
||||
|
||||
// IME cursor sync. Ink writes a full ANSI frame each render; the
|
||||
// terminal cursor ends up at the bottom of that frame and IMEs
|
||||
// (fcitx5 / ibus on Linux, Microsoft Pinyin on Windows) query it for
|
||||
// candidate-window placement. Without this, popups land at screen
|
||||
// bottom instead of next to the visual ▌. Issue #1261.
|
||||
const cursorLineText = lines[cursorLine] ?? "";
|
||||
const cursorCellsInLine = stringCells(cursorLineText.slice(0, cursorCol), pastesRef.current);
|
||||
useEffect(() => {
|
||||
if (!stdout || disabled) return;
|
||||
const totalRows = stdout.rows;
|
||||
if (!totalRows || totalRows < 4) return;
|
||||
const linesBelow = Math.max(0, lines.length - 1 - cursorLine);
|
||||
const largeHint = showHugeBufferHints ? 1 : 0;
|
||||
// Inside our bordered Box, below the visual cursor: remaining input
|
||||
// lines, optional huge-hint, blank marginTop, HintRow, bottom border.
|
||||
const rowsBelow = linesBelow + largeHint + 3 + rowsAfter;
|
||||
const targetRow = Math.max(1, totalRows - rowsBelow);
|
||||
// Column 1-indexed. No left border, paddingX=1, prompt prefix "› " = 2 cells.
|
||||
const targetCol = 1 + 1 + 2 + cursorCellsInLine;
|
||||
stdout.write(`\x1b[${targetRow};${targetCol}H`);
|
||||
}, [
|
||||
stdout,
|
||||
disabled,
|
||||
cursorLine,
|
||||
cursorCellsInLine,
|
||||
lines.length,
|
||||
showHugeBufferHints,
|
||||
rowsAfter,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={1} backgroundColor="#0153e5" />
|
||||
@@ -270,7 +238,7 @@ export function PromptInput({
|
||||
key={`ln-${i}-text-0`}
|
||||
line=""
|
||||
isFirst={true}
|
||||
isCursorLine={isCursorLine && !disabled}
|
||||
isCursorLine={isCursorLine && inputActive}
|
||||
cursorCol={isCursorLine ? cursorCol : null}
|
||||
cursorVisible={cursorVisible}
|
||||
showPlaceholder
|
||||
@@ -281,6 +249,7 @@ export function PromptInput({
|
||||
accentColor={accentColor}
|
||||
pastes={pastesRef.current}
|
||||
disabled={disabled === true}
|
||||
steerBusy={steerBusy}
|
||||
/>,
|
||||
);
|
||||
firstRowEmitted = true;
|
||||
@@ -300,7 +269,7 @@ export function PromptInput({
|
||||
entry={pastesRef.current.get(seg.id)}
|
||||
pasteId={seg.id}
|
||||
isFirst={isFirst}
|
||||
active={cursorOnIt && !disabled}
|
||||
active={cursorOnIt && inputActive}
|
||||
visibleCells={visibleCells}
|
||||
accentColor={accentColor}
|
||||
/>,
|
||||
@@ -316,7 +285,7 @@ export function PromptInput({
|
||||
key={`ln-${i}-text-${segIdx}`}
|
||||
line={seg.text}
|
||||
isFirst={isFirst}
|
||||
isCursorLine={segHasCursor && !disabled}
|
||||
isCursorLine={segHasCursor && inputActive}
|
||||
cursorCol={segHasCursor ? cursorCol - seg.startOffset : null}
|
||||
cursorVisible={cursorVisible}
|
||||
showPlaceholder={false}
|
||||
@@ -327,6 +296,7 @@ export function PromptInput({
|
||||
accentColor={accentColor}
|
||||
pastes={pastesRef.current}
|
||||
disabled={disabled === true}
|
||||
steerBusy={steerBusy}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -338,7 +308,7 @@ export function PromptInput({
|
||||
key={`ln-${i}-empty`}
|
||||
line=""
|
||||
isFirst={isFirst}
|
||||
isCursorLine={isCursorLine && !disabled}
|
||||
isCursorLine={isCursorLine && inputActive}
|
||||
cursorCol={isCursorLine ? 0 : null}
|
||||
cursorVisible={cursorVisible}
|
||||
showPlaceholder={false}
|
||||
@@ -349,13 +319,14 @@ export function PromptInput({
|
||||
accentColor={accentColor}
|
||||
pastes={pastesRef.current}
|
||||
disabled={disabled === true}
|
||||
steerBusy={steerBusy}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
})()}
|
||||
{showHugeBufferHints && !disabled ? (
|
||||
{showHugeBufferHints && inputActive ? (
|
||||
<Box>
|
||||
<Text color={FG.faint}>
|
||||
{` [${lines.length} lines · PgUp/PgDn jump · Ctrl+U clear · Ctrl+W del word]`}
|
||||
@@ -371,11 +342,19 @@ export function PromptInput({
|
||||
</Box>
|
||||
) : null}
|
||||
<Box height={1} />
|
||||
{disabled ? (
|
||||
{inputFrozen ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={FG.faint}>{" esc to stop"}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{steerBusy ? (
|
||||
<Box marginTop={1} flexDirection="row">
|
||||
<Text color={TONE.accent}>{" \u23ce "}</Text>
|
||||
<Text color={FG.faint}>{t("composer.steerHint")}</Text>
|
||||
<Text color={FG.faint}>{" · "}</Text>
|
||||
<Text color={FG.faint}>{"esc to stop"}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -384,7 +363,7 @@ export function PromptInput({
|
||||
export function HintRow(): React.ReactElement {
|
||||
const items: Array<{ key: string; tKey: string }> = [
|
||||
{ key: "\u23ce", tKey: "composer.hintSend" },
|
||||
{ key: "\u21e7\u23ce", tKey: "composer.hintNewline" },
|
||||
{ key: "\u21e7\u23ce / ^J", tKey: "composer.hintNewline" },
|
||||
{ key: "^U", tKey: "composer.hintClear" },
|
||||
{ key: "\u2191\u2193", tKey: "composer.hintHistory" },
|
||||
{ key: "esc", tKey: "composer.hintAbort" },
|
||||
@@ -487,7 +466,7 @@ function formatChipLabel(entry: PasteEntry | undefined, pasteId: number, budget:
|
||||
const lines = `${entry.lineCount} line${entry.lineCount === 1 ? "" : "s"}`;
|
||||
const bytes = formatBytesShort(entry.charCount);
|
||||
const kind = sniffChipKind(entry.content);
|
||||
const full = `📋 pasted ${lines} · ${bytes} · ${kind} ^O expand · ⌫ remove`;
|
||||
const full = `📋 pasted ${lines} · ${bytes} · ${kind} ⌫ remove`;
|
||||
if (full.length <= Math.max(40, budget)) return full;
|
||||
const compact = `📋 pasted ${lines} · ${bytes} · ${kind}`;
|
||||
if (compact.length <= Math.max(30, budget)) return compact;
|
||||
@@ -527,6 +506,7 @@ interface PromptLineProps {
|
||||
accentColor: string;
|
||||
pastes: ReadonlyMap<number, PasteEntry>;
|
||||
disabled: boolean;
|
||||
steerBusy?: boolean;
|
||||
}
|
||||
|
||||
function PromptLine({
|
||||
@@ -543,14 +523,16 @@ function PromptLine({
|
||||
accentColor,
|
||||
pastes,
|
||||
disabled,
|
||||
steerBusy,
|
||||
}: PromptLineProps) {
|
||||
const promptActive = !disabled || !!steerBusy;
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color={accentColor}>
|
||||
{promptPrefix}
|
||||
</Text>
|
||||
{!disabled ? <Text color={accentColor}>{cursorVisible ? "▌" : " "}</Text> : null}
|
||||
{promptActive ? <Text color={accentColor}>{cursorVisible ? "▌" : " "}</Text> : null}
|
||||
<Text color={FG.faint}>{placeholderText}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { t } from "../../i18n/index.js";
|
||||
import { DenyContextInput } from "./DenyContextInput.js";
|
||||
import { SingleSelect } from "./Select.js";
|
||||
import { ApprovalCard } from "./cards/ApprovalCard.js";
|
||||
import { useReserveRows } from "./layout/viewport-budget.js";
|
||||
import { FG } from "./theme/tokens.js";
|
||||
|
||||
export type ShellConfirmChoice = "run_once" | "always_allow" | "deny";
|
||||
@@ -16,7 +15,6 @@ export interface ShellConfirmProps {
|
||||
}
|
||||
|
||||
export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
|
||||
useReserveRows("modal", { min: 8, max: 14 });
|
||||
const [phase, setPhase] = useState<"pick" | "deny">("pick");
|
||||
|
||||
if (phase === "deny") {
|
||||
@@ -36,11 +34,26 @@ export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// toApprovalPrompt mints English labels for the ACP surface; the unified
|
||||
// prompt.kind collapses foreground + background into "shell", so we recover
|
||||
// the split from meta.wait (only set on background prompts).
|
||||
const isBackground = prompt.meta?.wait !== undefined;
|
||||
const prefix = String(prompt.data?.prefix ?? "");
|
||||
const localTitle = isBackground
|
||||
? t("shellConfirm.promptTitleRunBackground")
|
||||
: t("shellConfirm.promptTitleRunCommand");
|
||||
const localActionLabel = (id: string, fallback: string): string => {
|
||||
if (id === "run_once") return t("shellConfirm.actionRunOnce");
|
||||
if (id === "always_allow") return t("shellConfirm.actionAlwaysAllow", { prefix });
|
||||
if (id === "deny") return t("shellConfirm.actionDeny");
|
||||
return fallback;
|
||||
};
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
tone={prompt.tone}
|
||||
glyph="?"
|
||||
title={prompt.title}
|
||||
title={localTitle}
|
||||
metaRight={t("shellConfirm.awaiting")}
|
||||
footerHint={t("shellConfirm.pickFooter")}
|
||||
>
|
||||
@@ -57,7 +70,7 @@ export function ShellConfirm({ prompt, onChoose }: ShellConfirmProps) {
|
||||
<InfoRows meta={prompt.meta} />
|
||||
<SingleSelect
|
||||
initialValue={prompt.actions[0]?.id ?? "run_once"}
|
||||
items={prompt.actions.map((a) => ({ value: a.id, label: a.label }))}
|
||||
items={prompt.actions.map((a) => ({ value: a.id, label: localActionLabel(a.id, a.label) }))}
|
||||
onSubmit={(v) => {
|
||||
const action = prompt.actions.find((a) => a.id === v);
|
||||
if (action?.secondaryInput) {
|
||||
|
||||
@@ -20,6 +20,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
items: [
|
||||
{ keys: "Enter", descKey: "shortcutsHelp.descEnter" },
|
||||
{ keys: "Shift+Enter", descKey: "shortcutsHelp.descShiftEnter" },
|
||||
{ keys: "Ctrl+Enter", descKey: "shortcutsHelp.descCtrlEnter" },
|
||||
{ keys: "Ctrl+J", descKey: "shortcutsHelp.descCtrlJ" },
|
||||
{ keys: "Ctrl+U", descKey: "shortcutsHelp.descCtrlU" },
|
||||
{ keys: "Ctrl+W", descKey: "shortcutsHelp.descCtrlW" },
|
||||
{ keys: "Ctrl+P", descKey: "shortcutsHelp.descCtrlP" },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import { clipToCells } from "../../../frame/width.js";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader, type MetaItem } from "../primitives/CardHeader.js";
|
||||
@@ -9,6 +8,7 @@ import { PILL_MODEL, PILL_SECTION, Pill, modelBadgeFor } from "../primitives/Pil
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { ReasoningCard as ReasoningCardData } from "../state/cards.js";
|
||||
import { VerboseContext } from "../state/verbose-context.js";
|
||||
import { clipToCells } from "../text-width.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
import { useIncrementalWrap } from "./useIncrementalWrap.js";
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React, { useContext } from "react";
|
||||
import { clipToCells } from "../../../frame/width.js";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { countTokensBounded } from "../../../tokenizer.js";
|
||||
import { LiveExpandContext } from "../layout/LiveExpandContext.js";
|
||||
import { useReserveRows } from "../layout/viewport-budget.js";
|
||||
import { Markdown } from "../markdown.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
import { CardHeader } from "../primitives/CardHeader.js";
|
||||
import { PILL_MODEL, Pill, modelBadgeFor } from "../primitives/Pill.js";
|
||||
import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { StreamingCard as StreamingCardData } from "../state/cards.js";
|
||||
import { clipToCells } from "../text-width.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
import { useSlowTick } from "../ticker.js";
|
||||
import { useIncrementalWrap } from "./useIncrementalWrap.js";
|
||||
@@ -99,11 +98,6 @@ export function StreamingCard({ card }: { card: StreamingCardData }): React.Reac
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const expanded = useContext(LiveExpandContext);
|
||||
const reserveCap = expanded ? EXPANDED_MAX_LINES + 2 : STREAMING_PREVIEW_LINES + 2;
|
||||
useReserveRows("stream", {
|
||||
min: STREAMING_PREVIEW_LINES + 1,
|
||||
max: reserveCap,
|
||||
});
|
||||
// Re-render at 1Hz so the rate keeps updating even when chunks stall.
|
||||
// Frozen once `card.done` is true — settled cards render via Static.
|
||||
useSlowTick();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import { clipToCells } from "../../../frame/width.js";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { Markdown } from "../markdown.js";
|
||||
import { Card } from "../primitives/Card.js";
|
||||
@@ -9,6 +8,7 @@ import { Spinner } from "../primitives/Spinner.js";
|
||||
import type { ToolCard as ToolCardData } from "../state/cards.js";
|
||||
import { useIsInflight } from "../state/inflight-context.js";
|
||||
import { VerboseContext } from "../state/verbose-context.js";
|
||||
import { clipToCells } from "../text-width.js";
|
||||
import { FG, TONE, TONE_ACTIVE } from "../theme/tokens.js";
|
||||
|
||||
const READ_TAIL = 2;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { wrapToCells } from "../../../frame/width.js";
|
||||
import { wrapToCells } from "../text-width.js";
|
||||
|
||||
export interface WrapCache {
|
||||
text: string;
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React as a runtime value
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { clipToCells } from "../../../frame/width.js";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { writeClipboard } from "../clipboard.js";
|
||||
import { useKeystroke } from "../keystroke-context.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { FG, TONE } from "../theme/tokens.js";
|
||||
import { type SnapshotLine, buildSnapshot, isYankable, yankRange } from "./snapshot.js";
|
||||
|
||||
export interface CopyModeProps {
|
||||
cards: ReadonlyArray<Card>;
|
||||
onClose: (yanked: { size: number; osc52: boolean; filePath: string | null } | null) => void;
|
||||
}
|
||||
|
||||
const CHROME_ROWS = 3;
|
||||
|
||||
export function CopyMode({ cards, onClose }: CopyModeProps): React.ReactElement {
|
||||
const snapshot = useMemo(() => buildSnapshot(cards), [cards]);
|
||||
const { stdout } = useStdout();
|
||||
const termRows = stdout?.rows ?? 30;
|
||||
const termCols = stdout?.columns ?? 80;
|
||||
const bodyRows = Math.max(4, termRows - CHROME_ROWS);
|
||||
|
||||
const lastYankableIdx = findLastYankable(snapshot);
|
||||
const initialCursor = findFirstYankable(snapshot);
|
||||
|
||||
const [cursor, setCursor] = useState(initialCursor);
|
||||
const [anchor, setAnchor] = useState<number | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
||||
const stepDown = (i: number) => stepBy(snapshot, i, +1);
|
||||
const stepUp = (i: number) => stepBy(snapshot, i, -1);
|
||||
|
||||
useKeystroke((ev) => {
|
||||
if (ev.escape || (ev.input === "q" && !ev.ctrl && !ev.meta)) return onClose(null);
|
||||
if (ev.input === "j" || ev.downArrow) return setCursor(stepDown(cursor));
|
||||
if (ev.input === "k" || ev.upArrow) return setCursor(stepUp(cursor));
|
||||
if (ev.pageDown) return setCursor(scrollBy(snapshot, cursor, bodyRows));
|
||||
if (ev.pageUp) return setCursor(scrollBy(snapshot, cursor, -bodyRows));
|
||||
if (ev.input === "g") return setCursor(initialCursor);
|
||||
if (ev.input === "G") return setCursor(lastYankableIdx);
|
||||
if (ev.input === "v" || ev.input === "V") {
|
||||
setAnchor((a) => (a === null ? cursor : null));
|
||||
return;
|
||||
}
|
||||
if (ev.input === "y" || ev.return) {
|
||||
const from = anchor ?? cursor;
|
||||
const to = cursor;
|
||||
const text = yankRange(snapshot, from, to).trim();
|
||||
if (text.length === 0) {
|
||||
setStatus(t("copyMode.statusEmpty"));
|
||||
return;
|
||||
}
|
||||
const w = writeClipboard(text);
|
||||
setStatus(t("copyMode.statusYanked", { size: text.length, osc52: w.osc52 ? "y" : "n" }));
|
||||
setTimeout(() => onClose(w), 600);
|
||||
}
|
||||
});
|
||||
|
||||
const window = computeWindow(snapshot, cursor, bodyRows);
|
||||
const selRange =
|
||||
anchor === null ? null : ([Math.min(anchor, cursor), Math.max(anchor, cursor)] as const);
|
||||
const totalY = countYankable(snapshot);
|
||||
const cursorY = countYankableUntil(snapshot, cursor);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={TONE.brand} bold>
|
||||
{t("copyMode.title")}
|
||||
</Text>
|
||||
<Text color={FG.faint}>{` ${t("copyMode.help")}`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
{snapshot.length === 0 ? (
|
||||
<Text color={FG.faint}>{t("copyMode.empty")}</Text>
|
||||
) : (
|
||||
window.lines.map((line, i) => {
|
||||
const idx = window.start + i;
|
||||
return (
|
||||
<CopyLine
|
||||
key={`${line.cardId}-${idx}`}
|
||||
line={line}
|
||||
cols={termCols}
|
||||
isCursor={idx === cursor}
|
||||
inSelection={selRange !== null && idx >= selRange[0] && idx <= selRange[1]}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={FG.meta}>
|
||||
{t("copyMode.statusBar", {
|
||||
cur: cursorY > 0 ? cursorY : 1,
|
||||
total: Math.max(1, totalY),
|
||||
sel: anchor === null ? "—" : String(rangeYankable(snapshot, anchor, cursor)),
|
||||
})}
|
||||
</Text>
|
||||
{status ? <Text color={TONE.ok}>{` ${status}`}</Text> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyLine({
|
||||
line,
|
||||
cols,
|
||||
isCursor,
|
||||
inSelection,
|
||||
}: {
|
||||
line: SnapshotLine;
|
||||
cols: number;
|
||||
isCursor: boolean;
|
||||
inSelection: boolean;
|
||||
}): React.ReactElement {
|
||||
const marker = isCursor ? "▸ " : " ";
|
||||
const room = Math.max(1, cols - 2);
|
||||
const display = line.kind === "blank" ? "" : clipToCells(line.text, room);
|
||||
if (line.kind === "header") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isCursor ? TONE.brand : FG.faint}>{marker}</Text>
|
||||
<Text color={FG.meta}>{display}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const color = isCursor ? TONE.brand : FG.body;
|
||||
return (
|
||||
<Box>
|
||||
<Text color={isCursor ? TONE.brand : FG.faint}>{marker}</Text>
|
||||
<Text color={color} inverse={inSelection}>
|
||||
{display.length === 0 ? " " : display}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function findFirstYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
|
||||
for (let i = 0; i < snapshot.length; i++) if (isYankable(snapshot[i])) return i;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function findLastYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
|
||||
for (let i = snapshot.length - 1; i >= 0; i--) if (isYankable(snapshot[i])) return i;
|
||||
return Math.max(0, snapshot.length - 1);
|
||||
}
|
||||
|
||||
function stepBy(snapshot: ReadonlyArray<SnapshotLine>, from: number, dir: 1 | -1): number {
|
||||
const last = snapshot.length - 1;
|
||||
let i = Math.max(0, Math.min(last, from + dir));
|
||||
while (i > 0 && i < last && snapshot[i]?.kind === "header") i += dir;
|
||||
if (i < 0) return 0;
|
||||
if (i > last) return last;
|
||||
return i;
|
||||
}
|
||||
|
||||
function scrollBy(snapshot: ReadonlyArray<SnapshotLine>, from: number, delta: number): number {
|
||||
const last = snapshot.length - 1;
|
||||
return Math.max(0, Math.min(last, from + delta));
|
||||
}
|
||||
|
||||
function computeWindow(
|
||||
snapshot: ReadonlyArray<SnapshotLine>,
|
||||
cursor: number,
|
||||
rows: number,
|
||||
): { start: number; lines: SnapshotLine[] } {
|
||||
if (snapshot.length <= rows) return { start: 0, lines: snapshot.slice() };
|
||||
const half = Math.floor(rows / 2);
|
||||
let start = Math.max(0, cursor - half);
|
||||
if (start + rows > snapshot.length) start = snapshot.length - rows;
|
||||
return { start, lines: snapshot.slice(start, start + rows) };
|
||||
}
|
||||
|
||||
function countYankable(snapshot: ReadonlyArray<SnapshotLine>): number {
|
||||
let n = 0;
|
||||
for (const line of snapshot) if (isYankable(line)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function countYankableUntil(snapshot: ReadonlyArray<SnapshotLine>, idx: number): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i <= Math.min(idx, snapshot.length - 1); i++) if (isYankable(snapshot[i])) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
function rangeYankable(snapshot: ReadonlyArray<SnapshotLine>, a: number, b: number): number {
|
||||
const lo = Math.min(a, b);
|
||||
const hi = Math.max(a, b);
|
||||
let n = 0;
|
||||
for (let i = lo; i <= hi; i++) if (isYankable(snapshot[i])) n++;
|
||||
return n;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
|
||||
export type SnapshotLineKind = "header" | "text" | "blank";
|
||||
|
||||
export interface SnapshotLine {
|
||||
readonly cardId: string;
|
||||
readonly kind: SnapshotLineKind;
|
||||
readonly role: "user" | "assistant" | "reasoning";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export function buildSnapshot(cards: ReadonlyArray<Card>): SnapshotLine[] {
|
||||
const out: SnapshotLine[] = [];
|
||||
for (const card of cards) {
|
||||
if (card.kind === "user") {
|
||||
pushCard(out, card.id, "user", t("copyMode.labelUser"), card.text);
|
||||
} else if (card.kind === "streaming") {
|
||||
pushCard(out, card.id, "assistant", t("copyMode.labelAssistant"), card.text);
|
||||
} else if (card.kind === "reasoning") {
|
||||
pushCard(out, card.id, "reasoning", t("copyMode.labelReasoning"), card.text);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pushCard(
|
||||
out: SnapshotLine[],
|
||||
cardId: string,
|
||||
role: SnapshotLine["role"],
|
||||
label: string,
|
||||
body: string,
|
||||
): void {
|
||||
if (out.length > 0) out.push({ cardId, kind: "blank", role, text: "" });
|
||||
out.push({ cardId, kind: "header", role, text: `─── ${label} ───` });
|
||||
const lines = body.length === 0 ? [""] : body.split("\n");
|
||||
for (const line of lines) out.push({ cardId, kind: "text", role, text: line });
|
||||
}
|
||||
|
||||
export function yankRange(
|
||||
snapshot: ReadonlyArray<SnapshotLine>,
|
||||
fromIdx: number,
|
||||
toIdx: number,
|
||||
): string {
|
||||
const lo = Math.min(fromIdx, toIdx);
|
||||
const hi = Math.max(fromIdx, toIdx);
|
||||
const picks: string[] = [];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const line = snapshot[i];
|
||||
if (!line) continue;
|
||||
if (line.kind === "header") continue;
|
||||
picks.push(line.text);
|
||||
}
|
||||
while (picks.length > 0 && picks[picks.length - 1] === "") picks.pop();
|
||||
while (picks.length > 0 && picks[0] === "") picks.shift();
|
||||
return picks.join("\n");
|
||||
}
|
||||
|
||||
export function isYankable(line: SnapshotLine | undefined): boolean {
|
||||
return !!line && line.kind !== "header";
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export function loopEventToDashboard(
|
||||
return { kind: "error", id, text: ev.content };
|
||||
case "status":
|
||||
return { kind: "status", text: ev.content };
|
||||
case "steer":
|
||||
return { kind: "user", id, text: ev.content };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import { type Frame, frameToAnsi } from "../../frame/index.js";
|
||||
|
||||
/** Frame → JSX. One Box per row. */
|
||||
export function renderFrame(f: Frame, keyPrefix: string): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{f.rows.map((row, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: row index is the row's identity
|
||||
<Box key={`${keyPrefix}/${i}`} height={1} flexShrink={0}>
|
||||
<Text>{frameToAnsi({ width: f.width, rows: [row] })}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { Box, type DOMElement, Text, useBoxMetrics, useStdout } from "ink";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { CardRenderer } from "../cards/CardRenderer.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { useChatScrollActions, useChatScrollState } from "../state/chat-scroll-provider.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
import { FG, SURFACE, TONE } from "../theme/tokens.js";
|
||||
|
||||
/** Buffer of rows kept rendered on each side of the viewport so a single scroll
|
||||
* step doesn't reveal an unmeasured card. Larger = smoother but renders more. */
|
||||
export const VISIBLE_BUFFER_ROWS = 30;
|
||||
|
||||
export type CardStreamItem<T> =
|
||||
| { kind: "spacer"; rows: number; key: string }
|
||||
| { kind: "card"; card: T };
|
||||
|
||||
/** Decide which cards render live vs collapse into a spacer, given the cached
|
||||
* heights and the current viewport position. Window is quantized to
|
||||
* VISIBLE_BUFFER_ROWS buckets so a single-row scrollRows / outerHeight wiggle
|
||||
* doesn't toggle a boundary card and re-trigger inner.height oscillation
|
||||
* (root cause of issues #549 / #700). */
|
||||
export function computeCardStreamItems<T extends { id: string }>(
|
||||
cards: readonly T[],
|
||||
cardHeights: ReadonlyMap<string, number>,
|
||||
scrollRows: number,
|
||||
outerHeight: number,
|
||||
): CardStreamItem<T>[] {
|
||||
const bucket = Math.floor(scrollRows / VISIBLE_BUFFER_ROWS) * VISIBLE_BUFFER_ROWS;
|
||||
const winStart = Math.max(0, bucket - VISIBLE_BUFFER_ROWS);
|
||||
const winEnd = bucket + outerHeight + VISIBLE_BUFFER_ROWS * 2;
|
||||
const out: CardStreamItem<T>[] = [];
|
||||
let cursor = 0;
|
||||
let pendingSpacer = 0;
|
||||
let spacerKey = 0;
|
||||
for (const card of cards) {
|
||||
const h = cardHeights.get(card.id);
|
||||
const cardEnd = cursor + (h ?? 0);
|
||||
const live = h === undefined || (cardEnd >= winStart && cursor <= winEnd);
|
||||
if (live) {
|
||||
if (pendingSpacer > 0) {
|
||||
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey++}` });
|
||||
pendingSpacer = 0;
|
||||
}
|
||||
out.push({ kind: "card", card });
|
||||
} else {
|
||||
pendingSpacer += h ?? 0;
|
||||
}
|
||||
cursor = cardEnd;
|
||||
}
|
||||
if (pendingSpacer > 0) {
|
||||
out.push({ kind: "spacer", rows: pendingSpacer, key: `sp-${spacerKey}` });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-precision virtual scroll with card-level virtualization.
|
||||
*
|
||||
* outer Box clips with overflow="hidden"; inner Box holds visible cards
|
||||
* plus spacer Boxes for off-screen ranges and slides up via negative
|
||||
* marginTop. Off-screen cards are replaced by a single spacer Box of the
|
||||
* cumulative height — Yoga skips them entirely on every re-layout.
|
||||
*
|
||||
* Heights are populated lazily: any card whose height isn't cached yet
|
||||
* is rendered live (so it can be measured), then collapses into the
|
||||
* spacer once outside the viewport. A streaming card that grows on every
|
||||
* delta keeps its height fresh through the same measurement path.
|
||||
*/
|
||||
export function CardStream({
|
||||
suppressLive = false,
|
||||
}: {
|
||||
suppressLive?: boolean;
|
||||
}): React.ReactElement {
|
||||
const cards = useAgentState((s) => s.cards);
|
||||
const scrollRows = useChatScrollState((s) => s.scrollRows);
|
||||
const cardHeights = useChatScrollState((s) => s.cardHeights);
|
||||
const { setMaxScroll, setCardHeight, pruneCardHeights } = useChatScrollActions();
|
||||
const outerRef = useRef<DOMElement>(null!);
|
||||
const innerRef = useRef<DOMElement>(null!);
|
||||
const outer = useBoxMetrics(outerRef);
|
||||
const inner = useBoxMetrics(innerRef);
|
||||
const maxScroll = Math.max(0, inner.height - outer.height);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxScroll(maxScroll);
|
||||
}, [maxScroll, setMaxScroll]);
|
||||
|
||||
// Drop heights for cards no longer in the list (resumed sessions, /clear, etc).
|
||||
useEffect(() => {
|
||||
const live = new Set<string>();
|
||||
for (const c of cards) live.add(c.id);
|
||||
pruneCardHeights(live);
|
||||
}, [cards, pruneCardHeights]);
|
||||
|
||||
let visible = cards;
|
||||
if (suppressLive && cards.length > 0 && !isFullySettled(cards[cards.length - 1]!)) {
|
||||
visible = cards.slice(0, -1);
|
||||
}
|
||||
|
||||
const items = useMemo(
|
||||
() => computeCardStreamItems(visible, cardHeights, scrollRows, outer.height),
|
||||
[visible, cardHeights, scrollRows, outer.height],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Always reserve the row — making it conditional ties outer.height to scrollRows and closes a setState loop with pinned mode. */}
|
||||
<Box height={1} flexShrink={0}>
|
||||
{scrollRows > 0 ? <ScrollIndicator scrollRows={scrollRows} maxScroll={maxScroll} /> : null}
|
||||
</Box>
|
||||
<Box ref={outerRef} flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
<Box ref={innerRef} flexDirection="column" marginTop={-scrollRows} flexShrink={0}>
|
||||
{items.map((item) =>
|
||||
item.kind === "spacer" ? (
|
||||
<Box key={item.key} height={item.rows} flexShrink={0} />
|
||||
) : (
|
||||
<MeasuredCard key={item.card.id} card={item.card} report={setCardHeight} />
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Thin wrapper that captures a card's row height on every render and reports
|
||||
* it to the scroll store. Wrapping in React.memo would defeat the purpose —
|
||||
* we *want* the effect to re-run when the streaming card grows.
|
||||
*
|
||||
* Monotonic height lock (#549 / #700): for unsettled (streaming/reasoning)
|
||||
* cards, height is only reported when it INCREASES. Yoga can emit intermediate
|
||||
* shrink measurements during re-layout that would otherwise cause the virtual
|
||||
* window to oscillate. Settled cards always report the exact final height. */
|
||||
function MeasuredCard({
|
||||
card,
|
||||
report,
|
||||
}: { card: Card; report: (id: string, rows: number) => void }): React.ReactElement {
|
||||
const ref = useRef<DOMElement>(null!);
|
||||
const m = useBoxMetrics(ref);
|
||||
const lastReportedRef = useRef<number>(0);
|
||||
const settled = isCardSettled(card);
|
||||
|
||||
useEffect(() => {
|
||||
const h = m.height;
|
||||
if (h <= 0) return;
|
||||
// Dedup: skip if height hasn't changed since last report.
|
||||
if (h === lastReportedRef.current) return;
|
||||
// Monotonic lock: for unsettled cards, only report growth.
|
||||
// Yoga may emit transient shrink values during streaming re-layout
|
||||
// that would otherwise feed back into scroll position oscillation.
|
||||
if (!settled && h < lastReportedRef.current) return;
|
||||
lastReportedRef.current = h;
|
||||
report(card.id, h);
|
||||
}, [card.id, m.height, report, settled]);
|
||||
|
||||
return (
|
||||
<Box ref={ref} flexDirection="column" flexShrink={0}>
|
||||
<CardRenderer card={card} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Position indicator in the row above the viewport. Briefly highlights on every
|
||||
* scroll tick (scrollVersion bump) so the user gets visual confirmation that
|
||||
* the wheel/arrow registered, even before the new frame paints. */
|
||||
function ScrollIndicator({
|
||||
scrollRows,
|
||||
maxScroll,
|
||||
}: { scrollRows: number; maxScroll: number }): React.ReactElement {
|
||||
const version = useChatScrollState((s) => s.scrollVersion);
|
||||
const [hot, setHot] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (version === 0) return;
|
||||
setHot(true);
|
||||
const id = setTimeout(() => setHot(false), 220);
|
||||
return () => clearTimeout(id);
|
||||
}, [version]);
|
||||
const remaining = Math.max(0, maxScroll - scrollRows);
|
||||
const above =
|
||||
scrollRows === 1
|
||||
? t("cardStream.scrollAbove", { scroll: scrollRows, max: maxScroll })
|
||||
: t("cardStream.scrollAbovePlural", { scroll: scrollRows, max: maxScroll });
|
||||
const more = remaining > 0 ? t("cardStream.scrollMore", { remaining }) : "";
|
||||
// `/copy` lives next to the scroll keys so users see the existing copy-mode
|
||||
// entry without having to dig through /help. Padded to full terminal width
|
||||
// so the background covers the whole row, not just up to the last char.
|
||||
const text = `${above}${more}${t("cardStream.scrollPgUp")}${t("cardStream.scrollCopy")}`;
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const pad = Math.max(0, cols - stringWidth(text));
|
||||
return (
|
||||
<Text color={hot ? TONE.accent : FG.faint} backgroundColor={SURFACE.bgElev}>
|
||||
{text + " ".repeat(pad)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function isFullySettled(card: Card): boolean {
|
||||
switch (card.kind) {
|
||||
case "streaming":
|
||||
case "tool":
|
||||
return card.done || !!card.aborted;
|
||||
case "reasoning":
|
||||
return !card.streaming || !!card.aborted;
|
||||
case "task":
|
||||
case "subagent":
|
||||
return card.status !== "running";
|
||||
case "plan":
|
||||
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a card's content is final — no more streaming deltas expected.
|
||||
* Drives the monotonic height lock in MeasuredCard: settled cards always
|
||||
* report their exact height; unsettled cards only report growth.
|
||||
*
|
||||
* If a future card kind legitimately shrinks in height while still in-flight,
|
||||
* add an explicit entry here — otherwise the monotonic lock won't catch it
|
||||
* and default:true will report the stale larger height. */
|
||||
function isCardSettled(card: Card): boolean {
|
||||
switch (card.kind) {
|
||||
case "reasoning":
|
||||
return !card.streaming || !!card.aborted;
|
||||
case "streaming":
|
||||
return card.done || !!card.aborted;
|
||||
case "tool":
|
||||
return card.done || !!card.aborted;
|
||||
case "task":
|
||||
case "subagent":
|
||||
return card.status !== "running";
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for fragments
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { t } from "../../../i18n/index.js";
|
||||
import { useChatScrollState } from "../state/chat-scroll-provider.js";
|
||||
import { FG } from "../theme/tokens.js";
|
||||
|
||||
/**
|
||||
* Renders either the input area (pinned) or the "reading history" hint
|
||||
* (scrolled up). Reads `pinned` from the chat-scroll store directly so
|
||||
* AppInner doesn't subscribe; toggling pinned only re-renders this leaf.
|
||||
*/
|
||||
export function InputAreaWithHistoryHint({
|
||||
inputArea,
|
||||
}: {
|
||||
inputArea: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const pinned = useChatScrollState((s) => s.pinned);
|
||||
const { stdout } = useStdout();
|
||||
if (!pinned) {
|
||||
const text = t("app.historyScrollHint");
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const pad = Math.max(0, cols - stringWidth(text));
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={1} backgroundColor="#0153e5" />
|
||||
<Box flexGrow={1} backgroundColor="#1e1e1e">
|
||||
<Text color={FG.faint}>{text + " ".repeat(pad)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return <>{inputArea}</>;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Box, Static } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX
|
||||
import React, { useMemo } from "react";
|
||||
import { CardRenderer } from "../cards/CardRenderer.js";
|
||||
import type { Card } from "../state/cards.js";
|
||||
import { useAgentState } from "../state/provider.js";
|
||||
|
||||
export function StaticCardStream({
|
||||
suppressLive = false,
|
||||
}: {
|
||||
suppressLive?: boolean;
|
||||
}): React.ReactElement {
|
||||
const cards = useAgentState((s) => s.cards);
|
||||
const { settled, live } = useMemo(() => partition(cards), [cards]);
|
||||
const visibleLive = suppressLive && live.length > 0 ? live.slice(0, -1) : live;
|
||||
return (
|
||||
<>
|
||||
<Static items={settled}>
|
||||
{(card) => (
|
||||
<Box key={card.id} flexDirection="column" flexShrink={0}>
|
||||
<CardRenderer card={card} />
|
||||
</Box>
|
||||
)}
|
||||
</Static>
|
||||
<Box flexDirection="column" flexShrink={0}>
|
||||
{visibleLive.map((card) => (
|
||||
<Box key={card.id} flexDirection="column" flexShrink={0}>
|
||||
<CardRenderer card={card} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function partition(cards: readonly Card[]): { settled: Card[]; live: Card[] } {
|
||||
const firstUnsettled = cards.findIndex((c) => !isFullySettled(c));
|
||||
if (firstUnsettled === -1) return { settled: [...cards], live: [] };
|
||||
return { settled: cards.slice(0, firstUnsettled), live: cards.slice(firstUnsettled) };
|
||||
}
|
||||
|
||||
function isFullySettled(card: Card): boolean {
|
||||
switch (card.kind) {
|
||||
case "streaming":
|
||||
case "tool":
|
||||
return card.done || !!card.aborted;
|
||||
case "reasoning":
|
||||
return !card.streaming || !!card.aborted;
|
||||
case "task":
|
||||
case "subagent":
|
||||
return card.status !== "running";
|
||||
case "plan":
|
||||
return card.steps.every((s) => s.status === "done" || s.status === "skipped");
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
/** Single allocator for vertical viewport rows; consumers claim per-zone via useReserveRows. */
|
||||
|
||||
import { useStdout } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig jsx=react needs React in value scope for JSX compilation
|
||||
import React, { createContext, useContext, useEffect, useMemo, useReducer } from "react";
|
||||
|
||||
export type ZoneId = "modal" | "status" | "input" | "stream" | "safety";
|
||||
|
||||
/** Higher number = claims rows first. */
|
||||
const ZONE_PRIORITY: Record<ZoneId, number> = {
|
||||
modal: 100,
|
||||
status: 60,
|
||||
input: 50,
|
||||
stream: 10,
|
||||
safety: 5,
|
||||
};
|
||||
|
||||
export interface ClaimSpec {
|
||||
/** Smallest acceptable allocation. May exceed total rows on tiny terminals. */
|
||||
min: number;
|
||||
/** Hard ceiling. `Number.POSITIVE_INFINITY` = "soak whatever's left". */
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface InternalClaim extends ClaimSpec {
|
||||
zone: ZoneId;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
/** Pure allocator — used by the provider and tested in isolation. */
|
||||
export function allocateRows(
|
||||
claims: ReadonlyArray<InternalClaim>,
|
||||
totalRows: number,
|
||||
): ReadonlyMap<ZoneId, number> {
|
||||
const sorted = [...claims].sort((a, b) => b.priority - a.priority);
|
||||
const out = new Map<ZoneId, number>();
|
||||
let remaining = Math.max(0, totalRows);
|
||||
for (const c of sorted) {
|
||||
const want = Math.min(c.max, Math.max(c.min, remaining));
|
||||
out.set(c.zone, want);
|
||||
remaining = Math.max(0, remaining - want);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface BudgetState {
|
||||
/** Active claims keyed by zone — one consumer per zone. */
|
||||
claims: ReadonlyMap<ZoneId, ClaimSpec>;
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
type BudgetAction =
|
||||
| { type: "claim"; zone: ZoneId; spec: ClaimSpec }
|
||||
| { type: "release"; zone: ZoneId }
|
||||
| { type: "resize"; rows: number };
|
||||
|
||||
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
|
||||
switch (action.type) {
|
||||
case "claim": {
|
||||
const next = new Map(state.claims);
|
||||
next.set(action.zone, action.spec);
|
||||
return { ...state, claims: next };
|
||||
}
|
||||
case "release": {
|
||||
if (!state.claims.has(action.zone)) return state;
|
||||
const next = new Map(state.claims);
|
||||
next.delete(action.zone);
|
||||
return { ...state, claims: next };
|
||||
}
|
||||
case "resize":
|
||||
if (action.rows === state.totalRows) return state;
|
||||
return { ...state, totalRows: action.rows };
|
||||
}
|
||||
}
|
||||
|
||||
interface BudgetContextValue {
|
||||
totalRows: number;
|
||||
allocations: ReadonlyMap<ZoneId, number>;
|
||||
claims: ReadonlyMap<ZoneId, ClaimSpec>;
|
||||
dispatch: React.Dispatch<BudgetAction>;
|
||||
}
|
||||
|
||||
const BudgetContext = createContext<BudgetContextValue | null>(null);
|
||||
|
||||
export interface ViewportBudgetProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Test seam — bypasses useStdout. */
|
||||
initialRows?: number;
|
||||
}
|
||||
|
||||
export function ViewportBudgetProvider({
|
||||
children,
|
||||
initialRows,
|
||||
}: ViewportBudgetProviderProps): React.ReactElement {
|
||||
const { stdout } = useStdout();
|
||||
const [state, dispatch] = useReducer(reducer, undefined, () => ({
|
||||
claims: new Map<ZoneId, ClaimSpec>(),
|
||||
totalRows: initialRows ?? stdout?.rows ?? 40,
|
||||
}));
|
||||
|
||||
// Single resize listener — children read totalRows from context.
|
||||
useEffect(() => {
|
||||
if (initialRows !== undefined) return undefined;
|
||||
if (!stdout) return undefined;
|
||||
const onResize = () => dispatch({ type: "resize", rows: stdout.rows ?? 40 });
|
||||
onResize();
|
||||
stdout.on("resize", onResize);
|
||||
return () => {
|
||||
stdout.off("resize", onResize);
|
||||
};
|
||||
}, [stdout, initialRows]);
|
||||
|
||||
const allocations = useMemo(() => {
|
||||
const list: InternalClaim[] = [];
|
||||
for (const [zone, spec] of state.claims) {
|
||||
list.push({ zone, priority: ZONE_PRIORITY[zone], ...spec });
|
||||
}
|
||||
return allocateRows(list, state.totalRows);
|
||||
}, [state.claims, state.totalRows]);
|
||||
|
||||
const value = useMemo<BudgetContextValue>(
|
||||
() => ({
|
||||
totalRows: state.totalRows,
|
||||
allocations,
|
||||
claims: state.claims,
|
||||
dispatch,
|
||||
}),
|
||||
[state.totalRows, allocations, state.claims],
|
||||
);
|
||||
|
||||
return <BudgetContext.Provider value={value}>{children}</BudgetContext.Provider>;
|
||||
}
|
||||
|
||||
/** Returns actual allocation; falls back to spec.max when no provider is mounted. */
|
||||
export function useReserveRows(zone: ZoneId, spec: ClaimSpec): number {
|
||||
const ctx = useContext(BudgetContext);
|
||||
// Deps key off dispatch (stable) + primitives — whole ctx changes every claim and would loop.
|
||||
const dispatch = ctx?.dispatch;
|
||||
|
||||
useEffect(() => {
|
||||
if (!dispatch) return undefined;
|
||||
dispatch({ type: "claim", zone, spec: { min: spec.min, max: spec.max } });
|
||||
return () => {
|
||||
dispatch({ type: "release", zone });
|
||||
};
|
||||
}, [dispatch, zone, spec.min, spec.max]);
|
||||
|
||||
if (!ctx) return Number.isFinite(spec.max) ? spec.max : 40;
|
||||
const allocated = ctx.allocations.get(zone);
|
||||
if (allocated !== undefined) return allocated;
|
||||
// Optimistic max for pre-effect first render.
|
||||
return Number.isFinite(spec.max) ? spec.max : ctx.totalRows;
|
||||
}
|
||||
|
||||
/** Total terminal rows from the provider; falls back to useStdout if unmounted. */
|
||||
export function useTotalRows(): number {
|
||||
const ctx = useContext(BudgetContext);
|
||||
const { stdout } = useStdout();
|
||||
return ctx?.totalRows ?? stdout?.rows ?? 40;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { Box, Text, Transform, useStdout } from "ink";
|
||||
import { type Token, type Tokens, marked } from "marked";
|
||||
import React from "react";
|
||||
import stringWidth from "string-width";
|
||||
import { wrapToCells } from "../../frame/width.js";
|
||||
import { decodeHtmlEntities } from "./html-entities.js";
|
||||
import { wrapToCells } from "./text-width.js";
|
||||
import { FG, SURFACE, TONE } from "./theme/tokens.js";
|
||||
|
||||
/** Left margin consumed by card outer marginLeft + body paddingLeft + safety. */
|
||||
|
||||
+10
-35
@@ -1,53 +1,28 @@
|
||||
// Mouse-protocol selection for the TUI. Reasonix only reacts to the wheel —
|
||||
// clicks and drags are not handled — so the goal is "wheel works in-app,
|
||||
// native click/drag/right-click stays untouched".
|
||||
//
|
||||
// `?1007h` (DECSET alternate scroll) does exactly that: the terminal
|
||||
// translates wheel events into up/down (or PgUp/PgDn) key sequences in the
|
||||
// alt screen, leaving mouse buttons native. Works on iTerm2, GNOME
|
||||
// Terminal, kitty, Alacritty, recent xterm.
|
||||
//
|
||||
// Windows Terminal is the odd one out: it advertises `?1007h` but doesn't
|
||||
// reliably translate wheel events to keys, so reasonix never sees the
|
||||
// wheel. The old default `?1000h+?1006h` (full SGR mouse capture) DID work
|
||||
// for the wheel on WT, at the cost of breaking native text selection /
|
||||
// right-click — which is what got us into #1337 / #677 / #1419 in the
|
||||
// first place. On Windows the proven wheel-working mode is still SGR, so
|
||||
// platform-detect and pick the right one rather than ship a regression
|
||||
// either way (#1456-followup: WT users lost wheel scroll after the
|
||||
// alternate-scroll switch).
|
||||
//
|
||||
// Escape hatch: `REASONIX_MOUSE_MODE=sgr|alternate-scroll|off` overrides
|
||||
// the platform default for users on terminals where the auto-pick is
|
||||
// wrong.
|
||||
// Reasonix is append-only now: the terminal owns scrollback, copy, and the
|
||||
// mouse wheel. We never want to capture the wheel, and on startup we emit
|
||||
// disables for every common mouse-capture mode so stale state from a prior
|
||||
// crashed TUI in the same terminal can't keep eating wheel events. The
|
||||
// REASONIX_MOUSE_MODE env var stays as an escape hatch.
|
||||
|
||||
type Mode = "alternate-scroll" | "sgr" | "off";
|
||||
|
||||
function platformDefault(): Mode {
|
||||
// Windows Terminal / conhost / ConEmu / Cmder all honor SGR mouse capture
|
||||
// but not alternate-scroll. The handful of Linux/macOS terminals that
|
||||
// don't honor `?1007h` (notably macOS Terminal.app) fall back to native
|
||||
// terminal scrollback, which is acceptable; broken in-app wheel for WT
|
||||
// users is not.
|
||||
return process.platform === "win32" ? "sgr" : "alternate-scroll";
|
||||
}
|
||||
|
||||
function readMode(): Mode {
|
||||
const raw = (process.env.REASONIX_MOUSE_MODE ?? "").toLowerCase();
|
||||
if (raw === "sgr") return "sgr";
|
||||
if (raw === "alternate-scroll") return "alternate-scroll";
|
||||
if (raw === "off") return "off";
|
||||
return platformDefault();
|
||||
return "off";
|
||||
}
|
||||
|
||||
const RESET_ALL = "\u001b[?1000l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?1007l\u001b[?1015l";
|
||||
|
||||
const SEQUENCES: Record<Mode, { enable: string; disable: string }> = {
|
||||
"alternate-scroll": { enable: "\u001b[?1007h", disable: "\u001b[?1007l" },
|
||||
sgr: { enable: "\u001b[?1000h\u001b[?1006h", disable: "\u001b[?1006l\u001b[?1000l" },
|
||||
off: { enable: "", disable: "" },
|
||||
off: { enable: RESET_ALL, disable: "" },
|
||||
};
|
||||
|
||||
let active = false;
|
||||
let activeMode: Mode = "alternate-scroll";
|
||||
let activeMode: Mode = "off";
|
||||
|
||||
export function enableMouseMode(): void {
|
||||
if (active) return;
|
||||
|
||||
@@ -187,7 +187,8 @@ export function processMultilineKey(
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
if (key.shift || key.meta) return insertAt(value, cursor, "\n");
|
||||
// Shift+Enter / Ctrl+Enter / Alt+Enter → 换行
|
||||
if (key.shift || key.ctrl || key.meta) return insertAt(value, cursor, "\n");
|
||||
// Bash-style line continuation: trailing '\' + Enter (only when the
|
||||
// cursor sits at end-of-buffer, so a stray '\' mid-line doesn't
|
||||
// trigger it).
|
||||
|
||||
@@ -136,11 +136,6 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
group: "info",
|
||||
summary: "keyboard + mouse + copy/paste reference",
|
||||
},
|
||||
{
|
||||
cmd: "copy",
|
||||
group: "chat",
|
||||
summary: "vim/tmux-style copy mode — j/k navigate, v select, y yank to clipboard",
|
||||
},
|
||||
{
|
||||
cmd: "feedback",
|
||||
group: "info",
|
||||
@@ -337,10 +332,10 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
{
|
||||
cmd: "search-engine",
|
||||
group: "advanced",
|
||||
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
summary:
|
||||
"switch web search backend — mojeek (default, no deps), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native). Provider with no key prompts inline config.",
|
||||
argCompleter: ["mojeek", "searxng", "metaso", "tavily", "perplexity", "exa"],
|
||||
"switch web search backend — bing (default, works from CN without proxy), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native). Provider with no key prompts inline config.",
|
||||
argCompleter: ["bing", "searxng", "metaso", "tavily", "perplexity", "exa"],
|
||||
aliases: ["se"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { wrapToCells } from "@/frame/width.js";
|
||||
import { wrapToCells } from "@/cli/ui/text-width.js";
|
||||
import { t, tObj } from "@/i18n/index.js";
|
||||
import { VERSION } from "@/version.js";
|
||||
import { formatDuration, formatLoopStatus, parseLoopCommand } from "../../loop.js";
|
||||
@@ -156,8 +156,6 @@ const keys: SlashHandler = (_args, _loop, ctx) => {
|
||||
return {};
|
||||
};
|
||||
|
||||
const copy: SlashHandler = () => ({ openCopyMode: true });
|
||||
|
||||
const about: SlashHandler = () => {
|
||||
const lines = [
|
||||
t("handlers.basic.aboutHeader", { version: VERSION }),
|
||||
@@ -176,6 +174,5 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
retry,
|
||||
loop,
|
||||
keys,
|
||||
copy,
|
||||
about,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { basename } from "node:path";
|
||||
import { listPlanArchives, loadPlanState, relativeTime } from "@/code/plan-store.js";
|
||||
import { t } from "@/i18n/index.js";
|
||||
import type { StepCompletion, StepEvidence } from "@/tools/plan.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
const plans: SlashHandler = (args, loop, ctx) => {
|
||||
@@ -28,6 +29,11 @@ const plans: SlashHandler = (args, loop, ctx) => {
|
||||
when,
|
||||
}),
|
||||
);
|
||||
const lifecycle = ctx.getEngineeringLifecycleSnapshot?.();
|
||||
if (lifecycle?.mode !== "off" && lifecycle?.mutatedSinceLastStep) {
|
||||
lines.push(t("handlers.plans.evidencePending"));
|
||||
}
|
||||
lines.push(...formatActiveEvidenceLines(active.stepCompletions, active.completedStepIds));
|
||||
} else {
|
||||
lines.push(t("handlers.plans.activeNone"));
|
||||
}
|
||||
@@ -55,6 +61,8 @@ const plans: SlashHandler = (args, loop, ctx) => {
|
||||
label,
|
||||
}),
|
||||
);
|
||||
const evidence = formatArchivedEvidenceSummary(a.stepCompletions, a.completedStepIds);
|
||||
if (evidence) lines.push(t("handlers.plans.archivedEvidenceLine", { summary: evidence }));
|
||||
}
|
||||
return { info: lines.join("\n") };
|
||||
};
|
||||
@@ -123,6 +131,54 @@ function handleDone(rest: string[], ctx: Parameters<SlashHandler>[2]): { info: s
|
||||
}
|
||||
}
|
||||
|
||||
function formatActiveEvidenceLines(
|
||||
completions: Record<string, StepCompletion> | undefined,
|
||||
completedStepIds: readonly string[],
|
||||
): string[] {
|
||||
if (!completions) return [];
|
||||
const lines: string[] = [];
|
||||
for (const stepId of completedStepIds) {
|
||||
const summary = formatCompletionEvidenceSummary(completions[stepId]);
|
||||
if (!summary) continue;
|
||||
lines.push(t("handlers.plans.evidenceLine", { stepId, summary }));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function formatArchivedEvidenceSummary(
|
||||
completions: Record<string, StepCompletion> | undefined,
|
||||
completedStepIds: readonly string[],
|
||||
): string | null {
|
||||
if (!completions) return null;
|
||||
const parts: string[] = [];
|
||||
for (const stepId of completedStepIds) {
|
||||
const summary = formatCompletionEvidenceSummary(completions[stepId]);
|
||||
if (!summary) continue;
|
||||
parts.push(`${stepId} ${summary}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("; ") : null;
|
||||
}
|
||||
|
||||
function formatCompletionEvidenceSummary(completion: StepCompletion | undefined): string | null {
|
||||
const evidence = completion?.evidence;
|
||||
if (!evidence || evidence.length === 0) {
|
||||
const compact = completion?.evidenceSummary?.trim();
|
||||
return compact || null;
|
||||
}
|
||||
const parts = evidence.map(formatEvidenceItem).filter((part) => part.length > 0);
|
||||
return parts.length > 0 ? parts.join("; ") : null;
|
||||
}
|
||||
|
||||
function formatEvidenceItem(evidence: StepEvidence): string {
|
||||
const extras: string[] = [];
|
||||
if (evidence.command) extras.push(evidence.command);
|
||||
if (evidence.paths && evidence.paths.length > 0) {
|
||||
extras.push(evidence.paths.slice(0, 3).join(", "));
|
||||
}
|
||||
const suffix = extras.length > 0 ? ` (${extras.join("; ")})` : "";
|
||||
return `${evidence.kind} - ${evidence.summary}${suffix}`;
|
||||
}
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
plans,
|
||||
replay,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveThemePreference, saveTheme } from "@/config.js";
|
||||
import { t } from "../../../../i18n/index.js";
|
||||
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
|
||||
import type { SlashHandler } from "../dispatch.js";
|
||||
|
||||
@@ -13,12 +14,12 @@ const theme: SlashHandler = (args) => {
|
||||
if (!next) return { openThemePicker: true };
|
||||
|
||||
if (!isThemeChoice(next)) {
|
||||
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
|
||||
return { info: t("app.unknownTheme", { name: next, choices: themeChoices.join(", ") }) };
|
||||
}
|
||||
|
||||
saveTheme(next);
|
||||
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
|
||||
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
|
||||
return { info: t("app.themeSaved", { name: next, active }) };
|
||||
};
|
||||
|
||||
export const handlers: Record<string, SlashHandler> = {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
const engine = args[0];
|
||||
if (
|
||||
!engine ||
|
||||
(engine !== "mojeek" &&
|
||||
(engine !== "bing" &&
|
||||
engine !== "searxng" &&
|
||||
engine !== "metaso" &&
|
||||
engine !== "tavily" &&
|
||||
@@ -29,7 +29,7 @@ export const handlers: Record<string, SlashHandler> = {
|
||||
t("handlers.webSearchEngine.endpoint", { url: webSearchEndpoint() }),
|
||||
"",
|
||||
t("handlers.webSearchEngine.usageHeader"),
|
||||
t("handlers.webSearchEngine.usageMojeek"),
|
||||
t("handlers.webSearchEngine.usageBing"),
|
||||
t("handlers.webSearchEngine.usageSearxng"),
|
||||
t("handlers.webSearchEngine.usageSearxngUrl"),
|
||||
t("handlers.webSearchEngine.usageMetaso"),
|
||||
|
||||
@@ -21,8 +21,6 @@ export interface SlashResult {
|
||||
openThemePicker?: boolean;
|
||||
/** Open the unified MCP hub — `/mcp` defaults to "live", `/mcp browse` to "marketplace". */
|
||||
openMcpHub?: { tab: "live" | "marketplace" };
|
||||
/** Open the vim/tmux-style copy mode — yank chat text to clipboard via OSC 52. */
|
||||
openCopyMode?: boolean;
|
||||
/** Open the arg-completer picker for this command (e.g. `/language` → language picker). */
|
||||
openArgPickerFor?: string;
|
||||
/** Exit the app. */
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
type ChatScrollState,
|
||||
type ChatScrollStore,
|
||||
createChatScrollStore,
|
||||
} from "./chat-scroll-store.js";
|
||||
|
||||
const Ctx = React.createContext<ChatScrollStore | null>(null);
|
||||
|
||||
export function ChatScrollProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
const store = React.useMemo(() => createChatScrollStore(), []);
|
||||
return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
function useStore(): ChatScrollStore {
|
||||
const s = React.useContext(Ctx);
|
||||
if (!s) throw new Error("useChatScroll* must be used inside ChatScrollProvider");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Subscribes to a slice of scroll state — only re-renders when that slice changes. */
|
||||
export function useChatScrollState<T>(selector: (s: ChatScrollState) => T): T {
|
||||
const store = useStore();
|
||||
const subscribe = React.useCallback((cb: () => void) => store.subscribe(cb), [store]);
|
||||
const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]);
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Returns the action set — stable across renders, never triggers re-renders by itself. */
|
||||
export function useChatScrollActions(): Pick<
|
||||
ChatScrollStore,
|
||||
| "scrollUp"
|
||||
| "scrollDown"
|
||||
| "scrollPageUp"
|
||||
| "scrollPageDown"
|
||||
| "scrollWheelUp"
|
||||
| "scrollWheelDown"
|
||||
| "jumpToBottom"
|
||||
| "setMaxScroll"
|
||||
| "setCardHeight"
|
||||
| "pruneCardHeights"
|
||||
> {
|
||||
return useStore();
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/** Chat-scroll state in its own store so wheel/arrow ticks don't dirty App.tsx. */
|
||||
|
||||
export interface ChatScrollState {
|
||||
/** Rows of content above the visible viewport. */
|
||||
scrollRows: number;
|
||||
/** True while following the bottom — auto-advances on new content. */
|
||||
pinned: boolean;
|
||||
/** Total scrollable rows; CardStream reports this once Yoga has measured. */
|
||||
maxScroll: number;
|
||||
/** Bumped on every applied scroll delta — consumers can flash an indicator. */
|
||||
scrollVersion: number;
|
||||
/** Per-card row height, populated as cards mount and re-measured on streaming changes. */
|
||||
cardHeights: ReadonlyMap<string, number>;
|
||||
}
|
||||
|
||||
export type ScrollListener = () => void;
|
||||
|
||||
export interface ChatScrollStore {
|
||||
getState(): ChatScrollState;
|
||||
subscribe(listener: ScrollListener): () => void;
|
||||
scrollUp(): void;
|
||||
scrollDown(): void;
|
||||
scrollPageUp(): void;
|
||||
scrollPageDown(): void;
|
||||
scrollWheelUp(): void;
|
||||
scrollWheelDown(): void;
|
||||
jumpToBottom(): void;
|
||||
setMaxScroll(rows: number): void;
|
||||
/** Reports a card's measured height. No-op if value matches the cache. */
|
||||
setCardHeight(id: string, rows: number): void;
|
||||
/** Drops heights for cards no longer in the visible list. Called by CardStream when cards change. */
|
||||
pruneCardHeights(liveIds: ReadonlySet<string>): void;
|
||||
}
|
||||
|
||||
export const SCROLL_ARROW_ROWS = 3;
|
||||
export const SCROLL_PAGE_ROWS = 5;
|
||||
/** One wheel notch on most mice emits 2-5 SGR mouse reports back-to-back,
|
||||
* so anything larger here multiplies into a 10-25 row jump per notch. */
|
||||
export const SCROLL_WHEEL_ROWS = 1;
|
||||
const COALESCE_MS = 16;
|
||||
|
||||
const EMPTY_HEIGHTS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
const initial: ChatScrollState = {
|
||||
scrollRows: 0,
|
||||
pinned: true,
|
||||
maxScroll: 0,
|
||||
scrollVersion: 0,
|
||||
cardHeights: EMPTY_HEIGHTS,
|
||||
};
|
||||
|
||||
export function createChatScrollStore(): ChatScrollStore {
|
||||
let state = initial;
|
||||
const listeners = new Set<ScrollListener>();
|
||||
let pendingDelta = 0;
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
// Trailing-edge coalesce target for pinned-mode shrinks (issue #653).
|
||||
// While a burst of card-collapse re-measurements arrives, we hold the latest
|
||||
// target here and apply it once on a microtask flush, so subscribers see one
|
||||
// settled transition instead of N oscillating snaps.
|
||||
// Grows still apply immediately — streaming content should auto-scroll without
|
||||
// latency. Growth oscillation is prevented upstream by the monotonic height
|
||||
// lock in CardStream.MeasuredCard: card heights only increase during streaming,
|
||||
// so maxScroll growth is naturally monotonic.
|
||||
let pendingMaxShrink: number | null = null;
|
||||
let shrinkTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
function set(next: Partial<ChatScrollState>): void {
|
||||
const merged = { ...state, ...next };
|
||||
if (
|
||||
merged.scrollRows === state.scrollRows &&
|
||||
merged.pinned === state.pinned &&
|
||||
merged.maxScroll === state.maxScroll &&
|
||||
merged.scrollVersion === state.scrollVersion &&
|
||||
merged.cardHeights === state.cardHeights
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state = merged;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function applyDelta(): void {
|
||||
const d = pendingDelta;
|
||||
pendingDelta = 0;
|
||||
if (d === 0) return;
|
||||
const next = Math.max(0, Math.min(state.maxScroll, state.scrollRows + d));
|
||||
set({
|
||||
scrollRows: next,
|
||||
pinned: d < 0 ? false : next >= state.maxScroll ? true : state.pinned,
|
||||
scrollVersion: state.scrollVersion + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Leading-edge: first tick flushes immediately, rest coalesce into one trailing flush. */
|
||||
function schedule(delta: number): void {
|
||||
if (flushTimer === null) {
|
||||
pendingDelta = delta;
|
||||
applyDelta();
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
if (pendingDelta !== 0) applyDelta();
|
||||
}, COALESCE_MS);
|
||||
} else {
|
||||
pendingDelta += delta;
|
||||
}
|
||||
}
|
||||
|
||||
function flushShrink(): void {
|
||||
if (shrinkTimer !== null) {
|
||||
clearTimeout(shrinkTimer);
|
||||
shrinkTimer = null;
|
||||
}
|
||||
const target = pendingMaxShrink;
|
||||
pendingMaxShrink = null;
|
||||
if (target === null) return;
|
||||
const nextScrollRows = state.pinned ? target : Math.min(state.scrollRows, target);
|
||||
set({ maxScroll: target, scrollRows: nextScrollRows });
|
||||
}
|
||||
|
||||
return {
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
scrollUp: () => schedule(-SCROLL_ARROW_ROWS),
|
||||
scrollDown: () => schedule(SCROLL_ARROW_ROWS),
|
||||
scrollPageUp: () => schedule(-SCROLL_PAGE_ROWS),
|
||||
scrollPageDown: () => schedule(SCROLL_PAGE_ROWS),
|
||||
scrollWheelUp: () => schedule(-SCROLL_WHEEL_ROWS),
|
||||
scrollWheelDown: () => schedule(SCROLL_WHEEL_ROWS),
|
||||
jumpToBottom() {
|
||||
pendingDelta = 0;
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
// Drop any deferred shrink so an explicit jump isn't undone by a stale target.
|
||||
pendingMaxShrink = null;
|
||||
if (shrinkTimer !== null) {
|
||||
clearTimeout(shrinkTimer);
|
||||
shrinkTimer = null;
|
||||
}
|
||||
set({ pinned: true });
|
||||
},
|
||||
setMaxScroll(rows: number) {
|
||||
const m = rows < 0 ? 0 : rows;
|
||||
// Coalesce shrinks while pinned (issue #653): a burst of card-teardown
|
||||
// re-measurements during an Esc-abort would otherwise snap scrollRows N
|
||||
// times, producing a visible flicker. Grows still apply immediately so
|
||||
// normal streaming output keeps the viewport pinned without latency.
|
||||
// Growth oscillation is prevented upstream by the monotonic height lock
|
||||
// in CardStream.MeasuredCard — card heights only increase during streaming,
|
||||
// so maxScroll growth is naturally monotonic without coalescing.
|
||||
const currentMax = pendingMaxShrink ?? state.maxScroll;
|
||||
if (state.pinned && m < currentMax) {
|
||||
pendingMaxShrink = m;
|
||||
if (shrinkTimer === null) {
|
||||
shrinkTimer = setTimeout(() => {
|
||||
shrinkTimer = null;
|
||||
flushShrink();
|
||||
}, COALESCE_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-shrink path: flush any deferred shrink first so its trailing state
|
||||
// doesn't clobber the value we're about to set.
|
||||
if (pendingMaxShrink !== null) flushShrink();
|
||||
// Pinned-mode invariant: scrollRows tracks maxScroll exactly.
|
||||
const nextScrollRows = state.pinned ? m : Math.min(state.scrollRows, m);
|
||||
set({ maxScroll: m, scrollRows: nextScrollRows });
|
||||
},
|
||||
setCardHeight(id: string, rows: number) {
|
||||
if (state.cardHeights.get(id) === rows) return;
|
||||
const next = new Map(state.cardHeights);
|
||||
next.set(id, rows);
|
||||
set({ cardHeights: next });
|
||||
},
|
||||
pruneCardHeights(liveIds: ReadonlySet<string>) {
|
||||
let drop = 0;
|
||||
for (const id of state.cardHeights.keys()) {
|
||||
if (!liveIds.has(id)) drop++;
|
||||
}
|
||||
if (drop === 0) return;
|
||||
const next = new Map<string, number>();
|
||||
for (const [id, h] of state.cardHeights) {
|
||||
if (liveIds.has(id)) next.set(id, h);
|
||||
}
|
||||
set({ cardHeights: next });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function graphemes(s: string): string[] {
|
||||
return Array.from(segmenter.segment(s), (seg) => seg.segment);
|
||||
}
|
||||
|
||||
/** Clamp into {0,1,2} — Frame grid only knows narrow + wide cells. */
|
||||
/** Narrow=1 / wide=2 / zero-width=0 cell width for a single grapheme cluster. */
|
||||
export function graphemeWidth(g: string): 0 | 1 | 2 {
|
||||
if (g.length === 0) return 0;
|
||||
const w = stringWidthLib(g);
|
||||
@@ -166,10 +166,19 @@ export function useCompletionPickers({
|
||||
loading: browse.loading,
|
||||
};
|
||||
}
|
||||
// When the user already typed a directory prefix (e.g. `@dir/fil`),
|
||||
// filter search results to only files under that directory so a
|
||||
// shorter same-name match from elsewhere (e.g. root `.gitignore`)
|
||||
// doesn't steal the picker selection.
|
||||
const dirPrefix = parsed.dir ? `${parsed.dir}/` : "";
|
||||
let filtered = search.entries;
|
||||
if (dirPrefix) {
|
||||
filtered = search.entries.filter((e) => e.insertPath.startsWith(dirPrefix));
|
||||
}
|
||||
return {
|
||||
kind: "search",
|
||||
filter: parsed.filter,
|
||||
entries: search.entries,
|
||||
entries: filtered,
|
||||
scanned: search.scanned,
|
||||
searching: search.searching,
|
||||
};
|
||||
|
||||
+15
-4
@@ -223,8 +223,13 @@ export class DeepSeekClient {
|
||||
|
||||
async chat(opts: ChatRequestOptions): Promise<ChatResponse> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
const signal = opts.signal ?? ctrl.signal;
|
||||
const timer = setTimeout(
|
||||
() => ctrl.abort(new Error(`DeepSeek request timed out after ${this.timeoutMs}ms`)),
|
||||
this.timeoutMs,
|
||||
);
|
||||
// Combine — `opts.signal ?? ctrl.signal` orphans the timer when the
|
||||
// caller passes a signal, so timeoutMs never reaches fetch.
|
||||
const signal = opts.signal ? AbortSignal.any([opts.signal, ctrl.signal]) : ctrl.signal;
|
||||
|
||||
try {
|
||||
await this.waitForChatRateLimit(signal);
|
||||
@@ -261,8 +266,14 @@ export class DeepSeekClient {
|
||||
|
||||
async *stream(opts: ChatRequestOptions): AsyncGenerator<StreamChunk> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
const signal = opts.signal ?? ctrl.signal;
|
||||
const timer = setTimeout(
|
||||
() => ctrl.abort(new Error(`DeepSeek stream timed out after ${this.timeoutMs}ms`)),
|
||||
this.timeoutMs,
|
||||
);
|
||||
// Combine — `opts.signal ?? ctrl.signal` orphans the timer when the
|
||||
// caller passes a signal, leaving a stalled SSE body to hang forever
|
||||
// on reader.read() (issue #1535).
|
||||
const signal = opts.signal ? AbortSignal.any([opts.signal, ctrl.signal]) : ctrl.signal;
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
|
||||
+11
-5
@@ -14,6 +14,7 @@ import {
|
||||
writeSync,
|
||||
} from "node:fs";
|
||||
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
||||
import { type FileEncoding, decodeFileBuffer, encodeFile } from "./file-encoding.js";
|
||||
|
||||
export interface EditBlock {
|
||||
/** Path as written by the model — relative to rootDir, or absolute. */
|
||||
@@ -157,7 +158,7 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
if (n <= 0) break;
|
||||
readBytes += n;
|
||||
}
|
||||
const content = inBuf.toString("utf8", 0, readBytes);
|
||||
const { text: content, encoding } = decodeFileBuffer(inBuf.subarray(0, readBytes));
|
||||
const le = lineEndingOf(content);
|
||||
const adaptedSearch = block.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = block.replace.replace(/\r?\n/g, le);
|
||||
@@ -184,7 +185,7 @@ export function applyEditBlock(block: EditBlock, rootDir: string): ApplyResult {
|
||||
// Truncate first so a shorter result doesn't leave stale tail
|
||||
// bytes; ftruncate also pads with NUL when the new length is
|
||||
// longer, which we then overwrite below.
|
||||
const outBuf = Buffer.from(replaced, "utf8");
|
||||
const outBuf = encodeFile(replaced, encoding);
|
||||
ftruncateSync(fd, outBuf.length);
|
||||
let written = 0;
|
||||
while (written < outBuf.length) {
|
||||
@@ -210,7 +211,7 @@ export function toWholeFileEditBlock(path: string, content: string, rootDir: str
|
||||
let search = "";
|
||||
if (existsSync(abs)) {
|
||||
try {
|
||||
search = readFileSync(abs, "utf8");
|
||||
search = decodeFileBuffer(readFileSync(abs)).text;
|
||||
} catch {
|
||||
search = "";
|
||||
}
|
||||
@@ -223,14 +224,18 @@ export interface EditSnapshot {
|
||||
path: string;
|
||||
/** `null` = file didn't exist; restore means delete. */
|
||||
prevContent: string | null;
|
||||
/** Encoding the file used before the edit. Required to round-trip GB18030 / UTF-8-BOM on restore. */
|
||||
prevEncoding?: FileEncoding;
|
||||
}
|
||||
|
||||
/** De-duped by path — one "before" snapshot per file even with multiple blocks. */
|
||||
export function snapshotBeforeEdits(blocks: EditBlock[], rootDir: string): EditSnapshot[] {
|
||||
const absRoot = resolve(rootDir);
|
||||
const seen = new Set<string>();
|
||||
const snapshots: EditSnapshot[] = [];
|
||||
for (const b of blocks) {
|
||||
const abs = resolveEditPath(rootDir, b.path);
|
||||
if (!pathIsUnder(abs, absRoot)) continue;
|
||||
if (seen.has(abs)) continue;
|
||||
seen.add(abs);
|
||||
if (!existsSync(abs)) {
|
||||
@@ -238,7 +243,8 @@ export function snapshotBeforeEdits(blocks: EditBlock[], rootDir: string): EditS
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
snapshots.push({ path: b.path, prevContent: readFileSync(abs, "utf8") });
|
||||
const { text, encoding } = decodeFileBuffer(readFileSync(abs));
|
||||
snapshots.push({ path: b.path, prevContent: text, prevEncoding: encoding });
|
||||
} catch {
|
||||
// Unreadable (permission / binary) — record null so we at least
|
||||
// don't pretend the snapshot is authoritative. The restore path
|
||||
@@ -270,7 +276,7 @@ export function restoreSnapshots(snapshots: EditSnapshot[], rootDir: string): Ap
|
||||
message: "removed (the edit had created it)",
|
||||
};
|
||||
}
|
||||
writeFileSync(abs, snap.prevContent, "utf8");
|
||||
writeFileSync(abs, encodeFile(snap.prevContent, snap.prevEncoding ?? "utf8"));
|
||||
return {
|
||||
path: snap.path,
|
||||
status: "applied",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** Read/write preserves the file's original encoding so edit_file on GB18030 (CN Windows) or UTF-8-BOM files doesn't silently convert or fail SEARCH on mangled decode (issue #1445). */
|
||||
|
||||
import { promises as fsp, readFileSync, writeFileSync } from "node:fs";
|
||||
import iconv from "iconv-lite";
|
||||
|
||||
export type FileEncoding = "utf8" | "utf8-bom" | "gb18030";
|
||||
|
||||
export interface DecodedFile {
|
||||
text: string;
|
||||
encoding: FileEncoding;
|
||||
}
|
||||
|
||||
const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
|
||||
|
||||
export function decodeFileBuffer(buf: Buffer): DecodedFile {
|
||||
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
||||
return { text: buf.subarray(3).toString("utf8"), encoding: "utf8-bom" };
|
||||
}
|
||||
try {
|
||||
return { text: new TextDecoder("utf-8", { fatal: true }).decode(buf), encoding: "utf8" };
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
// GB18030 isn't platform-conditional — files originated on CN Windows can travel anywhere.
|
||||
try {
|
||||
return {
|
||||
text: new TextDecoder("gb18030", { fatal: true }).decode(buf),
|
||||
encoding: "gb18030",
|
||||
};
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
return { text: buf.toString("utf8"), encoding: "utf8" };
|
||||
}
|
||||
|
||||
export function encodeFile(text: string, encoding: FileEncoding): Buffer {
|
||||
if (encoding === "utf8") return Buffer.from(text, "utf8");
|
||||
if (encoding === "utf8-bom") {
|
||||
return Buffer.concat([UTF8_BOM, Buffer.from(text, "utf8")]);
|
||||
}
|
||||
return iconv.encode(text, "gb18030");
|
||||
}
|
||||
|
||||
export function readTextFileSmartSync(path: string): DecodedFile {
|
||||
return decodeFileBuffer(readFileSync(path));
|
||||
}
|
||||
|
||||
export async function readTextFileSmart(path: string): Promise<DecodedFile> {
|
||||
return decodeFileBuffer(await fsp.readFile(path));
|
||||
}
|
||||
|
||||
export function writeTextFileSmartSync(path: string, text: string, encoding: FileEncoding): void {
|
||||
writeFileSync(path, encodeFile(text, encoding));
|
||||
}
|
||||
|
||||
export async function writeTextFileSmart(
|
||||
path: string,
|
||||
text: string,
|
||||
encoding: FileEncoding,
|
||||
): Promise<void> {
|
||||
await fsp.writeFile(path, encodeFile(text, encoding));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
export type LifecyclePolicyRisk = "safe" | "mutation" | "high-risk";
|
||||
|
||||
export type LifecyclePolicyReason =
|
||||
| "safe-tool"
|
||||
| "mutation-tool"
|
||||
| "high-risk-tool"
|
||||
| "package-or-config-path"
|
||||
| "high-risk-command"
|
||||
| "default-safe";
|
||||
|
||||
export interface LifecyclePolicyDecision {
|
||||
toolName: string;
|
||||
risk: LifecyclePolicyRisk;
|
||||
reason: LifecyclePolicyReason;
|
||||
}
|
||||
|
||||
const SAFE_TOOL_NAMES = new Set([
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"directory_tree",
|
||||
"search_files",
|
||||
"search_content",
|
||||
"glob",
|
||||
"get_file_info",
|
||||
"semantic_search",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"recall_memory",
|
||||
"todo_write",
|
||||
"ask_choice",
|
||||
"submit_plan",
|
||||
"mark_step_complete",
|
||||
"revise_plan",
|
||||
"job_output",
|
||||
"wait_for_job",
|
||||
"list_jobs",
|
||||
]);
|
||||
|
||||
const HIGH_RISK_TOOL_NAMES = new Set([
|
||||
"multi_edit",
|
||||
"move_file",
|
||||
"delete_file",
|
||||
"delete_directory",
|
||||
"copy_file",
|
||||
"create_directory",
|
||||
"run_background",
|
||||
"stop_job",
|
||||
]);
|
||||
|
||||
const MUTATION_TOOL_NAMES = new Set([
|
||||
"edit_file",
|
||||
"write_file",
|
||||
"multi_edit",
|
||||
"move_file",
|
||||
"delete_file",
|
||||
"delete_directory",
|
||||
"copy_file",
|
||||
"create_directory",
|
||||
"run_background",
|
||||
"stop_job",
|
||||
]);
|
||||
|
||||
export function classifyLifecycleToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): LifecyclePolicyDecision {
|
||||
if (HIGH_RISK_TOOL_NAMES.has(toolName)) {
|
||||
return decision(toolName, "high-risk", "high-risk-tool");
|
||||
}
|
||||
if (SAFE_TOOL_NAMES.has(toolName)) {
|
||||
return decision(toolName, "safe", "safe-tool");
|
||||
}
|
||||
if (toolName === "write_file" || toolName === "edit_file") {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
if (isPackageOrConfigPath(path)) {
|
||||
return decision(toolName, "high-risk", "package-or-config-path");
|
||||
}
|
||||
return decision(toolName, "mutation", "mutation-tool");
|
||||
}
|
||||
if (toolName === "run_command") {
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
if (isHighRiskCommand(command)) {
|
||||
return decision(toolName, "high-risk", "high-risk-command");
|
||||
}
|
||||
return decision(toolName, "safe", "default-safe");
|
||||
}
|
||||
if (MUTATION_TOOL_NAMES.has(toolName)) {
|
||||
return decision(toolName, "mutation", "mutation-tool");
|
||||
}
|
||||
return decision(toolName, "safe", "default-safe");
|
||||
}
|
||||
|
||||
function decision(
|
||||
toolName: string,
|
||||
risk: LifecyclePolicyRisk,
|
||||
reason: LifecyclePolicyReason,
|
||||
): LifecyclePolicyDecision {
|
||||
return { toolName, risk, reason };
|
||||
}
|
||||
|
||||
function isPackageOrConfigPath(path: string): boolean {
|
||||
const normalized = path.replaceAll("\\", "/").toLowerCase();
|
||||
return (
|
||||
/(^|\/)package(-lock)?\.json$/.test(normalized) ||
|
||||
/(^|\/)pnpm-lock\.yaml$/.test(normalized) ||
|
||||
/(^|\/)yarn\.lock$/.test(normalized) ||
|
||||
/(^|\/)tsconfig[^/]*\.json$/.test(normalized) ||
|
||||
/(^|\/)vitest\.config\./.test(normalized) ||
|
||||
/(^|\/)biome\.json$/.test(normalized) ||
|
||||
normalized.startsWith(".github/workflows/")
|
||||
);
|
||||
}
|
||||
|
||||
function isHighRiskCommand(command: string): boolean {
|
||||
const tokens = shellTokens(command);
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]?.toLowerCase();
|
||||
if (!token || !isCommandPosition(tokens, i)) continue;
|
||||
if (
|
||||
(token === "npm" || token === "pnpm" || token === "yarn") &&
|
||||
isPackageMutation(tokens[i + 1])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (token === "git" && isHighRiskGitCommand(tokens.slice(i + 1))) return true;
|
||||
if (token === "rm" || token === "mv" || token === "cp") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shellTokens(command: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i] ?? "";
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" || ch === "|" || ch === "&") {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
const next = command[i + 1];
|
||||
if ((ch === "|" || ch === "&") && next === ch) {
|
||||
out.push(`${ch}${next}`);
|
||||
i++;
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current) out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCommandPosition(tokens: string[], index: number): boolean {
|
||||
if (index === 0) return true;
|
||||
const previous = tokens[index - 1];
|
||||
return previous === ";" || previous === "|" || previous === "&&" || previous === "||";
|
||||
}
|
||||
|
||||
function isPackageMutation(token: string | undefined): boolean {
|
||||
const normalized = token?.toLowerCase();
|
||||
return (
|
||||
normalized === "install" ||
|
||||
normalized === "add" ||
|
||||
normalized === "remove" ||
|
||||
normalized === "update"
|
||||
);
|
||||
}
|
||||
|
||||
function isHighRiskGitCommand(args: string[]): boolean {
|
||||
const subcommandIndex = args.findIndex((arg) => arg && !arg.startsWith("-"));
|
||||
const subcommand = args[subcommandIndex]?.toLowerCase();
|
||||
if (!subcommand) return false;
|
||||
if (
|
||||
subcommand === "push" ||
|
||||
subcommand === "reset" ||
|
||||
subcommand === "clean" ||
|
||||
subcommand === "switch"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (subcommand !== "checkout") return false;
|
||||
const checkoutArgs = args.slice(subcommandIndex + 1);
|
||||
if (checkoutArgs[0] === "--") return false;
|
||||
if (checkoutArgs.some((arg) => arg === "-b" || arg === "-B" || arg === "--orphan")) return true;
|
||||
const positional = checkoutArgs.filter((arg) => arg && !arg.startsWith("-"));
|
||||
if (positional.length === 0) return false;
|
||||
return !positional.every(looksLikePathCheckout);
|
||||
}
|
||||
|
||||
function looksLikePathCheckout(arg: string): boolean {
|
||||
return arg.includes("/") || arg.includes("\\") || arg.includes(".");
|
||||
}
|
||||
+3
-180
@@ -1,5 +1,6 @@
|
||||
import type { ToolInterceptor } from "../tools.js";
|
||||
import type { PlanStep, StepEvidence } from "../tools/plan.js";
|
||||
import { classifyLifecycleToolCall } from "./lifecycle-policy.js";
|
||||
|
||||
export type EngineeringLifecycleMode = "off" | "strict";
|
||||
export type EngineeringLifecycleState =
|
||||
@@ -24,77 +25,12 @@ export interface EngineeringLifecycleOptions {
|
||||
mode?: EngineeringLifecycleMode;
|
||||
}
|
||||
|
||||
const SAFE_TOOL_NAMES = new Set([
|
||||
"read_file",
|
||||
"list_directory",
|
||||
"directory_tree",
|
||||
"search_files",
|
||||
"search_content",
|
||||
"glob",
|
||||
"get_file_info",
|
||||
"semantic_search",
|
||||
"web_search",
|
||||
"web_fetch",
|
||||
"recall_memory",
|
||||
"todo_write",
|
||||
"ask_choice",
|
||||
"submit_plan",
|
||||
"mark_step_complete",
|
||||
"revise_plan",
|
||||
"job_output",
|
||||
"wait_for_job",
|
||||
"list_jobs",
|
||||
]);
|
||||
|
||||
const HIGH_RISK_TOOL_NAMES = new Set([
|
||||
"multi_edit",
|
||||
"move_file",
|
||||
"delete_file",
|
||||
"delete_directory",
|
||||
"copy_file",
|
||||
"create_directory",
|
||||
"run_background",
|
||||
"stop_job",
|
||||
]);
|
||||
|
||||
const MUTATION_TOOL_NAMES = new Set([
|
||||
"edit_file",
|
||||
"write_file",
|
||||
"multi_edit",
|
||||
"move_file",
|
||||
"delete_file",
|
||||
"delete_directory",
|
||||
"copy_file",
|
||||
"create_directory",
|
||||
"run_background",
|
||||
"stop_job",
|
||||
]);
|
||||
|
||||
export function isHighRiskLifecycleToolCall(name: string, args: Record<string, unknown>): boolean {
|
||||
if (HIGH_RISK_TOOL_NAMES.has(name)) return true;
|
||||
if (SAFE_TOOL_NAMES.has(name)) return false;
|
||||
if (name === "write_file") {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
return isPackageOrConfigPath(path);
|
||||
}
|
||||
if (name === "edit_file") {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
return isPackageOrConfigPath(path);
|
||||
}
|
||||
if (name === "run_command") {
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
return isHighRiskCommand(command);
|
||||
}
|
||||
return false;
|
||||
return classifyLifecycleToolCall(name, args).risk === "high-risk";
|
||||
}
|
||||
|
||||
export function isLifecycleMutationToolCall(name: string, args: Record<string, unknown>): boolean {
|
||||
if (MUTATION_TOOL_NAMES.has(name)) return true;
|
||||
if (name === "run_command") {
|
||||
const command = typeof args.command === "string" ? args.command : "";
|
||||
return isHighRiskCommand(command);
|
||||
}
|
||||
return false;
|
||||
return classifyLifecycleToolCall(name, args).risk !== "safe";
|
||||
}
|
||||
|
||||
export class EngineeringLifecycleRuntime {
|
||||
@@ -270,116 +206,3 @@ function toolResultLooksSuccessful(result: string): boolean {
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function isPackageOrConfigPath(path: string): boolean {
|
||||
const normalized = path.replaceAll("\\", "/").toLowerCase();
|
||||
return (
|
||||
/(^|\/)package(-lock)?\.json$/.test(normalized) ||
|
||||
/(^|\/)pnpm-lock\.yaml$/.test(normalized) ||
|
||||
/(^|\/)yarn\.lock$/.test(normalized) ||
|
||||
/(^|\/)tsconfig[^/]*\.json$/.test(normalized) ||
|
||||
/(^|\/)vitest\.config\./.test(normalized) ||
|
||||
/(^|\/)biome\.json$/.test(normalized) ||
|
||||
normalized.startsWith(".github/workflows/")
|
||||
);
|
||||
}
|
||||
|
||||
function isHighRiskCommand(command: string): boolean {
|
||||
const tokens = shellTokens(command);
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]?.toLowerCase();
|
||||
if (!token || !isCommandPosition(tokens, i)) continue;
|
||||
if (
|
||||
(token === "npm" || token === "pnpm" || token === "yarn") &&
|
||||
isPackageMutation(tokens[i + 1])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (token === "git" && isHighRiskGitCommand(tokens.slice(i + 1))) return true;
|
||||
if (token === "rm" || token === "mv" || token === "cp") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shellTokens(command: string): string[] {
|
||||
const out: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const ch = command[i] ?? "";
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === ";" || ch === "|" || ch === "&") {
|
||||
if (current) {
|
||||
out.push(current);
|
||||
current = "";
|
||||
}
|
||||
const next = command[i + 1];
|
||||
if ((ch === "|" || ch === "&") && next === ch) {
|
||||
out.push(`${ch}${next}`);
|
||||
i++;
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current) out.push(current);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isCommandPosition(tokens: string[], index: number): boolean {
|
||||
if (index === 0) return true;
|
||||
const previous = tokens[index - 1];
|
||||
return previous === ";" || previous === "|" || previous === "&&" || previous === "||";
|
||||
}
|
||||
|
||||
function isPackageMutation(token: string | undefined): boolean {
|
||||
const normalized = token?.toLowerCase();
|
||||
return (
|
||||
normalized === "install" ||
|
||||
normalized === "add" ||
|
||||
normalized === "remove" ||
|
||||
normalized === "update"
|
||||
);
|
||||
}
|
||||
|
||||
function isHighRiskGitCommand(args: string[]): boolean {
|
||||
const subcommandIndex = args.findIndex((arg) => arg && !arg.startsWith("-"));
|
||||
const subcommand = args[subcommandIndex]?.toLowerCase();
|
||||
if (!subcommand) return false;
|
||||
if (
|
||||
subcommand === "push" ||
|
||||
subcommand === "reset" ||
|
||||
subcommand === "clean" ||
|
||||
subcommand === "switch"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (subcommand !== "checkout") return false;
|
||||
const checkoutArgs = args.slice(subcommandIndex + 1);
|
||||
if (checkoutArgs[0] === "--") return false;
|
||||
if (checkoutArgs.some((arg) => arg === "-b" || arg === "-B" || arg === "--orphan")) return true;
|
||||
const positional = checkoutArgs.filter((arg) => arg && !arg.startsWith("-"));
|
||||
if (positional.length === 0) return false;
|
||||
return !positional.every(looksLikePathCheckout);
|
||||
}
|
||||
|
||||
function looksLikePathCheckout(arg: string): boolean {
|
||||
return arg.includes("/") || arg.includes("\\") || arg.includes(".");
|
||||
}
|
||||
+5
-1
@@ -68,7 +68,7 @@ the new lines
|
||||
>>>>>>> REPLACE
|
||||
|
||||
Rules:
|
||||
- read_file first so your SEARCH matches byte-for-byte.
|
||||
- **Read before edit (enforced).** You MUST call \`read_file\` on the target this session before \`edit_file\` / \`multi_edit\` will accept it — the tool refuses unread targets up front, so SEARCH text is grounded in on-disk bytes, not a guess. A fold / mechanical truncate clears the tracker, so re-read after one of those before mutating. \`write_file\` counts as a read for that path (the content is what you just wrote).
|
||||
- One edit per block; multiple blocks per response are fine.
|
||||
- Create a new file with empty SEARCH:
|
||||
path/to/new.ts
|
||||
@@ -111,6 +111,10 @@ When the user says run / start / launch / serve / boot up: start it, verify it c
|
||||
- One short paragraph explaining *why*, then the blocks.
|
||||
- Silence during exploration is fine — tool calls first, prose after.
|
||||
|
||||
# Task integrity — non-negotiable
|
||||
|
||||
The user's original objective and ALL constraints (especially "do NOT do X", "avoid Y", "never Z") remain in force for the entire session. You may NOT unilaterally simplify, narrow, or change the objective to save tokens, time, or steps. If you believe the objective needs adjustment, ask the user — do NOT decide on your own.
|
||||
|
||||
__ESCALATION_CONTRACT__
|
||||
|
||||
${TUI_FORMATTING_RULES}
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import {
|
||||
loadJavaSourceEnabled,
|
||||
loadProjectShellAllowed,
|
||||
loadResolvedSkillPaths,
|
||||
loadToolRateLimit,
|
||||
readConfig,
|
||||
searchEnabled,
|
||||
} from "../config.js";
|
||||
@@ -49,7 +50,7 @@ export interface CodeToolset {
|
||||
}
|
||||
|
||||
export async function buildCodeToolset(opts: CodeToolsetOpts): Promise<CodeToolset> {
|
||||
const tools = new ToolRegistry();
|
||||
const tools = new ToolRegistry({ rateLimit: loadToolRateLimit() });
|
||||
const jobs = new JobRegistry();
|
||||
|
||||
const outlineThresholdBytes = loadFilesystemOutlineThresholdBytes();
|
||||
|
||||
+88
-7
@@ -1,6 +1,13 @@
|
||||
/** Library reads only DEEPSEEK_API_KEY from env; the CLI bridges config.json → env var. */
|
||||
|
||||
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
appendFileSync,
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
@@ -13,6 +20,11 @@ import {
|
||||
} from "./index/config.js";
|
||||
import { type McpServerSpec, parseMcpSpec } from "./mcp/spec.js";
|
||||
import { normalizeQQAllowlist, normalizeQQOpenId } from "./qq/access.js";
|
||||
import {
|
||||
type NormalizedToolRateLimitConfig,
|
||||
type ToolRateLimitConfig,
|
||||
normalizeToolRateLimitConfig,
|
||||
} from "./tools/rate-limit.js";
|
||||
|
||||
/** Legacy `fast|smart|max` kept for back-compat with existing config.json files. */
|
||||
export type PresetName = "auto" | "flash" | "pro" | "fast" | "smart" | "max";
|
||||
@@ -110,6 +122,7 @@ export interface PricingOverride {
|
||||
}
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/** Client-side self-throttle in requests/minute — paces outbound chat calls with a min-interval timer. NOT a DeepSeek-enforced limit: DeepSeek's actual cap is concurrency, not RPM (500 for v4-pro, 2500 for v4-flash, account-wide), surfaced as HTTP 429. Set this only to be a polite neighbor on shared infra; single-user CLI rarely needs it. */
|
||||
rpm?: number;
|
||||
}
|
||||
|
||||
@@ -118,6 +131,8 @@ export interface ProxyConfig {
|
||||
disabled?: boolean;
|
||||
/** Additional NO_PROXY patterns (curl syntax). Additive on top of env NO_PROXY and the default DeepSeek-bypass whitelist. */
|
||||
noProxy?: string[];
|
||||
/** When false, route api.deepseek.com / *.deepseek.com through the proxy too (issue #1497 — corporate firewalls that block direct egress). Default true preserves the clash/v2ray US-exit-IP 403 fix. Env `REASONIX_PROXY_DEEPSEEK_DIRECT` overrides. */
|
||||
bypassDeepSeekDirect?: boolean;
|
||||
}
|
||||
|
||||
export interface ReasonixConfig {
|
||||
@@ -151,8 +166,8 @@ export interface ReasonixConfig {
|
||||
session?: string | null;
|
||||
setupCompleted?: boolean;
|
||||
search?: boolean;
|
||||
/** Web search engine backend: "mojeek" (default, scrapes Mojeek), "searxng" (self-hosted SearXNG), "metaso" (Metaso API), "tavily" (LLM-friendly API, free tier), "perplexity" (Perplexity AI), or "exa" (Exa API). */
|
||||
webSearchEngine?: "mojeek" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
|
||||
/** Web search engine backend: "bing" (default, scrapes cn.bing.com), "searxng" (self-hosted SearXNG), "metaso" (Metaso API), "tavily" (LLM-friendly API, free tier), "perplexity" (Perplexity AI), or "exa" (Exa API). */
|
||||
webSearchEngine?: "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa";
|
||||
/** Base URL for SearXNG instance (default http://localhost:8080). */
|
||||
webSearchEndpoint?: string;
|
||||
/** Metaso API key. Falls back to METASO_API_KEY env var. */
|
||||
@@ -166,6 +181,8 @@ export interface ReasonixConfig {
|
||||
|
||||
/** TUI mouse-wheel scrolling via SGR mouse tracking. Default true. Set false to fall back to native terminal drag-select for copy (then wheel is terminal-dependent — most terminals translate wheel→arrow in alt-screen, some don't). */
|
||||
mouseTracking?: boolean;
|
||||
/** Rows scrolled per single SGR mouse-wheel report. Default 1 — most terminals emit 2-5 reports per physical notch, so 1 already produces 2-5 rows per notch (#1419). Bump to 3-5 only if your terminal emits one report per notch and scrolling feels slow (#1494). Clamped to [1, 10]. */
|
||||
mouseWheelRows?: number;
|
||||
dashboard?: {
|
||||
/** Pin the embedded dashboard to a fixed port — required for stable SSH tunnels. 0/absent → ephemeral. */
|
||||
port?: number;
|
||||
@@ -216,6 +233,7 @@ export interface ReasonixConfig {
|
||||
/** Per-app proxy override. Layered on top of HTTPS_PROXY / NO_PROXY env vars + the default DeepSeek-bypass whitelist. */
|
||||
proxy?: ProxyConfig;
|
||||
rateLimit?: RateLimitConfig;
|
||||
toolRateLimit?: ToolRateLimitConfig;
|
||||
/** Host-enforced engineering lifecycle. Defaults to off so opt-outs pay zero prefix cost. */
|
||||
engineeringLifecycle?: {
|
||||
mode?: EngineeringLifecycleMode;
|
||||
@@ -392,13 +410,41 @@ export function readConfig(path: string = defaultConfigPath()): ReasonixConfig {
|
||||
}
|
||||
|
||||
export function writeConfig(cfg: ReasonixConfig, path: string = defaultConfigPath()): void {
|
||||
debugLogConfigWrite(cfg, path);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(cfg, null, 2), "utf8");
|
||||
// Atomic — write to a sibling tmp then rename. A torn write (process
|
||||
// killed mid-write, or another reader catching the file before
|
||||
// writeFileSync finished) used to leave a 0-byte or truncated
|
||||
// config.json, which readConfig would then parse as `{}` and the next
|
||||
// saveX would silently overwrite every other field with that empty
|
||||
// baseline (issue #1535 follow-on — preset reverting to default).
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(cfg, null, 2), "utf8");
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
chmodSync(tmp, 0o600);
|
||||
} catch {
|
||||
/* ignore on platforms without chmod */
|
||||
}
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
/** Append timestamp + keys + preset + stack to REASONIX_DEBUG_PRESET when set. Catches every persisted config change including the dashboard PATCH path that bypasses savePreset(). Zero cost when unset. */
|
||||
function debugLogConfigWrite(cfg: ReasonixConfig, configPath: string): void {
|
||||
const debugPath = process.env.REASONIX_DEBUG_PRESET;
|
||||
if (!debugPath) return;
|
||||
try {
|
||||
const stack = new Error("trace").stack ?? "";
|
||||
const keys = Object.keys(cfg).sort().join(",");
|
||||
const presetField = cfg.preset === undefined ? "(absent)" : JSON.stringify(cfg.preset);
|
||||
const line = `${new Date().toISOString()} writeConfig pid=${process.pid} → ${configPath}\n keys=[${keys}]\n preset=${presetField}\n${stack
|
||||
.split("\n")
|
||||
.slice(1, 10)
|
||||
.map((l) => ` ${l.trim()}`)
|
||||
.join("\n")}\n\n`;
|
||||
appendFileSync(debugPath, line);
|
||||
} catch {
|
||||
/* diagnostic only */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the language from config file. */
|
||||
@@ -583,6 +629,9 @@ export function loadProxyConfig(path: string = defaultConfigPath()): ProxyConfig
|
||||
);
|
||||
if (entries.length > 0) out.noProxy = entries;
|
||||
}
|
||||
if (typeof cfg.bypassDeepSeekDirect === "boolean") {
|
||||
out.bypassDeepSeekDirect = cfg.bypassDeepSeekDirect;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -592,6 +641,18 @@ export function loadRateLimit(path: string = defaultConfigPath()): RateLimitConf
|
||||
return { rpm };
|
||||
}
|
||||
|
||||
export function loadMouseWheelRows(path: string = defaultConfigPath()): number | undefined {
|
||||
const raw = readConfig(path).mouseWheelRows;
|
||||
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) return undefined;
|
||||
return Math.min(raw, 10);
|
||||
}
|
||||
|
||||
export function loadToolRateLimit(
|
||||
path: string = defaultConfigPath(),
|
||||
): false | NormalizedToolRateLimitConfig {
|
||||
return normalizeToolRateLimitConfig(readConfig(path).toolRateLimit);
|
||||
}
|
||||
|
||||
export function saveBaseUrl(url: string, path: string = defaultConfigPath()): void {
|
||||
const cfg = readConfig(path);
|
||||
const trimmed = url.trim();
|
||||
@@ -746,14 +807,17 @@ export function loadJavaSourceEnabled(path: string = defaultConfigPath()): boole
|
||||
|
||||
export function webSearchEngine(
|
||||
path: string = defaultConfigPath(),
|
||||
): "mojeek" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" {
|
||||
): "bing" | "searxng" | "metaso" | "tavily" | "perplexity" | "exa" {
|
||||
const cfg = readConfig(path).webSearchEngine;
|
||||
if (cfg === "searxng") return "searxng";
|
||||
if (cfg === "metaso") return "metaso";
|
||||
if (cfg === "tavily") return "tavily";
|
||||
if (cfg === "perplexity") return "perplexity";
|
||||
if (cfg === "exa") return "exa";
|
||||
return "mojeek";
|
||||
// Any other value (including legacy "mojeek" from configs predating the
|
||||
// engine swap) falls through to bing. Read-only — we never rewrite the
|
||||
// user's config, so `/search-engine mojeek` later still rejects loudly.
|
||||
return "bing";
|
||||
}
|
||||
|
||||
export function webSearchEndpoint(path: string = defaultConfigPath()): string {
|
||||
@@ -1098,11 +1162,28 @@ export function loadPreset(path: string = defaultConfigPath()): PresetName | und
|
||||
|
||||
/** Persist preset so `/preset pro` (or `/model deepseek-v4-pro`) sticks across relaunches. */
|
||||
export function savePreset(preset: PresetName, path: string = defaultConfigPath()): void {
|
||||
debugLogPresetWrite(preset, path);
|
||||
const cfg = readConfig(path);
|
||||
cfg.preset = preset;
|
||||
writeConfig(cfg, path);
|
||||
}
|
||||
|
||||
function debugLogPresetWrite(preset: PresetName, configPath: string): void {
|
||||
const debugPath = process.env.REASONIX_DEBUG_PRESET;
|
||||
if (!debugPath) return;
|
||||
try {
|
||||
const stack = new Error("trace").stack ?? "";
|
||||
const line = `${new Date().toISOString()} savePreset(${JSON.stringify(preset)}) → ${configPath}\n${stack
|
||||
.split("\n")
|
||||
.slice(1, 8)
|
||||
.map((l) => ` ${l.trim()}`)
|
||||
.join("\n")}\n\n`;
|
||||
appendFileSync(debugPath, line);
|
||||
} catch {
|
||||
/* diagnostic only */
|
||||
}
|
||||
}
|
||||
|
||||
export function loadIndexUserConfig(path: string = defaultConfigPath()): IndexUserConfig {
|
||||
return readConfig(path).index ?? {};
|
||||
}
|
||||
|
||||
+59
-30
@@ -1,7 +1,6 @@
|
||||
import type { DeepSeekClient } from "./client.js";
|
||||
import { Usage } from "./client.js";
|
||||
import { healLoadedMessages } from "./loop.js";
|
||||
import { thinkingModeForModel } from "./loop.js";
|
||||
import { stripHallucinatedToolMarkup } from "./loop.js";
|
||||
import { buildAssistantMessage } from "./loop/messages.js";
|
||||
import { DEFAULT_MAX_RESULT_CHARS } from "./mcp/registry.js";
|
||||
@@ -17,7 +16,16 @@ import {
|
||||
estimateConversationTokens,
|
||||
estimateRequestTokens,
|
||||
} from "./tokenizer.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
import type { ChatMessage, ToolSpec } from "./types.js";
|
||||
|
||||
function extractPinnedConstraints(systemPrompt: string): string {
|
||||
// matchAll because the system prompt can carry multiple blocks under the same
|
||||
// prefix — e.g. global User memory + per-project User memory, or several
|
||||
// Project memory files. Single .match() would only grab the first.
|
||||
const pattern =
|
||||
/# (?:HIGH PRIORITY constraints|User memory|Project memory)[\s\S]*?(?=\n# |\n---|$)/g;
|
||||
return Array.from(systemPrompt.matchAll(pattern), (m) => m[0]).join("\n\n");
|
||||
}
|
||||
|
||||
/** Auto-fold when a turn's response shows promptTokens above this fraction of ctxMax. */
|
||||
export const HISTORY_FOLD_THRESHOLD = 0.75;
|
||||
@@ -58,6 +66,12 @@ export interface ContextManagerDeps {
|
||||
sessionName: string | null;
|
||||
getAbortSignal: () => AbortSignal;
|
||||
getCurrentTurn: () => number;
|
||||
getSystemPrompt: () => string;
|
||||
/** Reuses the live prefix → fold summary call shares the cached bytes the main agent already paid for. */
|
||||
getToolSpecs?: () => readonly ToolSpec[];
|
||||
getFewShots?: () => readonly ChatMessage[];
|
||||
/** Fired when the message log was rewritten by fold/mechanicalTruncate; lets the loop drop session-scoped caches whose validity rested on the elided history (e.g. read-before-edit tracker). */
|
||||
onLogRewrite?: () => void;
|
||||
}
|
||||
|
||||
export type PostUsageDecisionKind = "none" | "fold" | "exit-with-summary";
|
||||
@@ -89,24 +103,33 @@ export interface FoldResult {
|
||||
summaryChars: number;
|
||||
}
|
||||
|
||||
// Stub pins in head so the summarizer doesn't paraphrase them; dedupe by name, last invocation wins.
|
||||
function extractPinnedSkills(head: ChatMessage[]): {
|
||||
stubbedHead: ChatMessage[];
|
||||
pinnedBodies: string[];
|
||||
} {
|
||||
function buildFoldSummaryInstruction(pinnedSkillNames: string[]): string {
|
||||
const base =
|
||||
"Summarize the conversation above as one self-contained prose recap. Preserve the user's " +
|
||||
"ORIGINAL OBJECTIVE (never paraphrase away negative constraints like 'do NOT do X'), all " +
|
||||
"'do not' / 'never' / 'avoid' instructions, decisions reached, files inspected or modified, " +
|
||||
"tool results still relevant, and any open todos. Skip turn-by-turn play-by-play. " +
|
||||
"Output plain prose only — no tool calls, no markdown headings, no SEARCH/REPLACE blocks.";
|
||||
if (pinnedSkillNames.length === 0) return base;
|
||||
const list = pinnedSkillNames.map((n) => `"${n}"`).join(", ");
|
||||
return `${base} The following skill memos are pinned verbatim and appended after your summary — do NOT quote or paraphrase their bodies: ${list}.`;
|
||||
}
|
||||
|
||||
// Dedupe by name, last invocation wins. Read-only — leaves head bytes unchanged so the
|
||||
// summarizer call's prefix still matches what the main agent already cached.
|
||||
function collectPinnedSkills(head: ChatMessage[]): { names: string[]; bodies: string[] } {
|
||||
const pinned = new Map<string, string>();
|
||||
const stubbedHead = head.map((msg) => {
|
||||
if (typeof msg.content !== "string") return msg;
|
||||
let hit = false;
|
||||
const next = msg.content.replace(SKILL_PIN_REGEX, (full, name: string) => {
|
||||
for (const msg of head) {
|
||||
if (typeof msg.content !== "string") continue;
|
||||
SKILL_PIN_REGEX.lastIndex = 0;
|
||||
for (const match of msg.content.matchAll(SKILL_PIN_REGEX)) {
|
||||
const name = match[1] as string;
|
||||
const full = match[0];
|
||||
pinned.delete(name);
|
||||
pinned.set(name, full);
|
||||
hit = true;
|
||||
return `[skill ${JSON.stringify(name)} memo — preserved separately, do not summarize.]`;
|
||||
});
|
||||
return hit ? { ...msg, content: next } : msg;
|
||||
});
|
||||
return { stubbedHead, pinnedBodies: [...pinned.values()] };
|
||||
}
|
||||
}
|
||||
return { names: [...pinned.keys()], bodies: [...pinned.values()] };
|
||||
}
|
||||
|
||||
export class ContextManager {
|
||||
@@ -217,19 +240,23 @@ export class ContextManager {
|
||||
const headTokens = totalTokens - cumTokens;
|
||||
if (headTokens < totalTokens * HISTORY_FOLD_MIN_SAVINGS_FRACTION) return noop;
|
||||
|
||||
const { stubbedHead, pinnedBodies } = extractPinnedSkills(head);
|
||||
const summary = await this.summarizeForFold(stubbedHead);
|
||||
const { names: pinnedNames, bodies: pinnedBodies } = collectPinnedSkills(head);
|
||||
const summary = await this.summarizeForFold(head, pinnedNames);
|
||||
if (!summary.content) return noop;
|
||||
|
||||
const memoTail =
|
||||
pinnedBodies.length > 0 ? `\n\n${SKILL_PIN_MEMO_HEADER}\n\n${pinnedBodies.join("\n\n")}` : "";
|
||||
const constraints = extractPinnedConstraints(this.deps.getSystemPrompt());
|
||||
const constraintTail = constraints
|
||||
? `\n\n[PINNED CONSTRAINTS — preserved verbatim]\n\n${constraints}`
|
||||
: "";
|
||||
// Route via buildAssistantMessage so the synthetic summary carries
|
||||
// reasoning_content under thinking-mode sessions — without it the
|
||||
// next API call 400s with "must be passed back" (#1042). Stamp uses
|
||||
// the SESSION model so an empty placeholder is added even when the
|
||||
// summarizer call somehow returned no reasoning.
|
||||
const summaryMsg = buildAssistantMessage(
|
||||
HISTORY_FOLD_MARKER + summary.content + memoTail,
|
||||
HISTORY_FOLD_MARKER + summary.content + memoTail + constraintTail,
|
||||
[],
|
||||
model,
|
||||
summary.reasoningContent,
|
||||
@@ -237,6 +264,7 @@ export class ContextManager {
|
||||
const replacement = [summaryMsg, ...tail];
|
||||
this.deps.log.compactInPlace(replacement);
|
||||
this.persistRewrite(replacement);
|
||||
this.deps.onLogRewrite?.();
|
||||
return {
|
||||
folded: true,
|
||||
beforeMessages: all.length,
|
||||
@@ -301,6 +329,7 @@ export class ContextManager {
|
||||
if (replacement.length === all.length) return noop;
|
||||
this.deps.log.compactInPlace(replacement);
|
||||
this.persistRewrite(replacement);
|
||||
this.deps.onLogRewrite?.();
|
||||
return {
|
||||
folded: true,
|
||||
beforeMessages: all.length,
|
||||
@@ -328,19 +357,19 @@ export class ContextManager {
|
||||
|
||||
private async summarizeForFold(
|
||||
messagesToSummarize: ChatMessage[],
|
||||
pinnedSkillNames: string[],
|
||||
): Promise<{ content: string; reasoningContent: string }> {
|
||||
const summaryModel = "deepseek-v4-flash";
|
||||
const systemPrompt =
|
||||
"You compress conversation history for a coding agent. Output one prose recap that preserves: the user's overall goal, decisions and conclusions reached, files inspected or modified, important tool results still relevant to ongoing work, and any open todos. Skip turn-by-turn play-by-play. No tool calls, no markdown headings, no SEARCH/REPLACE blocks — plain prose only.";
|
||||
const healed = healLoadedMessages(messagesToSummarize, DEFAULT_MAX_RESULT_CHARS).messages;
|
||||
const agentSystem = this.deps.getSystemPrompt();
|
||||
const fewShots = this.deps.getFewShots?.() ?? [];
|
||||
const tools = this.deps.getToolSpecs?.() ?? [];
|
||||
const instruction = buildFoldSummaryInstruction(pinnedSkillNames);
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "system", content: agentSystem },
|
||||
...fewShots.map((m) => ({ ...m })),
|
||||
...healed,
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Summarize the conversation above as plain prose. This summary replaces the original turns to free context — make it self-contained.",
|
||||
},
|
||||
{ role: "user", content: instruction },
|
||||
];
|
||||
const turnSignal = this.deps.getAbortSignal();
|
||||
const foldCtrl = new AbortController();
|
||||
@@ -369,9 +398,9 @@ export class ContextManager {
|
||||
this.deps.client.chat({
|
||||
model: summaryModel,
|
||||
messages,
|
||||
tools: tools.length ? (tools as ToolSpec[]) : undefined,
|
||||
signal: foldCtrl.signal,
|
||||
thinking: thinkingModeForModel(summaryModel),
|
||||
reasoningEffort: "high",
|
||||
thinking: "disabled",
|
||||
}),
|
||||
abortPromise,
|
||||
timeoutPromise,
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
/** Batches same-style runs into one SGR — per-cell escapes balloon 200x50 frames to 50KB+. */
|
||||
|
||||
import type { Cell, Frame, FrameRow } from "./types.js";
|
||||
|
||||
const ESC = "\u001b";
|
||||
const RESET = `${ESC}[0m`;
|
||||
|
||||
interface Style {
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
inverse?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
function sameStyle(a: Style, b: Style): boolean {
|
||||
return (
|
||||
a.fg === b.fg &&
|
||||
a.bg === b.bg &&
|
||||
!!a.bold === !!b.bold &&
|
||||
!!a.dim === !!b.dim &&
|
||||
!!a.italic === !!b.italic &&
|
||||
!!a.underline === !!b.underline &&
|
||||
!!a.inverse === !!b.inverse &&
|
||||
a.href === b.href
|
||||
);
|
||||
}
|
||||
|
||||
function fgEscape(color: string | undefined): string | null {
|
||||
if (!color) return null;
|
||||
const rgb = parseColor(color);
|
||||
if (rgb) return `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
|
||||
const named = NAMED_FG[color.toLowerCase()];
|
||||
if (named !== undefined) return String(named);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bgEscape(color: string | undefined): string | null {
|
||||
if (!color) return null;
|
||||
const rgb = parseColor(color);
|
||||
if (rgb) return `48;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
|
||||
const named = NAMED_BG[color.toLowerCase()];
|
||||
if (named !== undefined) return String(named);
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseColor(s: string): [number, number, number] | null {
|
||||
if (!s.startsWith("#")) return null;
|
||||
const hex = s.slice(1);
|
||||
if (hex.length !== 6) return null;
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
const NAMED_FG: Record<string, number> = {
|
||||
black: 30,
|
||||
red: 31,
|
||||
green: 32,
|
||||
yellow: 33,
|
||||
blue: 34,
|
||||
magenta: 35,
|
||||
cyan: 36,
|
||||
white: 37,
|
||||
gray: 90,
|
||||
grey: 90,
|
||||
brightred: 91,
|
||||
brightgreen: 92,
|
||||
brightyellow: 93,
|
||||
brightblue: 94,
|
||||
brightmagenta: 95,
|
||||
brightcyan: 96,
|
||||
brightwhite: 97,
|
||||
};
|
||||
|
||||
const NAMED_BG: Record<string, number> = {
|
||||
black: 40,
|
||||
red: 41,
|
||||
green: 42,
|
||||
yellow: 43,
|
||||
blue: 44,
|
||||
magenta: 45,
|
||||
cyan: 46,
|
||||
white: 47,
|
||||
gray: 100,
|
||||
grey: 100,
|
||||
};
|
||||
|
||||
function styleToAnsi(s: Style): string {
|
||||
const codes: string[] = [];
|
||||
if (s.bold) codes.push("1");
|
||||
if (s.dim) codes.push("2");
|
||||
if (s.italic) codes.push("3");
|
||||
if (s.underline) codes.push("4");
|
||||
if (s.inverse) codes.push("7");
|
||||
const fg = fgEscape(s.fg);
|
||||
if (fg) codes.push(fg);
|
||||
const bg = bgEscape(s.bg);
|
||||
if (bg) codes.push(bg);
|
||||
if (codes.length === 0) return "";
|
||||
return `${ESC}[${codes.join(";")}m`;
|
||||
}
|
||||
|
||||
const EMPTY_STYLE: Style = {};
|
||||
|
||||
/** RESET at row end so styling never bleeds onto the next line. */
|
||||
export function frameToAnsi(f: Frame, opts: { plain?: boolean } = {}): string {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < f.rows.length; i++) {
|
||||
out.push(rowToAnsi(f.rows[i]!, opts));
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function rowToAnsi(row: FrameRow, opts: { plain?: boolean }): string {
|
||||
if (opts.plain) {
|
||||
let s = "";
|
||||
for (const c of row) {
|
||||
if (c.tail) continue;
|
||||
s += c.char;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
let result = "";
|
||||
let curStyle = EMPTY_STYLE;
|
||||
let inHref = false;
|
||||
let curHref: string | undefined;
|
||||
|
||||
for (const c of row) {
|
||||
if (c.tail) continue; // tail cells contribute no visible output
|
||||
const cellStyle: Style = {
|
||||
fg: c.fg,
|
||||
bg: c.bg,
|
||||
bold: c.bold,
|
||||
dim: c.dim,
|
||||
italic: c.italic,
|
||||
underline: c.underline,
|
||||
inverse: c.inverse,
|
||||
href: c.href,
|
||||
};
|
||||
// OSC-8 hyperlink open/close
|
||||
if (cellStyle.href !== curHref) {
|
||||
if (inHref) {
|
||||
// close prior link
|
||||
result += `${ESC}]8;;${ESC}\\`;
|
||||
inHref = false;
|
||||
}
|
||||
if (cellStyle.href !== undefined) {
|
||||
result += `${ESC}]8;;${cellStyle.href}${ESC}\\`;
|
||||
inHref = true;
|
||||
}
|
||||
curHref = cellStyle.href;
|
||||
}
|
||||
// SGR styling — emit only when changed
|
||||
if (!sameStyle(curStyle, cellStyle)) {
|
||||
// Reset before applying new style so e.g. bold→non-bold works
|
||||
// (some terminals don't have a "turn off bold" code reliably).
|
||||
result += RESET;
|
||||
result += styleToAnsi(cellStyle);
|
||||
curStyle = cellStyle;
|
||||
}
|
||||
result += c.char;
|
||||
}
|
||||
if (inHref) result += `${ESC}]8;;${ESC}\\`;
|
||||
result += RESET;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function rowText(row: FrameRow): string {
|
||||
let s = "";
|
||||
for (const c of row) {
|
||||
if (c.tail) continue;
|
||||
s += c.char;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
/** Pure primitives on Frame; every row's cells sum to exactly `Frame.width` (tests in tests/frame.test.ts lock this). */
|
||||
|
||||
import type { Cell, Frame, FrameRow, TextOpts } from "./types.js";
|
||||
import { graphemeWidth, graphemes } from "./width.js";
|
||||
|
||||
/** Single space cell with no styling — the universal padding atom. */
|
||||
const SPACE: Cell = { char: " ", width: 1 };
|
||||
/** Tail half of a 2-wide grapheme — alignment only, no glyph. */
|
||||
const TAIL: Cell = { char: "", width: 1, tail: true };
|
||||
|
||||
export function empty(width = 0): Frame {
|
||||
return { width, rows: [] };
|
||||
}
|
||||
|
||||
export function blank(width: number, height: number): Frame {
|
||||
if (width <= 0 || height <= 0) return empty(Math.max(0, width));
|
||||
const row: FrameRow = Object.freeze(Array.from({ length: width }, () => SPACE));
|
||||
const rows: FrameRow[] = [];
|
||||
for (let i = 0; i < height; i++) rows.push(row);
|
||||
return { width, rows };
|
||||
}
|
||||
|
||||
export function text(s: string, opts: TextOpts): Frame {
|
||||
const { width, fg, bg, bold, dim, italic, underline, inverse, href } = opts;
|
||||
if (width <= 0) return empty(0);
|
||||
|
||||
const styleOf = (g: string, w: 1 | 2): Cell => {
|
||||
const base: Cell = { char: g, width: w };
|
||||
if (fg !== undefined) base.fg = fg;
|
||||
if (bg !== undefined) base.bg = bg;
|
||||
if (bold) base.bold = true;
|
||||
if (dim) base.dim = true;
|
||||
if (italic) base.italic = true;
|
||||
if (underline) base.underline = true;
|
||||
if (inverse) base.inverse = true;
|
||||
if (href !== undefined) base.href = href;
|
||||
return base;
|
||||
};
|
||||
|
||||
const rows: FrameRow[] = [];
|
||||
const lines = s.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.length === 0) {
|
||||
rows.push(padRowRight([], width));
|
||||
continue;
|
||||
}
|
||||
let buf: Cell[] = [];
|
||||
let bufWidth = 0;
|
||||
for (const g of graphemes(line)) {
|
||||
const w = graphemeWidth(g);
|
||||
if (w === 0) continue; // combining mark / ZWJ — already part of prior cell
|
||||
if (bufWidth + w > width) {
|
||||
rows.push(padRowRight(buf, width - bufWidth));
|
||||
buf = [];
|
||||
bufWidth = 0;
|
||||
}
|
||||
buf.push(styleOf(g, w as 1 | 2));
|
||||
if (w === 2) buf.push(TAIL);
|
||||
bufWidth += w;
|
||||
}
|
||||
rows.push(padRowRight(buf, width - bufWidth));
|
||||
}
|
||||
return { width, rows };
|
||||
}
|
||||
|
||||
function padRowRight(cells: Cell[], extraSpaces: number): FrameRow {
|
||||
if (extraSpaces <= 0) return cells.slice();
|
||||
const out = cells.slice();
|
||||
for (let i = 0; i < extraSpaces; i++) out.push(SPACE);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Generate a row of pure-space padding at the given width. */
|
||||
function spacerRow(width: number): FrameRow {
|
||||
if (width <= 0) return [];
|
||||
return Array.from({ length: width }, () => SPACE);
|
||||
}
|
||||
|
||||
export function vstack(...frames: Frame[]): Frame {
|
||||
if (frames.length === 0) return empty(0);
|
||||
const w = Math.max(...frames.map((f) => f.width));
|
||||
const rows: FrameRow[] = [];
|
||||
for (const f of frames) {
|
||||
if (f.width === w) {
|
||||
rows.push(...f.rows);
|
||||
} else {
|
||||
const extra = w - f.width;
|
||||
for (const r of f.rows) rows.push(padRowRight(r as Cell[], extra));
|
||||
}
|
||||
}
|
||||
return { width: w, rows };
|
||||
}
|
||||
|
||||
export function hstack(...frames: Frame[]): Frame {
|
||||
if (frames.length === 0) return empty(0);
|
||||
const h = Math.max(...frames.map((f) => f.rows.length));
|
||||
const w = frames.reduce((a, f) => a + f.width, 0);
|
||||
const rows: FrameRow[] = [];
|
||||
for (let i = 0; i < h; i++) {
|
||||
const cells: Cell[] = [];
|
||||
for (const f of frames) {
|
||||
const r = f.rows[i] ?? spacerRow(f.width);
|
||||
cells.push(...r);
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
return { width: w, rows };
|
||||
}
|
||||
|
||||
/** Padding is in cells (visual columns), not graphemes. */
|
||||
export function pad(f: Frame, top: number, right: number, bottom: number, left: number): Frame {
|
||||
const newWidth = f.width + Math.max(0, left) + Math.max(0, right);
|
||||
const tPad = Math.max(0, top);
|
||||
const bPad = Math.max(0, bottom);
|
||||
const blank = spacerRow(newWidth);
|
||||
const rows: FrameRow[] = [];
|
||||
for (let i = 0; i < tPad; i++) rows.push(blank);
|
||||
if (left <= 0 && right <= 0) {
|
||||
rows.push(...f.rows);
|
||||
} else {
|
||||
const lPad = spacerRow(Math.max(0, left));
|
||||
const rPad = spacerRow(Math.max(0, right));
|
||||
for (const r of f.rows) rows.push([...lPad, ...r, ...rPad]);
|
||||
}
|
||||
for (let i = 0; i < bPad; i++) rows.push(blank);
|
||||
return { width: newWidth, rows };
|
||||
}
|
||||
|
||||
export function borderLeft(f: Frame, color: string, char = "│"): Frame {
|
||||
const bar: Cell = { char, width: 1, fg: color };
|
||||
const newWidth = f.width + 1;
|
||||
const rows: FrameRow[] = [];
|
||||
for (const r of f.rows) rows.push([bar, ...r]);
|
||||
return { width: newWidth, rows };
|
||||
}
|
||||
|
||||
/** Out-of-range bounds clamp; never throws. */
|
||||
export function slice(f: Frame, top: number, height: number): Frame {
|
||||
if (height <= 0 || f.rows.length === 0) return { width: f.width, rows: [] };
|
||||
const start = Math.max(0, Math.min(top, f.rows.length));
|
||||
const end = Math.max(start, Math.min(start + height, f.rows.length));
|
||||
return { width: f.width, rows: f.rows.slice(start, end) };
|
||||
}
|
||||
|
||||
export function bottom(f: Frame, height: number): Frame {
|
||||
if (height <= 0) return { width: f.width, rows: [] };
|
||||
return slice(f, Math.max(0, f.rows.length - height), height);
|
||||
}
|
||||
|
||||
/** `offset` counted from bottom; offset=0 is `bottom(f, height)`. Caps to a valid slice. */
|
||||
export function viewport(f: Frame, offset: number, height: number): Frame {
|
||||
if (height <= 0) return { width: f.width, rows: [] };
|
||||
const maxOffset = Math.max(0, f.rows.length - height);
|
||||
const o = Math.max(0, Math.min(offset, maxOffset));
|
||||
const start = Math.max(0, f.rows.length - height - o);
|
||||
return slice(f, start, height);
|
||||
}
|
||||
|
||||
/** Result has SAME dimensions as `base` — overlay never grows the frame. */
|
||||
export function overlay(base: Frame, top: Frame, x: number, y: number): Frame {
|
||||
const rows: FrameRow[] = base.rows.map((r) => r.slice());
|
||||
for (let i = 0; i < top.rows.length; i++) {
|
||||
const targetRow = rows[y + i] as Cell[] | undefined;
|
||||
if (!targetRow) continue;
|
||||
const src = top.rows[i]!;
|
||||
let col = x;
|
||||
for (const cell of src) {
|
||||
if (col >= 0 && col < base.width) targetRow[col] = cell;
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
return { width: base.width, rows };
|
||||
}
|
||||
|
||||
/** Cut splitting a 2-wide grapheme replaces the orphaned head with a space — half-glyphs render unpredictably. */
|
||||
export function fitWidth(f: Frame, width: number): Frame {
|
||||
if (f.width === width) return f;
|
||||
const rows: FrameRow[] = [];
|
||||
for (const r of f.rows) {
|
||||
if (r.length >= width) {
|
||||
const cut = r.slice(0, width) as Cell[];
|
||||
const last = cut[cut.length - 1];
|
||||
if (last && last.width === 2 && !last.tail) {
|
||||
// Cut splits a 2-wide grapheme — head kept, tail dropped.
|
||||
// Replace the orphaned head with a space so the visual width
|
||||
// matches the row count.
|
||||
cut[cut.length - 1] = SPACE;
|
||||
}
|
||||
rows.push(cut);
|
||||
} else {
|
||||
rows.push(padRowRight(r as Cell[], width - r.length));
|
||||
}
|
||||
}
|
||||
return { width, rows };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export type { Cell, Frame, FrameRow, TextOpts } from "./types.js";
|
||||
export {
|
||||
blank,
|
||||
borderLeft,
|
||||
bottom,
|
||||
empty,
|
||||
fitWidth,
|
||||
hstack,
|
||||
overlay,
|
||||
pad,
|
||||
slice,
|
||||
text,
|
||||
viewport,
|
||||
vstack,
|
||||
} from "./frame.js";
|
||||
export { frameToAnsi, rowText } from "./ansi.js";
|
||||
export { graphemeWidth, graphemes, stringWidth } from "./width.js";
|
||||
@@ -1,43 +0,0 @@
|
||||
/** Canonical grid: every row's cell array totals exactly `Frame.width` (counting `tail` cells for 2-wide chars). */
|
||||
|
||||
/** `width` is canonical — never re-derived from the character. ANSI lives only in ansi.ts paint. */
|
||||
export interface Cell {
|
||||
/** 2-wide chars emit a `tail: true, char: ""` follower so row.length === Frame.width invariant holds. */
|
||||
char: string;
|
||||
/** 1 for ASCII / Latin / most BMP. 2 for CJK / emoji / fullwidth. */
|
||||
width: 1 | 2;
|
||||
/** Sentinel for the second cell of a 2-wide grapheme. */
|
||||
tail?: boolean;
|
||||
/** Foreground color: hex `#rrggbb` or named ANSI ("red", "cyan"). */
|
||||
fg?: string;
|
||||
/** Background color: hex `#rrggbb` or named ANSI. */
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
inverse?: boolean;
|
||||
/** OSC-8 hyperlink target (cell renders as a clickable link). */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/** INVARIANT: `cells.reduce((a, c) => a + (c.tail ? 0 : c.width), 0) === Frame.width`. */
|
||||
export type FrameRow = readonly Cell[];
|
||||
|
||||
export interface Frame {
|
||||
readonly width: number;
|
||||
readonly rows: readonly FrameRow[];
|
||||
}
|
||||
|
||||
export interface TextOpts {
|
||||
/** Wrap column. Mandatory — text without a budget is a rendering bug. */
|
||||
width: number;
|
||||
fg?: string;
|
||||
bg?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
inverse?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
+79
-39
@@ -46,6 +46,16 @@ export const EN: TranslationSchema = {
|
||||
sessions: {
|
||||
emptyHint:
|
||||
"no saved sessions yet — run `reasonix chat` (sessions are auto-saved unless --no-session).",
|
||||
listHeader: "Saved sessions (~/.reasonix/sessions/):",
|
||||
inspectHint: "Inspect: reasonix sessions <name>",
|
||||
resumeHint: "Resume: reasonix chat --session <name>",
|
||||
noSession: 'no session named "{name}" (or it\u2019s empty).',
|
||||
lookedAt: "looked at: {path}",
|
||||
noIdleSessions: "no sessions idle \u2265{days} days. Nothing pruned.",
|
||||
wouldPrune: "would prune {count} session(s) idle \u2265{days} days:",
|
||||
dryRunHint: "re-run without --dry-run to actually delete.",
|
||||
prunedCount: "pruned {count} session(s) idle \u2265{days} days:",
|
||||
daysInvalid: "--days must be a positive integer (got {days}).",
|
||||
},
|
||||
ui: {
|
||||
welcome: "Run `reasonix` any time to start chatting — your settings are remembered.",
|
||||
@@ -157,10 +167,6 @@ export const EN: TranslationSchema = {
|
||||
title: "copy / paste",
|
||||
rows: [
|
||||
{ key: "select text", text: "drag to select — terminal-native (no modifier needed)" },
|
||||
{
|
||||
key: "/copy",
|
||||
text: "vim/tmux-style copy mode — works in SSH/mosh/tmux where drag-select can't extend past the viewport",
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
text: "Ctrl+Shift+C (Win/Linux) · Cmd+C (macOS) — or auto-copy-on-select if your terminal does it",
|
||||
@@ -255,9 +261,6 @@ export const EN: TranslationSchema = {
|
||||
},
|
||||
slash: {
|
||||
help: { description: "show the full command reference" },
|
||||
copy: {
|
||||
description: "open vim/tmux-style copy mode — j/k navigate, v select, y yank to clipboard",
|
||||
},
|
||||
status: { description: "current model, flags, context, session" },
|
||||
preset: {
|
||||
description: "model bundle — auto escalates flash → pro, flash/pro lock",
|
||||
@@ -424,8 +427,8 @@ export const EN: TranslationSchema = {
|
||||
},
|
||||
"search-engine": {
|
||||
description:
|
||||
"switch web search backend — mojeek (default, no deps), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native)",
|
||||
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
"switch web search backend — bing (default, works from CN without proxy), searxng (self-hosted), metaso (free 100/d), tavily (free 1000/mo), perplexity (AI-native), or exa (AI-native)",
|
||||
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
@@ -597,6 +600,8 @@ export const EN: TranslationSchema = {
|
||||
verboseOn: "▸ verbose mode on — full reasoning + tool output",
|
||||
verboseOff: "▸ verbose mode off — head/tail elision restored",
|
||||
commandFailed: "! command failed",
|
||||
steerInjected: "▸ steering queued — will be added after the current step",
|
||||
steerCommandRejected: "▸ commands are disabled while steering a busy turn",
|
||||
btwUsage: "▸ /btw <question> — ask a side question without polluting the conversation context.",
|
||||
btwHeader: "≫ btw",
|
||||
btwFailed: "/btw failed",
|
||||
@@ -647,6 +652,8 @@ export const EN: TranslationSchema = {
|
||||
editHistoryRevertFile: "/undo {id} {path} \u2192 revert just this file",
|
||||
mcpFailed: "MCP {name} failed",
|
||||
mcpWarn: "MCP {name} warn",
|
||||
unknownTheme: "unknown theme: {name}\navailable: {choices}",
|
||||
themeSaved: "theme saved: {name}\nactive on next launch: {active}",
|
||||
},
|
||||
hooks: {
|
||||
head: "hook {tag} `{cmd}` {decision}{truncTag}",
|
||||
@@ -705,6 +712,8 @@ export const EN: TranslationSchema = {
|
||||
"Out of balance (DeepSeek 402): {inner}. Top up at https://platform.deepseek.com/top_up — the panel header shows your balance once it's non-zero.",
|
||||
badparam422: "Invalid parameter (DeepSeek 422): {inner}",
|
||||
badrequest400: "Bad request (DeepSeek 400): {inner}",
|
||||
concurrency429:
|
||||
"DeepSeek concurrency limit hit (429): {inner}. The account has too many in-flight requests (cap: 500 for v4-pro, 2500 for v4-flash, summed across API keys account-wide). Usually means another Reasonix process is sharing the same key, or a parallel subagent fan-out overshot. Wait a few seconds and retry, reduce parallelism, or request a higher cap at https://platform.deepseek.com.",
|
||||
deepseek5xxHead:
|
||||
"DeepSeek service unavailable ({status}) — this is a DeepSeek-side problem, not Reasonix. Already retried 4× with backoff.",
|
||||
deepseek5xxReachable:
|
||||
@@ -1074,6 +1083,10 @@ export const EN: TranslationSchema = {
|
||||
noArchives:
|
||||
"no archived plans yet for this session — they auto-archive when every step is done",
|
||||
archivedHeader: "Archived ({count}):",
|
||||
evidencePending:
|
||||
" ! evidence pending — current step needs verification/diff/checkpoint/manual evidence",
|
||||
evidenceLine: " evidence {stepId}: {summary}",
|
||||
archivedEvidenceLine: " evidence: {summary}",
|
||||
replayNoSession:
|
||||
"no session attached — `/replay` is per-session. Run `reasonix code` in a project to get a session.",
|
||||
replayNoArchives:
|
||||
@@ -1187,7 +1200,8 @@ export const EN: TranslationSchema = {
|
||||
currentEngine: "Current web search engine: {engine}",
|
||||
endpoint: "SearXNG endpoint: {url}",
|
||||
usageHeader: "Usage:",
|
||||
usageMojeek: " /search-engine mojeek use Mojeek (default, no external deps)",
|
||||
usageBing:
|
||||
" /search-engine bing use Bing (default, works from CN without proxy)",
|
||||
usageSearxng: " /search-engine searxng use SearXNG at default endpoint",
|
||||
usageSearxngUrl: " /search-engine searxng <url> use SearXNG at custom endpoint",
|
||||
usageMetaso:
|
||||
@@ -1302,6 +1316,8 @@ export const EN: TranslationSchema = {
|
||||
"no $EDITOR / $VISUAL / $GIT_EDITOR set \u2014 export one (e.g. `export EDITOR=nano`) and retry",
|
||||
editorExited: "editor exited with code {code}",
|
||||
typeaheadStaged: "\u25b8 {count} line(s) staged \u00b7 esc recall",
|
||||
steerPlaceholder: "type to steer the current task — commands are disabled while busy",
|
||||
steerHint: "send — injected mid-turn",
|
||||
},
|
||||
pathConfirm: {
|
||||
title: "Outside-sandbox path",
|
||||
@@ -1323,6 +1339,12 @@ export const EN: TranslationSchema = {
|
||||
pathLabel: "path",
|
||||
sandboxLabel: "sandbox",
|
||||
allowPrefixLabel: "prefix",
|
||||
promptTitleRead: "Access path \u2014 read",
|
||||
promptTitleWrite: "Access path \u2014 write",
|
||||
actionAllowRead: "Allow read",
|
||||
actionAllowWrite: "Allow write",
|
||||
actionAlwaysAllow: "Always allow \u2014 {prefix}",
|
||||
actionDeny: "Deny",
|
||||
},
|
||||
shellConfirm: {
|
||||
title: "Shell command",
|
||||
@@ -1347,6 +1369,11 @@ export const EN: TranslationSchema = {
|
||||
waitLabel: "wait",
|
||||
previewMore: "… {n} more line hidden — press esc, ask the model to split it",
|
||||
previewMorePlural: "… {n} more lines hidden — press esc, ask the model to split it",
|
||||
promptTitleRunCommand: "Run command",
|
||||
promptTitleRunBackground: "Run background command",
|
||||
actionRunOnce: "Run once",
|
||||
actionAlwaysAllow: "Always allow \u2014 {prefix}",
|
||||
actionDeny: "Deny",
|
||||
},
|
||||
editConfirm: {
|
||||
footer:
|
||||
@@ -1495,25 +1522,25 @@ export const EN: TranslationSchema = {
|
||||
},
|
||||
webErrors: {
|
||||
status:
|
||||
"web_search {status} \u2014 try: the search backend returned an error; rephrase the query, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search {status} \u2014 try: the search backend returned an error; rephrase the query, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
rateLimit429:
|
||||
"web_search 429 \u2014 try: wait 10s before retrying, or rephrase the query; the search backend is rate-limiting this client",
|
||||
forbidden403:
|
||||
"web_search 403 \u2014 try: the search backend is blocking this client; switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa, or wait and retry later",
|
||||
"web_search 403 \u2014 try: the search backend is blocking this client; switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa, or wait and retry later",
|
||||
serverError5xx:
|
||||
"web_search {status} \u2014 try: open the search URL in a browser; if it loads this is transient and a retry in 30s may help",
|
||||
mojeekBlocked:
|
||||
"web_search: Mojeek anti-bot page \u2014 rate-limited or blocked \u2014 try: wait 30s and retry, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
mojeekNoResults:
|
||||
"web_search: 0 results but response doesn't look like a real empty page ({chars} chars, first 120: {preview}) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
bingBlocked:
|
||||
"web_search: Bing anti-bot page \u2014 rate-limited or blocked \u2014 try: wait 30s and retry, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
bingNoResults:
|
||||
"web_search: 0 results but response doesn't look like a real empty page ({chars} chars, first 120: {preview}) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
invalidEndpoint:
|
||||
'web_search: invalid SearXNG endpoint "{endpoint}" \u2014 try: set a valid URL with /search-endpoint http://host:port',
|
||||
endpointMustBeHttp:
|
||||
"web_search: SearXNG endpoint must be http(s), got {protocol} \u2014 try: set a valid URL with /search-endpoint http://host:port",
|
||||
cannotReach:
|
||||
"web_search: Cannot reach SearXNG server at {endpoint} \u2014 try: install and start SearXNG (https://github.com/searxng/searxng, e.g. `docker run -d -p 8080:8080 searxng/searxng`), or switch to another engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Cannot reach SearXNG server at {endpoint} \u2014 try: install and start SearXNG (https://github.com/searxng/searxng, e.g. `docker run -d -p 8080:8080 searxng/searxng`), or switch to another engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
searxngNoResults:
|
||||
"web_search: 0 results but SearXNG response doesn't look like an empty results page ({chars} chars) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: 0 results but SearXNG response doesn't look like an empty results page ({chars} chars) \u2014 try: rephrase the query with simpler terms, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
metasoMissingKey:
|
||||
"web_search: Metaso requires an API key \u2014 set METASO_API_KEY or configure one with /search-engine metaso <key>. Get one at https://metaso.cn/search-api/playground",
|
||||
metasoDailyLimit:
|
||||
@@ -1523,7 +1550,7 @@ export const EN: TranslationSchema = {
|
||||
metasoRateLimit:
|
||||
"web_search: Metaso rate-limited \u2014 wait and retry, or get your own API key at https://metaso.cn/search-api/playground",
|
||||
metasoServerError:
|
||||
"web_search: Metaso server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Metaso server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
metasoParseError:
|
||||
"web_search: Metaso returned unparseable response (HTTP {status}) \u2014 try again later",
|
||||
metasoApiError: "web_search: Metaso API error (code {code}: {message}) \u2014 try again later",
|
||||
@@ -1532,9 +1559,9 @@ export const EN: TranslationSchema = {
|
||||
tavilyUnauthorized:
|
||||
"web_search: Tavily API key rejected \u2014 check TAVILY_API_KEY or get one at https://tavily.com",
|
||||
tavilyRateLimit:
|
||||
"web_search: Tavily rate-limited or monthly quota exceeded \u2014 wait, switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa, or upgrade your Tavily plan",
|
||||
"web_search: Tavily rate-limited or monthly quota exceeded \u2014 wait, switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa, or upgrade your Tavily plan",
|
||||
tavilyServerError:
|
||||
"web_search: Tavily server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Tavily server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
tavilyParseError:
|
||||
"web_search: Tavily returned unparseable response (HTTP {status}) \u2014 try again later",
|
||||
perplexityMissingKey:
|
||||
@@ -1542,9 +1569,9 @@ export const EN: TranslationSchema = {
|
||||
perplexityUnauthorized:
|
||||
"web_search: Perplexity API key rejected \u2014 check PERPLEXITY_API_KEY or get one at https://perplexity.ai/settings/api",
|
||||
perplexityRateLimit:
|
||||
"web_search: Perplexity rate-limited \u2014 wait and retry, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Perplexity rate-limited \u2014 wait and retry, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
perplexityServerError:
|
||||
"web_search: Perplexity server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Perplexity server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
perplexityParseError:
|
||||
"web_search: Perplexity returned unparseable response (HTTP {status}) \u2014 try again later",
|
||||
exaMissingKey:
|
||||
@@ -1554,7 +1581,7 @@ export const EN: TranslationSchema = {
|
||||
exaRateLimit:
|
||||
"web_search: Exa API rate-limited or monthly quota exceeded \u2014 wait or upgrade at https://exa.ai/pricing",
|
||||
exaServerError:
|
||||
"web_search: Exa server error ({status}) \u2014 try again later, or switch engine with /search-engine mojeek|searxng|metaso|tavily|perplexity|exa",
|
||||
"web_search: Exa server error ({status}) \u2014 try again later, or switch engine with /search-engine bing|searxng|metaso|tavily|perplexity|exa",
|
||||
exaParseError:
|
||||
"web_search: Exa returned unparseable response (HTTP {status}) \u2014 try again later",
|
||||
fetchStatus:
|
||||
@@ -1656,19 +1683,6 @@ export const EN: TranslationSchema = {
|
||||
categoryProject: "project",
|
||||
categoryReference: "reference",
|
||||
},
|
||||
copyMode: {
|
||||
title: "── COPY MODE ──",
|
||||
help: "j/k or ↑/↓ move · v select · y yank · g/G top/bottom · q quit",
|
||||
statusBar: "line {cur}/{total} · selection: {sel}",
|
||||
statusYanked: "yanked {size} chars (osc52={osc52})",
|
||||
statusEmpty: "nothing selected",
|
||||
empty: "(no chat content yet — say something to the model first)",
|
||||
labelUser: "you",
|
||||
labelAssistant: "assistant",
|
||||
labelReasoning: "reasoning",
|
||||
yankedToast: "▸ copied {size} chars to clipboard (osc52)",
|
||||
yankedToastFile: "▸ copied {size} chars · file: {path}",
|
||||
},
|
||||
mcpHealth: {
|
||||
noData: "no inspect data",
|
||||
healthy: "healthy \u00b7 {ms}ms",
|
||||
@@ -1834,9 +1848,11 @@ export const EN: TranslationSchema = {
|
||||
groupSystem: "System",
|
||||
descEnter: "Send message",
|
||||
descShiftEnter: "New line",
|
||||
descCtrlEnter: "New line",
|
||||
descCtrlJ: "New line",
|
||||
descCtrlU: "Clear input",
|
||||
descCtrlW: "Delete word",
|
||||
descCtrlP: "Toggle shortcut panel",
|
||||
descCtrlP: "Show/hide shortcuts",
|
||||
descCtrlX: "Open in editor",
|
||||
descArrows: "Input history",
|
||||
descPgUpDown: "Scroll page",
|
||||
@@ -1850,8 +1866,32 @@ export const EN: TranslationSchema = {
|
||||
descCtrlC: "Quit",
|
||||
descEsc: "Stop / Cancel",
|
||||
descCtrlR: "Toggle verbose",
|
||||
descCtrlO: "Expand stream",
|
||||
descCtrlO: "Expand reply (streaming only)",
|
||||
descHelp: "Show all commands",
|
||||
descShiftTab: "Switch edit mode",
|
||||
},
|
||||
mcpCli: {
|
||||
bundledCatalog: "Bundled MCP servers (offline catalog):",
|
||||
justFetched: "just fetched",
|
||||
cachedAge: "cached, {age}",
|
||||
moreAvailable: "more available",
|
||||
allLoaded: "all loaded",
|
||||
morePagesAvailable:
|
||||
"\u25b8 more pages available \u2014 `reasonix mcp list --pages <n>` or --all",
|
||||
installHint: "Install: reasonix mcp install <name>",
|
||||
usageSearch: "usage: reasonix mcp search <query>",
|
||||
usageInstall: "usage: reasonix mcp install <name>",
|
||||
noMatchesFor: 'No matches for "{q}" across {count} loaded entries ({source})',
|
||||
matchCount: '{count} match(es) for "{q}" in {source} registry ({loaded} entries scanned):',
|
||||
moreLoaded: "\u2026 {count} more loaded \u2014 use `reasonix mcp search <query>` to filter",
|
||||
moreMatches: "\u2026 {count} more matches",
|
||||
installed: "Installed: {spec}",
|
||||
noServerFound:
|
||||
'No MCP server named "{target}" found after walking {pages} page(s) of the {source} registry.',
|
||||
noServerTryMore: "Try: reasonix mcp install {target} --max-pages 100",
|
||||
noInstallMeta:
|
||||
'Could not derive install metadata for "{name}" \u2014 try `npx -y @smithery/cli install {name}` directly.',
|
||||
buildSpecFailed: "Cannot build install spec for {name}: {message}",
|
||||
alreadyInstalled: "Already installed: {spec}",
|
||||
},
|
||||
};
|
||||
+55
-15
@@ -43,6 +43,16 @@ export interface TranslationSchema {
|
||||
};
|
||||
sessions: {
|
||||
emptyHint: string;
|
||||
listHeader: string;
|
||||
inspectHint: string;
|
||||
resumeHint: string;
|
||||
noSession: string;
|
||||
lookedAt: string;
|
||||
noIdleSessions: string;
|
||||
wouldPrune: string;
|
||||
dryRunHint: string;
|
||||
prunedCount: string;
|
||||
daysInvalid: string;
|
||||
};
|
||||
ui: {
|
||||
welcome: string;
|
||||
@@ -187,6 +197,8 @@ export interface TranslationSchema {
|
||||
verboseOn: string;
|
||||
verboseOff: string;
|
||||
commandFailed: string;
|
||||
steerInjected: string;
|
||||
steerCommandRejected: string;
|
||||
btwUsage: string;
|
||||
btwHeader: string;
|
||||
btwFailed: string;
|
||||
@@ -233,6 +245,8 @@ export interface TranslationSchema {
|
||||
editHistoryRevertFile: string;
|
||||
mcpFailed: string;
|
||||
mcpWarn: string;
|
||||
unknownTheme: string;
|
||||
themeSaved: string;
|
||||
};
|
||||
mcpBrowse: {
|
||||
noResources: string;
|
||||
@@ -288,6 +302,7 @@ export interface TranslationSchema {
|
||||
balance402: string;
|
||||
badparam422: string;
|
||||
badrequest400: string;
|
||||
concurrency429: string;
|
||||
deepseek5xxHead: string;
|
||||
deepseek5xxReachable: string;
|
||||
deepseek5xxUnreachable: string;
|
||||
@@ -470,6 +485,10 @@ export interface TranslationSchema {
|
||||
editorExited: string;
|
||||
/** Typeahead queue indicator, e.g. "▸ 3 lines staged · esc recall" */
|
||||
typeaheadStaged: string;
|
||||
/** Placeholder shown when steerBusy is active. */
|
||||
steerPlaceholder: string;
|
||||
/** Status-line hint shown when steerBusy is active. */
|
||||
steerHint: string;
|
||||
};
|
||||
pathConfirm: {
|
||||
title: string;
|
||||
@@ -489,6 +508,12 @@ export interface TranslationSchema {
|
||||
pathLabel: string;
|
||||
sandboxLabel: string;
|
||||
allowPrefixLabel: string;
|
||||
promptTitleRead: string;
|
||||
promptTitleWrite: string;
|
||||
actionAllowRead: string;
|
||||
actionAllowWrite: string;
|
||||
actionAlwaysAllow: string;
|
||||
actionDeny: string;
|
||||
};
|
||||
shellConfirm: {
|
||||
title: string;
|
||||
@@ -511,6 +536,11 @@ export interface TranslationSchema {
|
||||
waitLabel: string;
|
||||
previewMore: string;
|
||||
previewMorePlural: string;
|
||||
promptTitleRunCommand: string;
|
||||
promptTitleRunBackground: string;
|
||||
actionRunOnce: string;
|
||||
actionAlwaysAllow: string;
|
||||
actionDeny: string;
|
||||
};
|
||||
editConfirm: {
|
||||
footer: string;
|
||||
@@ -655,8 +685,8 @@ export interface TranslationSchema {
|
||||
rateLimit429: string;
|
||||
forbidden403: string;
|
||||
serverError5xx: string;
|
||||
mojeekBlocked: string;
|
||||
mojeekNoResults: string;
|
||||
bingBlocked: string;
|
||||
bingNoResults: string;
|
||||
invalidEndpoint: string;
|
||||
endpointMustBeHttp: string;
|
||||
cannotReach: string;
|
||||
@@ -773,19 +803,6 @@ export interface TranslationSchema {
|
||||
categoryProject: string;
|
||||
categoryReference: string;
|
||||
};
|
||||
copyMode: {
|
||||
title: string;
|
||||
help: string;
|
||||
statusBar: string;
|
||||
statusYanked: string;
|
||||
statusEmpty: string;
|
||||
empty: string;
|
||||
labelUser: string;
|
||||
labelAssistant: string;
|
||||
labelReasoning: string;
|
||||
yankedToast: string;
|
||||
yankedToastFile: string;
|
||||
};
|
||||
mcpHealth: {
|
||||
noData: string;
|
||||
healthy: string;
|
||||
@@ -925,6 +942,8 @@ export interface TranslationSchema {
|
||||
groupSystem: string;
|
||||
descEnter: string;
|
||||
descShiftEnter: string;
|
||||
descCtrlEnter: string;
|
||||
descCtrlJ: string;
|
||||
descCtrlU: string;
|
||||
descCtrlW: string;
|
||||
descCtrlP: string;
|
||||
@@ -945,4 +964,25 @@ export interface TranslationSchema {
|
||||
descHelp: string;
|
||||
descShiftTab: string;
|
||||
};
|
||||
mcpCli: {
|
||||
bundledCatalog: string;
|
||||
justFetched: string;
|
||||
cachedAge: string;
|
||||
moreAvailable: string;
|
||||
allLoaded: string;
|
||||
morePagesAvailable: string;
|
||||
installHint: string;
|
||||
usageSearch: string;
|
||||
usageInstall: string;
|
||||
noMatchesFor: string;
|
||||
matchCount: string;
|
||||
moreLoaded: string;
|
||||
moreMatches: string;
|
||||
installed: string;
|
||||
noServerFound: string;
|
||||
noServerTryMore: string;
|
||||
noInstallMeta: string;
|
||||
buildSpecFailed: string;
|
||||
alreadyInstalled: string;
|
||||
};
|
||||
}
|
||||
+75
-39
@@ -46,6 +46,16 @@ export const zhCN: TranslationSchema = {
|
||||
sessions: {
|
||||
emptyHint:
|
||||
"暂无已保存的会话 — 运行 `reasonix chat`(会话会自动保存,除非使用了 --no-session)。",
|
||||
listHeader: "保存的会话 (~/.reasonix/sessions/):",
|
||||
inspectHint: "查看:reasonix sessions <name>",
|
||||
resumeHint: "恢复:reasonix chat --session <name>",
|
||||
noSession: '找不到会话 "{name}"(或为空)。',
|
||||
lookedAt: "位置:{path}",
|
||||
noIdleSessions: "没有闲置 ≥{days} 天的会话。无需清理。",
|
||||
wouldPrune: "将清理 {count} 个闲置 ≥{days} 天的会话:",
|
||||
dryRunHint: "去掉 --dry-run 可实际执行删除。",
|
||||
prunedCount: "已清理 {count} 个闲置 ≥{days} 天的会话:",
|
||||
daysInvalid: "--days 必须是正整数(传入了 {days})。",
|
||||
},
|
||||
ui: {
|
||||
welcome: "随时运行 `reasonix` 开始聊天 — 您的设置将被记住。",
|
||||
@@ -150,10 +160,6 @@ export const zhCN: TranslationSchema = {
|
||||
title: "复制 / 粘贴",
|
||||
rows: [
|
||||
{ key: "选中文字", text: "直接拖动 — 终端原生(不需要任何修饰键)" },
|
||||
{
|
||||
key: "/copy",
|
||||
text: "vim/tmux 风格复制模式 — SSH / mosh / tmux 下拖选越过可视区无效时用这个",
|
||||
},
|
||||
{
|
||||
key: "复制",
|
||||
text: "Ctrl+Shift+C(Win/Linux)· Cmd+C(macOS)— 或选中即复制(看终端设置)",
|
||||
@@ -246,9 +252,6 @@ export const zhCN: TranslationSchema = {
|
||||
},
|
||||
slash: {
|
||||
help: { description: "显示完整命令参考" },
|
||||
copy: {
|
||||
description: "进入 vim/tmux 风格复制模式 — j/k 移动、v 起选区、y 复制到剪贴板",
|
||||
},
|
||||
status: { description: "当前模型、标志、上下文、会话" },
|
||||
preset: {
|
||||
description: "模型组合 — 自动在 flash → pro 之间切换,或锁定 flash/pro",
|
||||
@@ -409,8 +412,8 @@ export const zhCN: TranslationSchema = {
|
||||
},
|
||||
"search-engine": {
|
||||
description:
|
||||
"切换网络搜索后端 — mojeek(默认,无依赖)、searxng(自托管)、metaso(每日 100 次)、tavily(每月 1000 次免费)、perplexity(AI 直接回答)或 exa(AI 直接回答)",
|
||||
argsHint: "<mojeek|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
"切换网络搜索后端 — bing(默认,国内裸 IP 直连)、searxng(自托管)、metaso(每日 100 次)、tavily(每月 1000 次免费)、perplexity(AI 直接回答)或 exa(AI 直接回答)",
|
||||
argsHint: "<bing|searxng|metaso|tavily|perplexity|exa> [<key>]",
|
||||
},
|
||||
},
|
||||
wizard: {
|
||||
@@ -579,6 +582,8 @@ export const zhCN: TranslationSchema = {
|
||||
verboseOn: "▸ 详细模式已开 — 显示完整推理 + 工具输出",
|
||||
verboseOff: "▸ 详细模式已关 — 恢复头尾省略",
|
||||
commandFailed: "! 命令失败",
|
||||
steerInjected: "▸ 已加入引导队列 — 将在当前步骤后注入",
|
||||
steerCommandRejected: "▸ 当前轮次忙碌时不能提交命令,只能输入普通引导消息",
|
||||
btwUsage: "▸ /btw <问题> — 顺便问个题外话,不会写入当前会话上下文。",
|
||||
btwHeader: "≫ btw",
|
||||
btwFailed: "/btw 调用失败",
|
||||
@@ -627,6 +632,8 @@ export const zhCN: TranslationSchema = {
|
||||
editHistoryRevertFile: "/undo {id} {path} → 仅还原此文件",
|
||||
mcpFailed: "MCP {name} 失败",
|
||||
mcpWarn: "MCP {name} 警告",
|
||||
unknownTheme: "未知主题:{name}\n可用主题:{choices}",
|
||||
themeSaved: "主题已保存:{name}\n下次启动生效:{active}",
|
||||
},
|
||||
hooks: {
|
||||
head: "钩子 {tag} `{cmd}` {decision}{truncTag}",
|
||||
@@ -683,6 +690,8 @@ export const zhCN: TranslationSchema = {
|
||||
"余额不足(DeepSeek 402):{inner}。在 https://platform.deepseek.com/top_up 充值 — 余额非零时面板顶栏会显示。",
|
||||
badparam422: "参数错误(DeepSeek 422):{inner}",
|
||||
badrequest400: "请求错误(DeepSeek 400):{inner}",
|
||||
concurrency429:
|
||||
"DeepSeek 并发超限(429):{inner}。账号在跑的请求超过上限(v4-pro 500、v4-flash 2500,账号下所有 API key 累加)。通常是同一账号开了多个 Reasonix 进程,或者并行 subagent 一次发太多。等几秒重试、减少并行,或在 https://platform.deepseek.com 申请扩容。",
|
||||
deepseek5xxHead:
|
||||
"DeepSeek 服务不可用({status}) — 这是 DeepSeek 服务端问题,不是 Reasonix 故障。已按指数退避重试 4 次。",
|
||||
deepseek5xxReachable:
|
||||
@@ -1013,6 +1022,10 @@ export const zhCN: TranslationSchema = {
|
||||
activeNone: "▸ 活跃计划:(无)",
|
||||
noArchives: "此会话尚无归档计划 — 当每个步骤完成时自动归档",
|
||||
archivedHeader: "已归档({count}):",
|
||||
evidencePending:
|
||||
" ! 等待 evidence — 当前步骤需要 verification/diff/checkpoint/manual evidence",
|
||||
evidenceLine: " evidence {stepId}: {summary}",
|
||||
archivedEvidenceLine: " evidence: {summary}",
|
||||
replayNoSession:
|
||||
"未附加会话 — `/replay` 是按会话的。在项目中运行 `reasonix code` 以获取会话。",
|
||||
replayNoArchives:
|
||||
@@ -1122,7 +1135,7 @@ export const zhCN: TranslationSchema = {
|
||||
currentEngine: "当前网页搜索引擎:{engine}",
|
||||
endpoint: "SearXNG 端点:{url}",
|
||||
usageHeader: "用法:",
|
||||
usageMojeek: " /search-engine mojeek 使用 Mojeek(默认,无外部依赖)",
|
||||
usageBing: " /search-engine bing 使用 Bing(默认,国内裸 IP 直连,无需代理)",
|
||||
usageSearxng: " /search-engine searxng 使用 SearXNG 默认端点",
|
||||
usageSearxngUrl: " /search-engine searxng <url> 使用 SearXNG 自定义端点",
|
||||
usageMetaso:
|
||||
@@ -1232,6 +1245,8 @@ export const zhCN: TranslationSchema = {
|
||||
"未设置 $EDITOR / $VISUAL / $GIT_EDITOR — 请导出环境变量(例如 `export EDITOR=nano`)后重试",
|
||||
editorExited: "编辑器异常退出,返回码 {code}",
|
||||
typeaheadStaged: "\u25b8 {count} 行已暂存 \u00b7 esc 召回",
|
||||
steerPlaceholder: "输入消息以引导当前任务 — 忙碌时不支持命令",
|
||||
steerHint: "发送 — 回合内注入",
|
||||
},
|
||||
pathConfirm: {
|
||||
title: "沙箱外路径",
|
||||
@@ -1251,6 +1266,12 @@ export const zhCN: TranslationSchema = {
|
||||
pathLabel: "路径",
|
||||
sandboxLabel: "沙箱",
|
||||
allowPrefixLabel: "前缀",
|
||||
promptTitleRead: "访问路径 — 读取",
|
||||
promptTitleWrite: "访问路径 — 写入",
|
||||
actionAllowRead: "允许读取",
|
||||
actionAllowWrite: "允许写入",
|
||||
actionAlwaysAllow: "始终允许 — {prefix}",
|
||||
actionDeny: "拒绝",
|
||||
},
|
||||
shellConfirm: {
|
||||
title: "Shell 命令",
|
||||
@@ -1273,6 +1294,11 @@ export const zhCN: TranslationSchema = {
|
||||
waitLabel: "等待",
|
||||
previewMore: "… 还有 {n} 行未显示 — 按 esc 取消,让模型拆分后再试",
|
||||
previewMorePlural: "… 还有 {n} 行未显示 — 按 esc 取消,让模型拆分后再试",
|
||||
promptTitleRunCommand: "运行命令",
|
||||
promptTitleRunBackground: "运行后台命令",
|
||||
actionRunOnce: "运行一次",
|
||||
actionAlwaysAllow: "始终允许 — {prefix}",
|
||||
actionDeny: "拒绝",
|
||||
},
|
||||
editConfirm: {
|
||||
footer:
|
||||
@@ -1416,25 +1442,25 @@ export const zhCN: TranslationSchema = {
|
||||
},
|
||||
webErrors: {
|
||||
status:
|
||||
"web_search {status} — try: 搜索后端返回错误;请改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search {status} — try: 搜索后端返回错误;请改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
rateLimit429:
|
||||
"web_search 429 — try: 等待 10 秒后重试,或改写查询;搜索后端正在对该客户端进行限流",
|
||||
forbidden403:
|
||||
"web_search 403 — try: 搜索后端拒绝该客户端访问;使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎,或稍后重试",
|
||||
"web_search 403 — try: 搜索后端拒绝该客户端访问;使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎,或稍后重试",
|
||||
serverError5xx:
|
||||
"web_search {status} — try: 在浏览器中打开搜索 URL;若能加载则属临时故障,等 30 秒重试即可",
|
||||
mojeekBlocked:
|
||||
"web_search: Mojeek 反爬页面 — 频率限制或被屏蔽 — try: 等待 30 秒后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
mojeekNoResults:
|
||||
"web_search: 返回 0 条结果但响应看起来不是正常空结果页({chars} 字符,前 120 字符:{preview})— try: 使用更简单的关键词改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
bingBlocked:
|
||||
"web_search: Bing 反爬页面 — 频率限制或被屏蔽 — try: 等待 30 秒后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
bingNoResults:
|
||||
"web_search: 返回 0 条结果但响应看起来不是正常空结果页({chars} 字符,前 120 字符:{preview})— try: 使用更简单的关键词改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
invalidEndpoint:
|
||||
'web_search: 无效的 SearXNG 端点 "{endpoint}" — try: 使用 /search-endpoint http://host:port 设置有效的 URL',
|
||||
endpointMustBeHttp:
|
||||
"web_search: SearXNG 端点必须是 http(s) 协议,当前为 {protocol} — try: 使用 /search-endpoint http://host:port 设置有效的 URL",
|
||||
cannotReach:
|
||||
"web_search: 无法访问 SearXNG 服务器 {endpoint} — try: 安装并启动 SearXNG(https://github.com/searxng/searxng,例如 `docker run -d -p 8080:8080 searxng/searxng`),或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: 无法访问 SearXNG 服务器 {endpoint} — try: 安装并启动 SearXNG(https://github.com/searxng/searxng,例如 `docker run -d -p 8080:8080 searxng/searxng`),或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
searxngNoResults:
|
||||
"web_search: 返回 0 条结果但 SearXNG 响应看起来不是正常空结果页({chars} 字符)— try: 使用更简单的关键词改写查询,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: 返回 0 条结果但 SearXNG 响应看起来不是正常空结果页({chars} 字符)— try: 使用更简单的关键词改写查询,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
metasoMissingKey:
|
||||
"web_search: Metaso 需要 API 密钥 — 设置 METASO_API_KEY,或使用 /search-engine metaso <key> 配置;可在 https://metaso.cn/search-api/playground 获取密钥",
|
||||
metasoDailyLimit:
|
||||
@@ -1444,7 +1470,7 @@ export const zhCN: TranslationSchema = {
|
||||
metasoRateLimit:
|
||||
"web_search: Metaso 请求频率限制 — 等待后重试,或在 https://metaso.cn/search-api/playground 获取自己的密钥",
|
||||
metasoServerError:
|
||||
"web_search: Metaso 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: Metaso 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
metasoParseError: "web_search: Metaso 返回无法解析的响应(HTTP {status})— 稍后重试",
|
||||
metasoApiError: "web_search: Metaso API 错误(code {code}: {message})— 稍后重试",
|
||||
tavilyMissingKey:
|
||||
@@ -1452,18 +1478,18 @@ export const zhCN: TranslationSchema = {
|
||||
tavilyUnauthorized:
|
||||
"web_search: Tavily API 密钥被拒绝 — 检查 TAVILY_API_KEY,或在 https://tavily.com 获取密钥",
|
||||
tavilyRateLimit:
|
||||
"web_search: Tavily 请求频率限制或月度配额用尽 — 等待、用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎,或升级 Tavily 计划",
|
||||
"web_search: Tavily 请求频率限制或月度配额用尽 — 等待、用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎,或升级 Tavily 计划",
|
||||
tavilyServerError:
|
||||
"web_search: Tavily 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: Tavily 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
tavilyParseError: "web_search: Tavily 返回无法解析的响应(HTTP {status})— 稍后重试",
|
||||
perplexityMissingKey:
|
||||
"web_search: Perplexity 后端需要 API 密钥 — 设置 PERPLEXITY_API_KEY 环境变量,或在 ~/.reasonix/config.json 中配置 `perplexityApiKey`;在 https://perplexity.ai/settings/api 获取密钥",
|
||||
perplexityUnauthorized:
|
||||
"web_search: Perplexity API 密钥被拒绝 — 检查 PERPLEXITY_API_KEY,或在 https://perplexity.ai/settings/api 获取密钥",
|
||||
perplexityRateLimit:
|
||||
"web_search: Perplexity 请求频率限制 — 等待后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: Perplexity 请求频率限制 — 等待后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
perplexityServerError:
|
||||
"web_search: Perplexity 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: Perplexity 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
perplexityParseError: "web_search: Perplexity 返回无法解析的响应(HTTP {status})— 稍后重试",
|
||||
exaMissingKey:
|
||||
"web_search: Exa 后端需要 API 密钥 — 设置 EXA_API_KEY 环境变量,或在 ~/.reasonix/config.json 中配置 `exaApiKey`;https://exa.ai 每月 1000 次免费",
|
||||
@@ -1472,7 +1498,7 @@ export const zhCN: TranslationSchema = {
|
||||
exaRateLimit:
|
||||
"web_search: Exa 请求频率限制或月度配额用尽 — 等待升级,或在 https://exa.ai/pricing 查看计划",
|
||||
exaServerError:
|
||||
"web_search: Exa 服务器错误({status})— 稍后重试,或使用 /search-engine mojeek|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
"web_search: Exa 服务器错误({status})— 稍后重试,或使用 /search-engine bing|searxng|metaso|tavily|perplexity|exa 切换引擎",
|
||||
exaParseError: "web_search: Exa 返回无法解析的响应(HTTP {status})— 稍后重试",
|
||||
fetchStatus:
|
||||
"web_fetch {status} for {url} — try: 在浏览器中确认该 URL 能否访问;该状态码表明目标主机返回了错误页面",
|
||||
@@ -1572,19 +1598,6 @@ export const zhCN: TranslationSchema = {
|
||||
categoryProject: "项目",
|
||||
categoryReference: "参考",
|
||||
},
|
||||
copyMode: {
|
||||
title: "── 复制模式 ──",
|
||||
help: "j/k 或 ↑/↓ 移动 · v 起选区 · y 复制 · g/G 顶/底 · q 退出",
|
||||
statusBar: "第 {cur}/{total} 行 · 选区:{sel}",
|
||||
statusYanked: "已复制 {size} 字符(osc52={osc52})",
|
||||
statusEmpty: "未选中内容",
|
||||
empty: "(还没有聊天内容 — 先和模型说点什么)",
|
||||
labelUser: "你",
|
||||
labelAssistant: "助手",
|
||||
labelReasoning: "推理",
|
||||
yankedToast: "▸ 已复制 {size} 字符到剪贴板 (osc52)",
|
||||
yankedToastFile: "▸ 已复制 {size} 字符 · 文件:{path}",
|
||||
},
|
||||
mcpHealth: {
|
||||
noData: "无检查数据",
|
||||
healthy: "正常 \u00b7 {ms}ms",
|
||||
@@ -1737,9 +1750,11 @@ export const zhCN: TranslationSchema = {
|
||||
groupSystem: "系统",
|
||||
descEnter: "发送消息",
|
||||
descShiftEnter: "换行",
|
||||
descCtrlEnter: "换行",
|
||||
descCtrlJ: "换行",
|
||||
descCtrlU: "清空输入",
|
||||
descCtrlW: "删除单词",
|
||||
descCtrlP: "打开/关闭快捷键面板",
|
||||
descCtrlP: "显示/隐藏快捷键",
|
||||
descCtrlX: "在编辑器中打开",
|
||||
descArrows: "浏览输入历史",
|
||||
descPgUpDown: "翻页",
|
||||
@@ -1753,8 +1768,29 @@ export const zhCN: TranslationSchema = {
|
||||
descCtrlC: "退出",
|
||||
descEsc: "停止/取消",
|
||||
descCtrlR: "切换详细模式",
|
||||
descCtrlO: "展开流式输出",
|
||||
descCtrlO: "展开回复(仅流式输出期间)",
|
||||
descHelp: "显示所有命令",
|
||||
descShiftTab: "切换编辑模式",
|
||||
},
|
||||
mcpCli: {
|
||||
bundledCatalog: "已打包的 MCP 服务器(离线目录):",
|
||||
justFetched: "刚刚获取",
|
||||
cachedAge: "缓存,{age}",
|
||||
moreAvailable: "还有更多",
|
||||
allLoaded: "已全部加载",
|
||||
morePagesAvailable: "▸ 还有更多页可用 — `reasonix mcp list --pages <n>` 或 --all",
|
||||
installHint: "安装:reasonix mcp install <name>",
|
||||
usageSearch: "用法:reasonix mcp search <query>",
|
||||
usageInstall: "用法:reasonix mcp install <name>",
|
||||
noMatchesFor: '未找到 "{q}" 的匹配项(已检索 {count} 条记录,来源:{source})',
|
||||
matchCount: '在 {source} 中找到 {count} 条 "{q}" 的匹配项(已扫描 {loaded} 条记录):',
|
||||
moreLoaded: "… 还有 {count} 条已加载 — 使用 `reasonix mcp search <query>` 筛选",
|
||||
moreMatches: "… 还有 {count} 条匹配项",
|
||||
installed: "已安装:{spec}",
|
||||
noServerFound: '在 {source} 中遍历了 {pages} 页后未找到名为 "{target}" 的 MCP 服务器。',
|
||||
noServerTryMore: "试试:reasonix mcp install {target} --max-pages 100",
|
||||
noInstallMeta: '无法为 "{name}" 获取安装元数据 — 试试 `npx -y @smithery/cli install {name}`。',
|
||||
buildSpecFailed: "无法为 {name} 构建安装 spec:{message}",
|
||||
alreadyInstalled: "已安装:{spec}",
|
||||
},
|
||||
};
|
||||
+1
-1
@@ -132,7 +132,7 @@ export type { RunCommandResult, ShellToolsOptions } from "./tools/shell.js";
|
||||
export {
|
||||
formatSearchResults,
|
||||
htmlToText,
|
||||
parseMojeekResults,
|
||||
parseBingResults,
|
||||
parseSearxngHtmlResults,
|
||||
registerWebTools,
|
||||
webFetch,
|
||||
|
||||
+60
-26
@@ -51,9 +51,17 @@ import {
|
||||
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
|
||||
import { SessionStats, type TurnStats } from "./telemetry/stats.js";
|
||||
import { ToolRegistry } from "./tools.js";
|
||||
import { parseRateLimitedToolResult } from "./tools/rate-limit.js";
|
||||
import { ReadTracker } from "./tools/read-tracker.js";
|
||||
import type { ChatMessage, ToolCall } from "./types.js";
|
||||
|
||||
const ESCALATION_MODEL = "deepseek-v4-pro";
|
||||
export const MID_TURN_STEER_WRAPPER =
|
||||
"[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]";
|
||||
|
||||
function formatSteerUserMessage(content: string): string {
|
||||
return [MID_TURN_STEER_WRAPPER, content].join("\n");
|
||||
}
|
||||
|
||||
export {
|
||||
fixToolCallPairing,
|
||||
@@ -109,6 +117,8 @@ export class CacheFirstLoop {
|
||||
readonly scratch = new VolatileScratch();
|
||||
readonly stats = new SessionStats();
|
||||
readonly repair: ToolCallRepair;
|
||||
/** Files the model has read this session; gates edit_file / multi_edit so SEARCH text matches on-disk bytes. Cleared on fold / mechanical truncate (the model's byte-level view of the elided history is gone). In-memory only — naturally empty on resume. */
|
||||
readonly readTracker = new ReadTracker();
|
||||
|
||||
// Mutable via configure() — slash commands in the TUI / library callers tweak
|
||||
// these mid-session so users don't have to restart.
|
||||
@@ -139,17 +149,21 @@ export class CacheFirstLoop {
|
||||
/** Authoritative running-id set — UI cards consult this instead of trusting end-event delivery. Insert at dispatch entry, delete in finally. */
|
||||
private readonly _inflight = new InflightSet();
|
||||
|
||||
/** Typeahead steer message set by the UI; step() consumes it at the next iter boundary. */
|
||||
private _steer: string | null = null;
|
||||
/** Typeahead steer messages set by the UI; step() consumes one at each iter boundary. */
|
||||
private readonly _steerQueue: string[] = [];
|
||||
|
||||
/** Set true when a steer was consumed this turn; cleared on next step() entry. */
|
||||
private _steerConsumed = false;
|
||||
|
||||
/** UI calls this to inject a mid-turn steer message without aborting the current turn.
|
||||
* New text resets steerConsumed — a fresh steer hasn't been consumed yet. */
|
||||
* New text resets steerConsumed because a fresh steer is queued. */
|
||||
steer(text: string | null): void {
|
||||
this._steer = text;
|
||||
if (text !== null) this._steerConsumed = false;
|
||||
if (text === null) {
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
this._steerQueue.push(text);
|
||||
this._steerConsumed = false;
|
||||
}
|
||||
|
||||
/** True when a steer was consumed this turn (UI gate to avoid double-submit). */
|
||||
@@ -253,6 +267,10 @@ export class CacheFirstLoop {
|
||||
sessionName: this.sessionName,
|
||||
getAbortSignal: () => this._turnAbort.signal,
|
||||
getCurrentTurn: () => this._turn,
|
||||
getSystemPrompt: () => this.prefix.system,
|
||||
getToolSpecs: () => this.prefix.toolSpecs,
|
||||
getFewShots: () => this.prefix.fewShots,
|
||||
onLogRewrite: () => this.readTracker.reset(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,6 +477,7 @@ export class CacheFirstLoop {
|
||||
signal,
|
||||
maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
|
||||
confirmationGate: this.confirmationGate,
|
||||
readTracker: this.readTracker,
|
||||
});
|
||||
|
||||
const postReport = await runHooks({
|
||||
@@ -490,11 +509,9 @@ export class CacheFirstLoop {
|
||||
}
|
||||
private _inflightCounter = 0;
|
||||
|
||||
private buildMessages(pendingUser: string | null): ChatMessage[] {
|
||||
private buildMessages(): ChatMessage[] {
|
||||
const healedMessages = this.healActiveLogBeforeSend();
|
||||
const msgs: ChatMessage[] = [...this.prefix.toMessages(), ...healedMessages];
|
||||
if (pendingUser !== null) msgs.push({ role: "user", content: pendingUser });
|
||||
return msgs;
|
||||
return [...this.prefix.toMessages(), ...healedMessages];
|
||||
}
|
||||
|
||||
private healActiveLogBeforeSend(): ChatMessage[] {
|
||||
@@ -589,6 +606,7 @@ export class CacheFirstLoop {
|
||||
cap: this.budgetUsd.toFixed(2),
|
||||
}),
|
||||
};
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
if (!this._budgetWarned && spent >= this.budgetUsd * 0.8) {
|
||||
@@ -653,8 +671,8 @@ export class CacheFirstLoop {
|
||||
// first round-trip still leaves the message in the log; the user can
|
||||
// /retry without re-typing.
|
||||
this.appendAndPersist({ role: "user", content: userInput });
|
||||
let pendingUser: string | null = null;
|
||||
const toolSpecs = this.prefix.tools();
|
||||
let rateLimitWarningShown = false;
|
||||
|
||||
for (let iter = 0; ; iter++) {
|
||||
if (signal.aborted) {
|
||||
@@ -682,6 +700,7 @@ export class CacheFirstLoop {
|
||||
} finally {
|
||||
this._turnAbort = new AbortController();
|
||||
}
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
// Bridge the silence between the PREVIOUS iter's tool result and
|
||||
@@ -703,20 +722,16 @@ export class CacheFirstLoop {
|
||||
content: t("loop.toolUploadStatus"),
|
||||
};
|
||||
}
|
||||
let messages = this.buildMessages(pendingUser);
|
||||
let messages = this.buildMessages();
|
||||
|
||||
// Consume a typeahead steer if the UI wrote one between iters.
|
||||
// Injecting as a user message via appendAndPersist means the
|
||||
// next buildMessages() (or the fold rebuild below) will include it.
|
||||
if (this._steer !== null) {
|
||||
const steer = this._steer;
|
||||
this._steer = null;
|
||||
this._steerConsumed = true;
|
||||
this.appendAndPersist({ role: "user", content: steer });
|
||||
messages = this.buildMessages(pendingUser);
|
||||
// Treat the steer as a fresh user utterance — reset pendingUser
|
||||
// since it's already in the log now.
|
||||
pendingUser = null;
|
||||
if (this._steerQueue.length > 0) {
|
||||
const steer = this._steerQueue.shift()!;
|
||||
this._steerConsumed = this._steerQueue.length === 0;
|
||||
this.appendAndPersist({
|
||||
role: "user",
|
||||
content: formatSteerUserMessage(steer),
|
||||
});
|
||||
messages = this.buildMessages();
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "steer",
|
||||
@@ -738,10 +753,10 @@ export class CacheFirstLoop {
|
||||
content: t("loop.preflightTruncateStatus"),
|
||||
};
|
||||
const result = this.context.mechanicalTruncate(this.model, {
|
||||
allowEmpty: pendingUser !== null,
|
||||
allowEmpty: false,
|
||||
});
|
||||
if (result.folded) {
|
||||
messages = this.buildMessages(pendingUser);
|
||||
messages = this.buildMessages();
|
||||
const after = this.context.decidePreflight(messages, this.prefix.toolSpecs, this.model);
|
||||
const stillFull = after.needsAction;
|
||||
yield {
|
||||
@@ -941,6 +956,7 @@ export class CacheFirstLoop {
|
||||
} finally {
|
||||
this._turnAbort = new AbortController();
|
||||
}
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
const probe = is5xxError(err) ? await probeDeepSeekReachable(this.client) : undefined;
|
||||
@@ -950,6 +966,7 @@ export class CacheFirstLoop {
|
||||
content: "",
|
||||
error: formatLoopError(err as Error, probe),
|
||||
};
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1069,11 +1086,16 @@ export class CacheFirstLoop {
|
||||
}
|
||||
|
||||
if (repairedCalls.length === 0) {
|
||||
if (this._steerQueue.length > 0) {
|
||||
continue;
|
||||
}
|
||||
if (allSuppressed) {
|
||||
yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "stuck" });
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
yield { turn: this._turn, role: "done", content: assistantContent };
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1122,6 +1144,7 @@ export class CacheFirstLoop {
|
||||
};
|
||||
this.context.trimTrailingToolCalls();
|
||||
yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
|
||||
this._steerQueue.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1196,6 +1219,17 @@ export class CacheFirstLoop {
|
||||
for (const w of preWarnings) yield w;
|
||||
for (const w of postWarnings) yield w;
|
||||
|
||||
// Keep the structured result in history; the warning is only host-side visibility.
|
||||
const rateLimited = parseRateLimitedToolResult(result);
|
||||
if (rateLimited && !rateLimitWarningShown) {
|
||||
rateLimitWarningShown = true;
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: rateLimited.message,
|
||||
};
|
||||
}
|
||||
|
||||
this.appendAndPersist({
|
||||
role: "tool",
|
||||
tool_call_id: call.id ?? "",
|
||||
@@ -1224,7 +1258,7 @@ export class CacheFirstLoop {
|
||||
return {
|
||||
client: this.client,
|
||||
signal: this._turnAbort.signal,
|
||||
buildMessages: () => this.buildMessages(null),
|
||||
buildMessages: () => this.buildMessages(),
|
||||
appendAndPersist: (m) => this.appendAndPersist(m),
|
||||
recordStats: (model, usage) => this.stats.record(this._turn, model, usage),
|
||||
turn: this._turn,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function formatLoopError(err: Error, probe?: DeepSeekProbeResult): string
|
||||
if (status === "402") return t("errors.balance402", { inner });
|
||||
if (status === "422") return t("errors.badparam422", { inner });
|
||||
if (status === "400") return t("errors.badrequest400", { inner });
|
||||
if (status === "429") return t("errors.concurrency429", { inner });
|
||||
if (is5xxStatus(status)) return formatDeepSeek5xx(status, probe);
|
||||
return msg;
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ export type EventRole =
|
||||
| "warning"
|
||||
/** Transient indicator for silent phases; UI clears on next primary event. */
|
||||
| "status"
|
||||
/** Mid-turn steer injected as a user utterance without aborting the current turn. */
|
||||
/** Mid-turn steer injected as queued user guidance without aborting the current turn. */
|
||||
| "steer";
|
||||
|
||||
export interface LoopEvent {
|
||||
|
||||
+41
-12
@@ -17,17 +17,18 @@ const PROXY_ENV_KEYS = [
|
||||
|
||||
const NO_PROXY_ENV_KEYS = ["NO_PROXY", "no_proxy"] as const;
|
||||
|
||||
// Loopback bypass protects the dashboard, MCP stdio sidecars' HTTP probes, and
|
||||
// `reasonix doctor` reachability checks; non-negotiable.
|
||||
const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1"] as const;
|
||||
|
||||
// DeepSeek's API origin is in CN; routing it through a user's clash/v2ray
|
||||
// (typically a US-exit pool) lands on shared abuse IPs that DeepSeek 403s.
|
||||
// Localhost entries protect the dashboard, MCP stdio sidecars' HTTP probes,
|
||||
// and the `reasonix doctor` reachability checks.
|
||||
export const DEFAULT_NO_PROXY = [
|
||||
"api.deepseek.com",
|
||||
"*.deepseek.com",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
] as const;
|
||||
// Opt-out via `proxy.bypassDeepSeekDirect: false` (config) or
|
||||
// `REASONIX_PROXY_DEEPSEEK_DIRECT=0` (env) for corporate firewalls that block
|
||||
// direct egress and require the proxy to reach api.deepseek.com (issue #1497).
|
||||
const DEEPSEEK_NO_PROXY = ["api.deepseek.com", "*.deepseek.com"] as const;
|
||||
|
||||
export const DEFAULT_NO_PROXY = [...DEEPSEEK_NO_PROXY, ...LOOPBACK_NO_PROXY] as const;
|
||||
|
||||
export function detectProxyUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
@@ -172,6 +173,8 @@ export interface InstallProxyOptions {
|
||||
disabled?: boolean;
|
||||
/** Additional NO_PROXY patterns layered on top of defaults + env. Sourced from `cfg.proxy.noProxy` / `REASONIX_NO_PROXY`. */
|
||||
extraNoProxy?: readonly string[];
|
||||
/** When false, route api.deepseek.com / *.deepseek.com through the proxy too (issue #1497). Default true preserves the clash/v2ray US-exit-IP 403 fix. */
|
||||
bypassDeepSeekDirect?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedNoProxy {
|
||||
@@ -183,12 +186,35 @@ export interface ResolvedNoProxy {
|
||||
all: NoProxyPattern[];
|
||||
}
|
||||
|
||||
/** Env `REASONIX_PROXY_DEEPSEEK_DIRECT` (1/0/true/false/yes/no/on/off) overrides config when set; defaults true. Per issue #1497, corporate firewalls need the env knob since they often can't edit `~/.reasonix/config.json` ergonomically. */
|
||||
export function resolveBypassDeepSeekDirect(
|
||||
env: NodeJS.ProcessEnv,
|
||||
configValue: boolean | undefined,
|
||||
): boolean {
|
||||
const raw = env.REASONIX_PROXY_DEEPSEEK_DIRECT;
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (trimmed === "0" || trimmed === "false" || trimmed === "no" || trimmed === "off") {
|
||||
return false;
|
||||
}
|
||||
if (trimmed === "1" || trimmed === "true" || trimmed === "yes" || trimmed === "on") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (configValue === false) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Merge default + env + REASONIX_NO_PROXY + opts.extraNoProxy into one resolved view. Same composition as installProxyIfConfigured so /doctor can show what's actually applied. */
|
||||
export function resolveNoProxy(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
opts: { extraNoProxy?: readonly string[] } = {},
|
||||
opts: { extraNoProxy?: readonly string[]; bypassDeepSeekDirect?: boolean } = {},
|
||||
): ResolvedNoProxy {
|
||||
const defaults = parseNoProxy(DEFAULT_NO_PROXY.join(","));
|
||||
const wantsDeepSeekDirect = resolveBypassDeepSeekDirect(env, opts.bypassDeepSeekDirect);
|
||||
const defaultHosts = wantsDeepSeekDirect
|
||||
? [...DEEPSEEK_NO_PROXY, ...LOOPBACK_NO_PROXY]
|
||||
: LOOPBACK_NO_PROXY;
|
||||
const defaults = parseNoProxy(defaultHosts.join(","));
|
||||
const envSystem = parseNoProxy(detectNoProxyRaw(env));
|
||||
const envReasonix = parseNoProxy(
|
||||
typeof env.REASONIX_NO_PROXY === "string" ? env.REASONIX_NO_PROXY : null,
|
||||
@@ -222,7 +248,10 @@ export function installProxyIfConfigured(
|
||||
// Default whitelist always applies; env NO_PROXY, REASONIX_NO_PROXY, and
|
||||
// opts.extraNoProxy (config) all layer on top additively. Composition lives
|
||||
// in resolveNoProxy() so /doctor and install can't drift.
|
||||
const { all: patterns } = resolveNoProxy(env, { extraNoProxy: opts.extraNoProxy });
|
||||
const { all: patterns } = resolveNoProxy(env, {
|
||||
extraNoProxy: opts.extraNoProxy,
|
||||
bypassDeepSeekDirect: opts.bypassDeepSeekDirect,
|
||||
});
|
||||
|
||||
try {
|
||||
const reinstalled = installed;
|
||||
|
||||
@@ -870,10 +870,6 @@ export function useQQChannel({
|
||||
beginThemePicker(buildThemeChoices());
|
||||
return true;
|
||||
}
|
||||
if (result.openCopyMode) {
|
||||
notifyTerminalOnly("`/copy` follow-up interaction is currently terminal-only.");
|
||||
return true;
|
||||
}
|
||||
if (result.openArgPickerFor) {
|
||||
notifyTerminalOnly(
|
||||
`\`/${result.openArgPickerFor}\` needs terminal-side argument completion.`,
|
||||
|
||||
@@ -79,6 +79,19 @@ export async function handleModal(
|
||||
ctx.resolveShellConfirm(choice);
|
||||
return { status: 200, body: { resolved: true } };
|
||||
}
|
||||
if (kind === "path") {
|
||||
if (!ctx.resolvePathConfirm) {
|
||||
return { status: 503, body: { error: "path modal resolution not wired" } };
|
||||
}
|
||||
if (choice !== "run_once" && choice !== "always_allow" && choice !== "deny") {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: "path choice must be run_once / always_allow / deny" },
|
||||
};
|
||||
}
|
||||
ctx.resolvePathConfirm(choice);
|
||||
return { status: 200, body: { resolved: true } };
|
||||
}
|
||||
if (kind === "choice") {
|
||||
if (!ctx.resolveChoiceConfirm) {
|
||||
return { status: 503, body: { error: "choice modal resolution not wired" } };
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** apiKey is write-only on the wire; GET always returns a redacted form so dashboard screenshots don't leak credentials. */
|
||||
|
||||
import { appendFileSync } from "node:fs";
|
||||
import {
|
||||
isPlausibleKey,
|
||||
normalizeSkillPathEntries,
|
||||
normalizeSkillPaths,
|
||||
readConfig,
|
||||
webSearchEngine as readWebSearchEngine,
|
||||
redactKey,
|
||||
saveEditMode,
|
||||
writeConfig,
|
||||
@@ -21,6 +23,7 @@ interface SettingsBody {
|
||||
preset?: unknown;
|
||||
reasoningEffort?: unknown;
|
||||
search?: unknown;
|
||||
webSearchEngine?: unknown;
|
||||
model?: unknown;
|
||||
proNext?: unknown;
|
||||
budgetUsd?: unknown;
|
||||
@@ -42,6 +45,34 @@ function parseBody(raw: string): SettingsBody {
|
||||
// read time. Web sends new names in 0.12.x onward.
|
||||
const VALID_PRESETS = new Set(["auto", "flash", "pro", "fast", "smart", "max"]);
|
||||
const VALID_EFFORTS = new Set(["high", "max"]);
|
||||
// Legacy "mojeek" is intentionally absent — the engine was retired (PR
|
||||
// adding bing default). Old config values map to bing at read; the
|
||||
// dashboard can't write the dead name back.
|
||||
const VALID_WEB_SEARCH_ENGINES = new Set([
|
||||
"bing",
|
||||
"searxng",
|
||||
"metaso",
|
||||
"tavily",
|
||||
"perplexity",
|
||||
"exa",
|
||||
]);
|
||||
|
||||
/** Twin of `config.savePreset`'s debug hook — the dashboard PATCH writes `cfg.preset` directly (no `savePreset()` round-trip), so instrument the bypass path too. Same env gate, same file. */
|
||||
function debugLogPresetWriteFromDashboard(preset: string): void {
|
||||
const debugPath = process.env.REASONIX_DEBUG_PRESET;
|
||||
if (!debugPath) return;
|
||||
try {
|
||||
const stack = new Error("trace").stack ?? "";
|
||||
const line = `${new Date().toISOString()} dashboard PATCH /api/settings { preset: ${JSON.stringify(preset)} }\n${stack
|
||||
.split("\n")
|
||||
.slice(1, 8)
|
||||
.map((l) => ` ${l.trim()}`)
|
||||
.join("\n")}\n\n`;
|
||||
appendFileSync(debugPath, line);
|
||||
} catch {
|
||||
/* diagnostic only */
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSettings(
|
||||
method: string,
|
||||
@@ -66,6 +97,7 @@ export async function handleSettings(
|
||||
preset: cfg.preset ?? "auto",
|
||||
reasoningEffort: cfg.reasoningEffort ?? "max",
|
||||
search: cfg.search !== false,
|
||||
webSearchEngine: readWebSearchEngine(ctx.configPath),
|
||||
editMode: cfg.editMode ?? "review",
|
||||
session: cfg.session ?? null,
|
||||
model: live?.model ?? null,
|
||||
@@ -87,6 +119,7 @@ export async function handleSettings(
|
||||
preset: "next-session",
|
||||
reasoningEffort: "next-turn",
|
||||
search: "next-session",
|
||||
webSearchEngine: "next-turn",
|
||||
model: "next-turn",
|
||||
proNext: "next-turn",
|
||||
budgetUsd: "live",
|
||||
@@ -139,6 +172,7 @@ export async function handleSettings(
|
||||
if (typeof fields.preset !== "string" || !VALID_PRESETS.has(fields.preset)) {
|
||||
return { status: 400, body: { error: "preset must be auto | flash | pro" } };
|
||||
}
|
||||
debugLogPresetWriteFromDashboard(fields.preset);
|
||||
cfg.preset = fields.preset as "auto" | "flash" | "pro" | "fast" | "smart" | "max";
|
||||
presetPendingLive = fields.preset;
|
||||
changed.push("preset");
|
||||
@@ -161,6 +195,27 @@ export async function handleSettings(
|
||||
cfg.search = fields.search;
|
||||
changed.push("search");
|
||||
}
|
||||
if (fields.webSearchEngine !== undefined) {
|
||||
if (
|
||||
typeof fields.webSearchEngine !== "string" ||
|
||||
!VALID_WEB_SEARCH_ENGINES.has(fields.webSearchEngine)
|
||||
) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: "webSearchEngine must be bing | searxng | metaso | tavily | perplexity | exa",
|
||||
},
|
||||
};
|
||||
}
|
||||
cfg.webSearchEngine = fields.webSearchEngine as
|
||||
| "bing"
|
||||
| "searxng"
|
||||
| "metaso"
|
||||
| "tavily"
|
||||
| "perplexity"
|
||||
| "exa";
|
||||
changed.push("webSearchEngine");
|
||||
}
|
||||
let modelPendingLive: string | null = null;
|
||||
let proNextPending: boolean | null = null;
|
||||
let budgetPending: number | null | undefined;
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface DashboardContext {
|
||||
/** Snapshot of any modal currently up (for SSE clients that connect mid-modal). */
|
||||
getActiveModal?: () => ActiveModal | null;
|
||||
resolveShellConfirm?: (choice: "run_once" | "always_allow" | "deny") => void;
|
||||
resolvePathConfirm?: (choice: "run_once" | "always_allow" | "deny") => void;
|
||||
resolveChoiceConfirm?: (choice: ChoiceResolution) => void;
|
||||
resolvePlanConfirm?: (choice: "approve" | "refine" | "cancel", text?: string) => void;
|
||||
resolveEditReview?: (choice: "apply" | "reject" | "apply-rest-of-turn" | "flip-to-auto") => void;
|
||||
@@ -159,6 +160,14 @@ export type ActiveModal =
|
||||
allowPrefix: string;
|
||||
shellKind: "run_command" | "run_background";
|
||||
}
|
||||
| {
|
||||
kind: "path";
|
||||
path: string;
|
||||
intent: "read" | "write";
|
||||
toolName: string;
|
||||
sandboxRoot: string;
|
||||
allowPrefix: string;
|
||||
}
|
||||
| {
|
||||
kind: "choice";
|
||||
question: string;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { PauseGate } from "./core/pause-gate.js";
|
||||
import { truncateForModel, truncateForModelByTokens } from "./mcp/registry.js";
|
||||
import { analyzeSchema, flattenSchema, nestArguments } from "./repair/flatten.js";
|
||||
import {
|
||||
type NormalizedToolRateLimitConfig,
|
||||
type ToolRateLimitOption,
|
||||
ToolRateLimiter,
|
||||
} from "./tools/rate-limit.js";
|
||||
import type { ReadTracker } from "./tools/read-tracker.js";
|
||||
import type { JSONSchema, ToolSpec } from "./types.js";
|
||||
|
||||
export interface ToolCallContext {
|
||||
signal?: AbortSignal;
|
||||
/** Inject a mock PauseGate for tests. When absent, tools use the singleton. */
|
||||
confirmationGate?: PauseGate;
|
||||
/** Per-session tracker of files the model has read. Filesystem tools mark on read/write, edit_file/multi_edit consult before mutating. */
|
||||
readTracker?: ReadTracker;
|
||||
}
|
||||
|
||||
export interface ToolDefinition<A = any, R = any> {
|
||||
@@ -32,6 +40,7 @@ interface InternalTool extends ToolDefinition {
|
||||
export interface ToolRegistryOptions {
|
||||
/** Auto-flatten + re-nest at dispatch; default true. */
|
||||
autoFlatten?: boolean;
|
||||
rateLimit?: ToolRateLimitOption;
|
||||
}
|
||||
|
||||
export type ToolCallAuditEvent = {
|
||||
@@ -62,6 +71,7 @@ export class ToolRegistry {
|
||||
private readonly _interceptors: Array<{ id: string; fn: ToolInterceptor }> = [];
|
||||
private _auditListener: ToolCallAuditListener | null = null;
|
||||
private _resultAugmenter: ToolResultAugmenter | null = null;
|
||||
private readonly _rateLimiter: ToolRateLimiter;
|
||||
/** Per-tool fingerprint of the last call that failed schema validation. Cleared by any successful validation for that tool. */
|
||||
private readonly _lastMalformed = new Map<string, string>();
|
||||
/** Per-tool fingerprint of the last host-side gate rejection. */
|
||||
@@ -69,6 +79,7 @@ export class ToolRegistry {
|
||||
|
||||
constructor(opts: ToolRegistryOptions = {}) {
|
||||
this._autoFlatten = opts.autoFlatten !== false;
|
||||
this._rateLimiter = new ToolRateLimiter(opts.rateLimit);
|
||||
}
|
||||
|
||||
/** Enable / disable plan-mode enforcement at dispatch. */
|
||||
@@ -113,6 +124,10 @@ export class ToolRegistry {
|
||||
return this._resultAugmenter !== null;
|
||||
}
|
||||
|
||||
get rateLimitPolicy(): false | NormalizedToolRateLimitConfig {
|
||||
return this._rateLimiter.policy;
|
||||
}
|
||||
|
||||
register<A, R>(def: ToolDefinition<A, R>): this {
|
||||
if (!def.name) throw new Error("tool requires a name");
|
||||
const internal: InternalTool = { ...(def as ToolDefinition) };
|
||||
@@ -173,6 +188,8 @@ export class ToolRegistry {
|
||||
maxResultTokens?: number;
|
||||
/** Inject a mock PauseGate for tests. */
|
||||
confirmationGate?: PauseGate;
|
||||
/** Session-scoped read tracker; filesystem tools mark on read/write, edit_file/multi_edit gate on it. */
|
||||
readTracker?: ReadTracker;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const tool = this._tools.get(name);
|
||||
@@ -260,6 +277,12 @@ export class ToolRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
// Only real dispatch attempts consume quota; earlier refusals are guidance, not work.
|
||||
const rateLimit = this._rateLimiter.consume(name);
|
||||
if (!rateLimit.allowed) {
|
||||
return JSON.stringify(rateLimit.result);
|
||||
}
|
||||
|
||||
let finalResult: string;
|
||||
try {
|
||||
try {
|
||||
@@ -270,6 +293,7 @@ export class ToolRegistry {
|
||||
const result = await tool.fn(args, {
|
||||
signal: opts.signal,
|
||||
confirmationGate: opts.confirmationGate,
|
||||
readTracker: opts.readTracker,
|
||||
});
|
||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
||||
// Pre-clip at dispatch so a single fat result can't balloon the
|
||||
@@ -375,6 +399,9 @@ function plainTextRejectedReason(name: string, result: string): string | null {
|
||||
if ((name === "edit_file" || name === "write_file") && /rejected this edit/i.test(result)) {
|
||||
return "edit-gate";
|
||||
}
|
||||
if ((name === "edit_file" || name === "multi_edit") && /read_file first/i.test(result)) {
|
||||
return "read-before-edit";
|
||||
}
|
||||
if ((name === "run_command" || name === "run_background") && /\buser denied:/i.test(result)) {
|
||||
return "shell-gate";
|
||||
}
|
||||
@@ -385,6 +412,8 @@ function rejectionRecoveryHint(reason: string): string {
|
||||
switch (reason) {
|
||||
case "edit-gate":
|
||||
return "Do not re-emit the same edit. Try a genuinely different edit or ask the user how to proceed.";
|
||||
case "read-before-edit":
|
||||
return "Call read_file on the target path first, then re-issue the edit.";
|
||||
case "shell-gate":
|
||||
return "Do not retry the same command. Use an allowlisted/read-only command, wait for approval, or ask the user how to proceed.";
|
||||
case "engineering-lifecycle":
|
||||
|
||||
+29
-6
@@ -3,6 +3,7 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import picomatch from "picomatch";
|
||||
import { decodeFileBuffer, encodeFile } from "../code/file-encoding.js";
|
||||
import { addProjectPathAllowed, loadProjectPathAllowed } from "../config.js";
|
||||
import { type ConfirmationChoice, pauseGate as defaultPauseGate } from "../core/pause-gate.js";
|
||||
import { DEFAULT_INDEX_EXCLUDES } from "../index/config.js";
|
||||
@@ -280,7 +281,11 @@ export function registerFilesystemTools(
|
||||
return `[refused: ${rel} appears to be binary (${formatBytes(sizeBytes)}) — read_file returns text only. Use get_file_info for stat.]`;
|
||||
}
|
||||
|
||||
const text = raw.toString("utf8");
|
||||
const { text } = decodeFileBuffer(raw);
|
||||
// Any successful read (full, range, head, tail, outline) marks the
|
||||
// file as seen so the edit gate accepts subsequent edits. Partial-
|
||||
// read mistakes still fail later via "search text not found".
|
||||
ctx?.readTracker?.markRead(abs);
|
||||
let lines = text.split(/\r?\n/);
|
||||
// Most files end with '\n' which splits into an empty trailing
|
||||
// entry; drop it so head/tail/range counts match the user's
|
||||
@@ -653,7 +658,16 @@ export function registerFilesystemTools(
|
||||
fn: async (args: { path: string; content: string }, ctx?: ToolCallContext) => {
|
||||
const abs = await safePath(args.path, "write_file", ctx, "write");
|
||||
await fs.mkdir(pathMod.dirname(abs), { recursive: true });
|
||||
await fs.writeFile(abs, args.content, "utf8");
|
||||
let encoding: ReturnType<typeof decodeFileBuffer>["encoding"] = "utf8";
|
||||
try {
|
||||
encoding = decodeFileBuffer(await fs.readFile(abs)).encoding;
|
||||
} catch {
|
||||
// New file or unreadable — fall back to utf8.
|
||||
}
|
||||
await fs.writeFile(abs, encodeFile(args.content, encoding));
|
||||
// Just wrote the content; the model knows what's on disk, so a
|
||||
// follow-up edit_file shouldn't be gated for re-reading.
|
||||
ctx?.readTracker?.markRead(abs);
|
||||
return `wrote ${args.content.length} chars to ${displayRel(rootDir, abs)}`;
|
||||
},
|
||||
});
|
||||
@@ -661,7 +675,7 @@ export function registerFilesystemTools(
|
||||
registry.register({
|
||||
name: "edit_file",
|
||||
description:
|
||||
"Apply a SEARCH/REPLACE edit to an existing file. `search` must match exactly (whitespace sensitive) — no regex. The match must be unique in the file; otherwise the edit is refused to avoid surprise rewrites.",
|
||||
"Apply a SEARCH/REPLACE edit to an existing file. Call `read_file` on this path first this session — the tool refuses otherwise, since SEARCH must match on-disk bytes exactly. `search` is whitespace-sensitive plain text (no regex) and must be unique in the file; otherwise the edit is refused to avoid surprise rewrites.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -672,13 +686,18 @@ export function registerFilesystemTools(
|
||||
required: ["path", "search", "replace"],
|
||||
},
|
||||
fn: async (args: { path: string; search: string; replace: string }, ctx?: ToolCallContext) =>
|
||||
applyEdit(rootDir, await safePath(args.path, "edit_file", ctx, "write"), args),
|
||||
applyEdit(
|
||||
rootDir,
|
||||
await safePath(args.path, "edit_file", ctx, "write"),
|
||||
args,
|
||||
ctx?.readTracker ? (abs) => ctx.readTracker!.hasRead(abs) : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
registry.register({
|
||||
name: "multi_edit",
|
||||
description:
|
||||
"Apply N SEARCH/REPLACE edits across ONE OR MORE files in one call. Edits validate across the full batch before writing. Validation failures leave all files untouched; disk write failures trigger best-effort rollback of files that may have been modified. Per-file edits run in array order, so a later edit can match text inserted by an earlier one. Same per-edit rules as edit_file: `search` is exact text (whitespace sensitive, no regex) and must be unique in its target file at the moment that edit applies. Use this for renames spanning multiple files, cross-file refactors, or any batch where you'd otherwise loop edit_file.",
|
||||
"Apply N SEARCH/REPLACE edits across ONE OR MORE files in one call. Every target file must have been `read_file`'d this session — the tool refuses the whole batch otherwise. Edits validate across the full batch before writing. Validation failures leave all files untouched; disk write failures trigger best-effort rollback of files that may have been modified. Per-file edits run in array order, so a later edit can match text inserted by an earlier one. Same per-edit rules as edit_file: `search` is exact text (whitespace sensitive, no regex) and must be unique in its target file at the moment that edit applies. Use this for renames spanning multiple files, cross-file refactors, or any batch where you'd otherwise loop edit_file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -715,7 +734,11 @@ export function registerFilesystemTools(
|
||||
replace: e?.replace,
|
||||
})),
|
||||
);
|
||||
return applyMultiEdit(rootDir, resolved);
|
||||
return applyMultiEdit(
|
||||
rootDir,
|
||||
resolved,
|
||||
ctx?.readTracker ? (abs) => ctx.readTracker!.hasRead(abs) : undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+28
-8
@@ -1,19 +1,30 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as pathMod from "node:path";
|
||||
import { type FileEncoding, decodeFileBuffer, encodeFile } from "../../code/file-encoding.js";
|
||||
|
||||
function displayRel(rootDir: string, full: string): string {
|
||||
return pathMod.relative(rootDir, full).replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
/** Marker substring in the gate-reject message so tools.ts's repeat-rejection tracker spots a 2nd identical unread-edit and switches to the sharper "stop retrying" hint. */
|
||||
export const READ_BEFORE_EDIT_MARKER = "read_file first";
|
||||
|
||||
export async function applyEdit(
|
||||
rootDir: string,
|
||||
abs: string,
|
||||
args: { search: string; replace: string },
|
||||
hasRead?: (abs: string) => boolean,
|
||||
): Promise<string> {
|
||||
if (args.search.length === 0) {
|
||||
throw new Error("edit_file: search cannot be empty");
|
||||
}
|
||||
const before = await fs.readFile(abs, "utf8");
|
||||
if (hasRead && !hasRead(abs)) {
|
||||
throw new Error(
|
||||
`edit_file: ${displayRel(rootDir, abs)} was not read this session — ${READ_BEFORE_EDIT_MARKER} so your SEARCH matches the bytes on disk.`,
|
||||
);
|
||||
}
|
||||
const beforeBuf = await fs.readFile(abs);
|
||||
const { text: before, encoding } = decodeFileBuffer(beforeBuf);
|
||||
const le = before.includes("\r\n") ? "\r\n" : "\n";
|
||||
const adaptedSearch = args.search.replace(/\r?\n/g, le);
|
||||
const adaptedReplace = args.replace.replace(/\r?\n/g, le);
|
||||
@@ -29,7 +40,7 @@ export async function applyEdit(
|
||||
}
|
||||
const after =
|
||||
before.slice(0, firstIdx) + adaptedReplace + before.slice(firstIdx + adaptedSearch.length);
|
||||
await fs.writeFile(abs, after, "utf8");
|
||||
await fs.writeFile(abs, encodeFile(after, encoding));
|
||||
const rel = displayRel(rootDir, abs);
|
||||
const header = `edited ${rel} (${adaptedSearch.length}→${adaptedReplace.length} chars)`;
|
||||
const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
|
||||
@@ -46,6 +57,7 @@ export interface MultiEditEntry {
|
||||
export async function applyMultiEdit(
|
||||
rootDir: string,
|
||||
edits: ReadonlyArray<MultiEditEntry>,
|
||||
hasRead?: (abs: string) => boolean,
|
||||
): Promise<string> {
|
||||
if (edits.length === 0) {
|
||||
throw new Error("multi_edit: edits must contain at least one entry");
|
||||
@@ -57,6 +69,7 @@ export async function applyMultiEdit(
|
||||
hunks: string[];
|
||||
deltaChars: number;
|
||||
touched: number;
|
||||
encoding: FileEncoding;
|
||||
};
|
||||
const filesByPath = new Map<string, FileState>();
|
||||
|
||||
@@ -81,16 +94,23 @@ export async function applyMultiEdit(
|
||||
}
|
||||
let state = filesByPath.get(e.abs);
|
||||
if (!state) {
|
||||
if (hasRead && !hasRead(e.abs)) {
|
||||
throw new Error(
|
||||
`multi_edit: edit #${i + 1} target ${rel} was not read this session — ${READ_BEFORE_EDIT_MARKER} (no edits applied)`,
|
||||
);
|
||||
}
|
||||
let before: string;
|
||||
let encoding: FileEncoding;
|
||||
try {
|
||||
before = await fs.readFile(e.abs, "utf8");
|
||||
const buf = await fs.readFile(e.abs);
|
||||
({ text: before, encoding } = decodeFileBuffer(buf));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`multi_edit: edit #${i + 1} cannot read ${rel}: ${(err as Error).message} (no edits applied)`,
|
||||
);
|
||||
}
|
||||
const le = before.includes("\r\n") ? "\r\n" : "\n";
|
||||
state = { before, buf: before, le, hunks: [], deltaChars: 0, touched: 0 };
|
||||
state = { before, buf: before, le, hunks: [], deltaChars: 0, touched: 0, encoding };
|
||||
filesByPath.set(e.abs, state);
|
||||
}
|
||||
const adaptedSearch = e.search.replace(/\r?\n/g, state.le);
|
||||
@@ -119,17 +139,17 @@ export async function applyMultiEdit(
|
||||
|
||||
// Push to `attempted` BEFORE writeFile so a write that truncates or
|
||||
// partially-writes before failing is also rolled back.
|
||||
const attempted: Array<{ abs: string; before: string }> = [];
|
||||
const attempted: Array<{ abs: string; before: string; encoding: FileEncoding }> = [];
|
||||
try {
|
||||
for (const [abs, state] of filesByPath) {
|
||||
attempted.push({ abs, before: state.before });
|
||||
await fs.writeFile(abs, state.buf, "utf8");
|
||||
attempted.push({ abs, before: state.before, encoding: state.encoding });
|
||||
await fs.writeFile(abs, encodeFile(state.buf, state.encoding));
|
||||
}
|
||||
} catch (writeErr) {
|
||||
const rollbackFailures: string[] = [];
|
||||
for (const item of [...attempted].reverse()) {
|
||||
try {
|
||||
await fs.writeFile(item.abs, item.before, "utf8");
|
||||
await fs.writeFile(item.abs, encodeFile(item.before, item.encoding));
|
||||
} catch (restoreErr) {
|
||||
rollbackFailures.push(`${displayRel(rootDir, item.abs)}: ${(restoreErr as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/** Sliding-window limits for per-session tool dispatch, with aggregate and per-tool buckets. */
|
||||
|
||||
export interface ToolRateLimitBucketConfig {
|
||||
maxCalls?: number;
|
||||
windowSeconds?: number;
|
||||
}
|
||||
|
||||
export interface ToolRateLimitConfig {
|
||||
enabled?: boolean;
|
||||
aggregate?: ToolRateLimitBucketConfig;
|
||||
tools?: Record<string, false | ToolRateLimitBucketConfig>;
|
||||
}
|
||||
|
||||
export interface NormalizedToolRateLimitBucket {
|
||||
maxCalls: number;
|
||||
windowSeconds: number;
|
||||
}
|
||||
|
||||
export interface NormalizedToolRateLimitConfig {
|
||||
aggregate: NormalizedToolRateLimitBucket;
|
||||
tools: Record<string, false | NormalizedToolRateLimitBucket>;
|
||||
}
|
||||
|
||||
export interface RateLimitedToolResult {
|
||||
error: "rate_limited";
|
||||
tool: string;
|
||||
scope: string;
|
||||
limit: number;
|
||||
windowSeconds: number;
|
||||
retryAfterMs: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ToolRateLimitDecision =
|
||||
| { allowed: true }
|
||||
| { allowed: false; result: RateLimitedToolResult };
|
||||
|
||||
// Generous defaults make this a host-pressure guard, not a normal workload budget.
|
||||
export const DEFAULT_TOOL_RATE_LIMIT: NormalizedToolRateLimitConfig = {
|
||||
aggregate: { maxCalls: 200, windowSeconds: 60 },
|
||||
tools: {
|
||||
run_command: { maxCalls: 60, windowSeconds: 60 },
|
||||
run_background: { maxCalls: 10, windowSeconds: 60 },
|
||||
},
|
||||
};
|
||||
|
||||
export type ToolRateLimitOption = false | ToolRateLimitConfig;
|
||||
|
||||
type Clock = () => number;
|
||||
|
||||
export class ToolRateLimiter {
|
||||
private readonly config: false | NormalizedToolRateLimitConfig;
|
||||
private readonly clock: Clock;
|
||||
private readonly aggregate: number[] = [];
|
||||
private readonly tools = new Map<string, number[]>();
|
||||
|
||||
constructor(config: ToolRateLimitOption | undefined = {}, clock: Clock = () => Date.now()) {
|
||||
this.config = normalizeToolRateLimitConfig(config);
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
get policy(): false | NormalizedToolRateLimitConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
consume(tool: string): ToolRateLimitDecision {
|
||||
if (this.config === false) return { allowed: true };
|
||||
|
||||
const now = this.clock();
|
||||
const toolBucket = this.config.tools[tool];
|
||||
if (toolBucket !== false && toolBucket !== undefined) {
|
||||
const timestamps = this.timestampsFor(tool);
|
||||
const blocked = inspectBucket(tool, timestamps, toolBucket, now);
|
||||
if (blocked) return { allowed: false, result: blocked };
|
||||
}
|
||||
|
||||
const aggregateBlocked = inspectBucket(
|
||||
tool,
|
||||
this.aggregate,
|
||||
this.config.aggregate,
|
||||
now,
|
||||
"all_tools",
|
||||
);
|
||||
if (aggregateBlocked) return { allowed: false, result: aggregateBlocked };
|
||||
|
||||
// Count at dispatch start so concurrent slow tools still occupy the active window.
|
||||
this.aggregate.push(now);
|
||||
if (toolBucket !== false && toolBucket !== undefined) this.timestampsFor(tool).push(now);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
private timestampsFor(tool: string): number[] {
|
||||
const existing = this.tools.get(tool);
|
||||
if (existing) return existing;
|
||||
const created: number[] = [];
|
||||
this.tools.set(tool, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeToolRateLimitConfig(
|
||||
config: ToolRateLimitOption | undefined,
|
||||
): false | NormalizedToolRateLimitConfig {
|
||||
if (config === false || config?.enabled === false) return false;
|
||||
const aggregate = normalizeBucket(config?.aggregate, DEFAULT_TOOL_RATE_LIMIT.aggregate);
|
||||
const tools: Record<string, false | NormalizedToolRateLimitBucket> = {
|
||||
...DEFAULT_TOOL_RATE_LIMIT.tools,
|
||||
};
|
||||
for (const [name, value] of Object.entries(config?.tools ?? {})) {
|
||||
if (value === false) {
|
||||
tools[name] = false;
|
||||
continue;
|
||||
}
|
||||
const fallback = DEFAULT_TOOL_RATE_LIMIT.tools[name];
|
||||
tools[name] = normalizeBucket(
|
||||
value,
|
||||
fallback === false || fallback === undefined ? DEFAULT_TOOL_RATE_LIMIT.aggregate : fallback,
|
||||
);
|
||||
}
|
||||
return { aggregate, tools };
|
||||
}
|
||||
|
||||
export function parseRateLimitedToolResult(result: string): RateLimitedToolResult | null {
|
||||
try {
|
||||
const parsed = JSON.parse(result) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const value = parsed as Partial<RateLimitedToolResult>;
|
||||
if (value.error !== "rate_limited") return null;
|
||||
if (typeof value.tool !== "string" || typeof value.scope !== "string") return null;
|
||||
if (typeof value.limit !== "number" || typeof value.windowSeconds !== "number") return null;
|
||||
if (typeof value.retryAfterMs !== "number" || typeof value.message !== "string") return null;
|
||||
return value as RateLimitedToolResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBucket(
|
||||
raw: ToolRateLimitBucketConfig | undefined,
|
||||
fallback: NormalizedToolRateLimitBucket,
|
||||
): NormalizedToolRateLimitBucket {
|
||||
return {
|
||||
maxCalls: positiveInteger(raw?.maxCalls) ?? fallback.maxCalls,
|
||||
windowSeconds: positiveInteger(raw?.windowSeconds) ?? fallback.windowSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function inspectBucket(
|
||||
tool: string,
|
||||
timestamps: number[],
|
||||
bucket: NormalizedToolRateLimitBucket,
|
||||
now: number,
|
||||
scope = tool,
|
||||
): RateLimitedToolResult | null {
|
||||
const windowMs = bucket.windowSeconds * 1_000;
|
||||
while (timestamps.length > 0 && now - timestamps[0]! >= windowMs) timestamps.shift();
|
||||
if (timestamps.length < bucket.maxCalls) return null;
|
||||
const retryAfterMs = Math.max(0, timestamps[0]! + windowMs - now);
|
||||
return {
|
||||
error: "rate_limited",
|
||||
tool,
|
||||
scope,
|
||||
limit: bucket.maxCalls,
|
||||
windowSeconds: bucket.windowSeconds,
|
||||
retryAfterMs,
|
||||
message: `${scope} rate-limited: ${bucket.maxCalls} calls / ${bucket.windowSeconds}s. Wait ${formatWait(retryAfterMs)} or summarize what you know.`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatWait(ms: number): string {
|
||||
const seconds = ms / 1_000;
|
||||
return `${Number.isInteger(seconds) ? seconds.toFixed(0) : seconds.toFixed(1)}s`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as pathMod from "node:path";
|
||||
|
||||
/** Tracks files the model has had byte-exact visibility into this session. `edit_file` and `multi_edit` consult it before mutating, so the SEARCH text matches the on-disk bytes the model actually saw — not what it guessed. */
|
||||
export class ReadTracker {
|
||||
private readonly _seen = new Set<string>();
|
||||
|
||||
private static norm(abs: string): string {
|
||||
const resolved = pathMod.resolve(abs);
|
||||
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
markRead(abs: string): void {
|
||||
this._seen.add(ReadTracker.norm(abs));
|
||||
}
|
||||
|
||||
hasRead(abs: string): boolean {
|
||||
return this._seen.has(ReadTracker.norm(abs));
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._seen.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._seen.size;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user