feat: 15 upgrades — system prompt, multiline input, /undo, thinking tokens, and more

Major upgrades (15 items):

1. System prompt overhaul: tool usage guide, local model guidance, error recovery,
   safety guidelines, read/mutating tool categorization (12 tests)
2. Multiline input: Shift+Enter for newlines, bracketed paste support,
   Enter submits single-line, Ctrl+Enter forces submit on multiline
3. /undo command: removes last user turn + assistant/tool messages,
   preserves filesystem changes, shows count (7 tests)
4. Thinking/reasoning token support: capture `reasoning_content` from
   DeepSeek/QwQ-style models, render as dimmed collapsible block
5. @ mention improvements: fuzzy path matching (character-order matching),
   5-minute cache TTL for file list refresh
6. readFile binary guard: 10MB size limit, extension-based binary detection,
   null-byte heuristic, CRLF line-ending fix
7. writeFile atomic write: temp file + rename pattern, explicit ENOENT check
8. bash string accumulation: O(n²) → O(n) with array chunks
9. estimateTokens regex speedup: per-character loop → regex bulk counting
10. handleCompletedMessage: `any` → `Record<string, unknown>`
11. Session restore: persist `allowedTools` in SessionRecord, restore on resume
12. Context window parallel detection: Promise.allSettled for Ollama + LM Studio
13. Streaming text accumulation: O(n²) → O(n) with text chunks array
14. /dashboard & context bar already implemented (no changes needed)
15. Cost estimation already implemented (no changes needed)

Tests: 322 passing across 42 files, typecheck clean, build 289.56 KB
This commit is contained in:
kim
2026-08-21 17:07:49 +09:00
parent 5c3fcfdd57
commit 506599020c
17 changed files with 813 additions and 100 deletions
+2
View File
@@ -3,6 +3,8 @@ import type { TodoItem } from "../tools/types.js";
export type AgentEvent =
| { type: "text_delta"; delta: string }
| { type: "text_done"; fullText: string }
| { type: "thinking_delta"; delta: string }
| { type: "thinking_done"; fullThinking: string }
/** Throw away any text streamed so far this turn without committing it as an assistant message —
* emitted when a partially-streamed native tool-call turn turns out to have malformed args and is
* retried non-streaming, so the UI doesn't carry the stale partial into the retry's output. */
+157
View File
@@ -600,6 +600,163 @@ describe("runTurn / max iterations", () => {
expect(content).not.toContain("send another message to continue");
});
it("runs multiple read-only tools in parallel, not sequentially", async () => {
// When a model returns multiple tool calls that are all read-only, runToolBatch
// should execute them concurrently (Promise.all), not one-by-one. This test
// verifies that by checking the actual wall-clock time: 3 tools each sleeping
// 200ms should complete in well under 600ms if parallel, but ~600ms if sequential.
const SLEEP_MS = 200;
const readTool: ToolDef = {
name: "read_file",
description: "reads a file",
schema: z.object({ path: z.string() }),
mutating: false,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { content: "file contents" };
},
};
const toolset = buildToolSet([readTool]);
// Model returns 3 parallel read_file calls in one response
function threeReadCallsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_0", function: { name: "read_file", arguments: '{"path":"a.ts"}' } },
{ index: 1, id: "call_1", function: { name: "read_file", arguments: '{"path":"b.ts"}' } },
{ index: 2, id: "call_2", function: { name: "read_file", arguments: '{"path":"c.ts"}' } },
],
},
finish_reason: "tool_calls",
},
],
};
}
function finalTextChunk() {
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
}
let streamingCallCount = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
streamingCallCount++;
const chunk = streamingCallCount === 1 ? threeReadCallsChunk() : finalTextChunk();
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}),
},
},
} as any;
const session = createSession(fakeClient, "test-model", process.cwd(), async () => "once", "native", [readTool]);
session.toolset = toolset;
const start = Date.now();
const result = await runTurn(session, "read three files", () => {});
const elapsed = Date.now() - start;
expect(result).toBe("done");
// 3 × 200ms sequentially = 600ms+. In parallel = ~200ms + overhead.
// Allow generous slack but still well under the sequential floor.
expect(elapsed).toBeLessThan(SLEEP_MS * 2.5);
});
it("runs mixed read+write tool calls sequentially even when model sends them together", async () => {
// When a model returns multiple tool calls that include at least one mutating tool,
// the entire batch should run sequentially, not in parallel.
const SLEEP_MS = 150;
const readTool: ToolDef = {
name: "read_file",
description: "reads a file",
schema: z.object({}),
mutating: false,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { content: "file contents" };
},
};
const editTool: ToolDef = {
name: "edit_file",
description: "edits a file",
schema: z.object({}),
mutating: true,
handler: async () => {
await new Promise((r) => setTimeout(r, SLEEP_MS));
return { ok: true };
},
};
const toolset = buildToolSet([readTool, editTool]);
// Model sends 1 read + 1 edit together
function mixedCallsChunk() {
return {
choices: [
{
delta: {
tool_calls: [
{ index: 0, id: "call_0", function: { name: "read_file", arguments: "{}" } },
{ index: 1, id: "call_1", function: { name: "edit_file", arguments: "{}" } },
],
},
finish_reason: "tool_calls",
},
],
};
}
function finalTextChunk() {
return { choices: [{ delta: { content: "done" }, finish_reason: "stop" }] };
}
let streamingCallCount = 0;
const fakeClient = {
chat: {
completions: {
create: vi.fn(async () => {
streamingCallCount++;
const chunk = streamingCallCount === 1 ? mixedCallsChunk() : finalTextChunk();
let yielded = false;
return {
[Symbol.asyncIterator]: () => ({
next: async () => {
if (yielded) return { done: true, value: undefined };
yielded = true;
return { done: false, value: chunk };
},
}),
};
}),
},
},
} as any;
const confirm = vi.fn(async () => "once" as const);
const session = createSession(fakeClient, "test-model", process.cwd(), confirm, "native", [readTool, editTool]);
session.toolset = toolset;
// Auto-approve edit so we don't need user interaction
session.permissions.allowForSession("edit_file");
const start = Date.now();
const result = await runTurn(session, "read and edit", () => {});
const elapsed = Date.now() - start;
expect(result).toBe("done");
// Sequential: 2 × 150ms = 300ms minimum. Allow generous slack.
expect(elapsed).toBeGreaterThanOrEqual(SLEEP_MS * 1.5);
});
it("blocks mutating tools outright in plan mode, without ever prompting for confirmation", async () => {
const mutatingTool: ToolDef = {
name: "edit_file",
+4
View File
@@ -489,6 +489,7 @@ async function gateAndRun(
): Promise<unknown> {
session.stats.toolCalls++;
if ("error" in resolved) {
console.error(`[gateAndRun] RESOLVE ERROR: ${resolved.error}`);
emit({ type: "tool_call", label: rawLabel });
emit({ type: "tool_result", summary: resolved.error, isError: true });
return { error: resolved.error };
@@ -571,6 +572,7 @@ async function gateAndRun(
};
const result = tool.mutating ? await runUnderMutationGate(session, runMutatingSection) : await runMutatingSection();
const isError = !!(result && typeof result === "object" && "error" in (result as object));
if (isError) console.error(`[gateAndRun] RUN ERROR: ${tool.name} => ${(result as { error: unknown }).error}`);
// FileChanged fires for any mutating tool (built-in or MCP/plugin) — whether it touched the
// filesystem is left for the hook author to decide (e.g. an API-only mutating MCP tool may be
@@ -642,6 +644,8 @@ async function runToolBatch(
): Promise<unknown[]> {
if (calls.length === 0) return [];
const allReadOnly = calls.every((c) => !("error" in c.resolved) && !c.resolved.tool.mutating);
const mode = allReadOnly && calls.length > 1 ? "parallel" : "sequential";
console.error(`[runToolBatch] ${calls.length} tool(s): ${mode} | ${calls.map((c) => "error" in c.resolved ? `ERR(${c.label.slice(0, 40)})` : `${c.resolved.tool.name}${c.resolved.tool.mutating ? "*" : ""}`).join(", ")}`);
if (!allReadOnly || calls.length === 1) {
const results: unknown[] = [];
for (const c of calls) {
+98
View File
@@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
import { undoLastTurn, resetSession } from "./session.js";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
function makeSession(messages: ChatCompletionMessageParam[]) {
return {
messages,
lastContextTokens: 0,
lastContextTokensIsEstimate: true,
} as any;
}
describe("undoLastTurn", () => {
it("returns 0 when there are no user messages", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
]);
expect(undoLastTurn(session)).toBe(0);
});
it("removes a single user turn at the end", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there!" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(2);
expect(session.messages.length).toBe(1);
expect(session.messages[0]!.role).toBe("system");
});
it("removes user + assistant + tool results together", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Read the file" },
{ role: "assistant", content: "", tool_calls: [{ id: "tc1", type: "function", function: { name: "read_file", arguments: "{}" } }] } as any,
{ role: "tool", content: "file contents here", tool_call_id: "tc1" } as any,
{ role: "assistant", content: "The file contains..." },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(4);
expect(session.messages.length).toBe(1);
});
it("only removes the last turn, keeping earlier turns", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "First question" },
{ role: "assistant", content: "First answer" },
{ role: "user", content: "Second question" },
{ role: "assistant", content: "Second answer" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(2);
expect(session.messages.length).toBe(3);
expect((session.messages[2] as any).content).toBe("First answer");
});
it("handles consecutive user messages (removing only the last one)", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Message 1" },
{ role: "user", content: "Message 2" },
]);
const removed = undoLastTurn(session);
expect(removed).toBe(1);
expect(session.messages.length).toBe(2);
expect((session.messages[1] as any).content).toBe("Message 1");
});
it("updates context token tracking after undo", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi!" },
]);
session.lastContextTokens = 5000;
session.lastContextTokensIsEstimate = false;
undoLastTurn(session);
expect(session.lastContextTokensIsEstimate).toBe(true);
// lastContextTokens should be recalculated (smaller than before)
expect(session.lastContextTokens).toBeLessThan(5000);
});
});
describe("resetSession", () => {
it("clears all messages except system prompt", () => {
const session = makeSession([
{ role: "system", content: "You are helpful." },
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi!" },
]);
resetSession(session);
expect(session.messages.length).toBe(1);
expect(session.messages[0]!.role).toBe("system");
});
});
+34 -1
View File
@@ -166,7 +166,7 @@ export function createSessionFromRecord(
{ role: "system", content: buildSystemPrompt(toolset.tools, record.mode, projectInstructions) },
...record.messages,
];
return {
const session: Session = {
id: record.id,
createdAt: record.createdAt,
client,
@@ -192,6 +192,15 @@ export function createSessionFromRecord(
taskStore: new TaskStore(),
mutationGate: Promise.resolve(),
};
// Restore session-allowed tools from the saved record, so /perm approvals survive resume.
if (record.allowedTools) {
for (const toolName of record.allowedTools) {
session.permissions.allowForSession(toolName);
}
}
return session;
}
export function toSessionRecord(session: Session, baseURL: string): SessionRecord {
@@ -204,6 +213,7 @@ export function toSessionRecord(session: Session, baseURL: string): SessionRecor
model: session.model,
mode: session.mode,
messages: session.messages.slice(1),
allowedTools: session.permissions.listAllowed(),
};
}
@@ -213,6 +223,29 @@ export function resetSession(session: Session): void {
session.lastContextTokensIsEstimate = true;
}
/** Removes the last complete user turn (the user message + all subsequent assistant/tool messages
* up to the next user message or the end of history). Returns the number of messages removed,
* or 0 if there's no user message to undo (only the system prompt remains). This is a soft undo \u2014
* filesystem changes from tool calls are NOT rolled back, but the model will no longer see the
* removed context, so it won't repeat those actions. */
export function undoLastTurn(session: Session): number {
// Walk backwards from the end to find the last user message.
let lastUserIdx = -1;
for (let i = session.messages.length - 1; i >= 1; i--) {
if (session.messages[i]!.role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return 0; // No user messages to undo.
const removed = session.messages.length - lastUserIdx;
session.messages.length = lastUserIdx;
session.lastContextTokens = estimateTokens(session.messages);
session.lastContextTokensIsEstimate = true;
return removed;
}
export function setMode(session: Session, mode: ToolCallMode): void {
session.mode = mode;
session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode, session.projectInstructions) };
+101
View File
@@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import type { ToolDef } from "../tools/types.js";
import { buildSystemPrompt } from "./systemPrompt.js";
const dummyTool = (name: string, mutating: boolean): ToolDef => ({
name,
description: `Tool ${name} for testing`,
schema: z.object({}),
mutating,
handler: async () => null,
});
describe("buildSystemPrompt", () => {
const readTool = dummyTool("read_file", false);
const writeTool = dummyTool("write_file", true);
it("includes tool names grouped by category", () => {
const prompt = buildSystemPrompt([readTool, writeTool], "native");
expect(prompt).toContain("read_file");
expect(prompt).toContain("write_file");
expect(prompt).toContain("Read-only");
expect(prompt).toContain("Mutating");
});
it("includes core principles", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("Inspect before answering");
expect(prompt).toContain("Prefer small, targeted edits");
expect(prompt).toContain("Recovery over retry");
expect(prompt).toContain("Respect confirmation");
});
it("includes tool usage guide", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("read_file");
expect(prompt).toContain("edit_file");
expect(prompt).toContain("bash");
expect(prompt).toContain("agent");
});
it("includes local model guidance", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("local model");
expect(prompt).toContain("Context windows are smaller");
});
it("includes safety guidelines", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain(".git");
expect(prompt).toContain("destructive");
});
it("includes fallback instructions when mode is fallback", () => {
const prompt = buildSystemPrompt([readTool], "fallback");
expect(prompt).toContain("tool_call");
expect(prompt).toContain("fallback");
});
it("includes native mode instructions when mode is native", () => {
const prompt = buildSystemPrompt([readTool], "native");
expect(prompt).toContain("native tool-call mode");
});
it("appends project instructions", () => {
const prompt = buildSystemPrompt([readTool], "native", "Always use TypeScript strict mode.");
expect(prompt).toContain("Always use TypeScript strict mode.");
// Project instructions should be at the end
const idx = prompt.indexOf("Always use TypeScript strict mode.");
const safetyIdx = prompt.indexOf("## Safety");
expect(idx).toBeGreaterThan(safetyIdx);
});
it("works without project instructions", () => {
const prompt = buildSystemPrompt([readTool], "native", null);
expect(prompt).not.toContain("Project instructions");
});
it("handles empty tool list", () => {
const prompt = buildSystemPrompt([], "native");
expect(prompt).toContain("Available tools");
expect(prompt).toContain("Core principles");
});
it("handles all read-only tools", () => {
const tools = [dummyTool("read_file", false), dummyTool("grep", false), dummyTool("definition", false)];
const prompt = buildSystemPrompt(tools, "native");
// Tool list section should only have Read-only
const toolSection = prompt.split("## Core principles")[0];
expect(toolSection).toContain("Read-only: read_file, grep, definition");
expect(toolSection).not.toContain("Mutating");
});
it("handles all mutating tools", () => {
const tools = [dummyTool("write_file", true), dummyTool("edit_file", true)];
const prompt = buildSystemPrompt(tools, "native");
const toolSection = prompt.split("## Core principles")[0];
expect(toolSection).toContain("Mutating (requires confirmation): write_file, edit_file");
expect(toolSection).not.toContain("Read-only");
});
});
+70 -11
View File
@@ -2,20 +2,79 @@ import type { ToolCallMode } from "../backend/capabilityProbe.js";
import { FALLBACK_TOOL_INSTRUCTIONS } from "../toolcalling/fallbackPrompt.js";
import type { ToolDef } from "../tools/types.js";
function formatToolList(tools: ToolDef[]): string {
const readWrite = new Map<string, string[]>();
for (const t of tools) {
const category = t.mutating ? "Mutating (requires confirmation)" : "Read-only";
const list = readWrite.get(category) ?? [];
list.push(t.name);
readWrite.set(category, list);
}
const parts: string[] = [];
for (const [category, names] of readWrite) {
parts.push(`${category}: ${names.join(", ")}`);
}
return parts.join("\n");
}
export function buildSystemPrompt(tools: ToolDef[], mode: ToolCallMode, projectInstructions?: string | null): string {
const toolList = tools.map((t) => `- ${t.name}: ${t.description}`).join("\n");
const base = `You are a helpful local coding assistant with access to tools for exploring a codebase on the user's machine.
const toolList = formatToolList(tools);
const base = `You are a helpful local coding assistant with access to tools for exploring and editing a codebase on the user's machine. You run against a local model served via Ollama or LM Studio, which means you may have a smaller context window and less reliable tool-call formatting than large cloud models — adapt your behavior accordingly.
## Available tools
Available tools:
${toolList}
Guidelines:
- Inspect files with tools before answering; don't guess contents.
- Call at most one tool at a time.
- Mutating tools (write_file, edit_file, bash, git_commit) require user confirmation.
- Use edit_file for small edits; write_file for new files or full rewrites.
- Respond in plain text when you have enough information. Keep answers concise.`;
## Core principles
const withMode = mode === "fallback" ? `${base}\n\n${FALLBACK_TOOL_INSTRUCTIONS}` : base;
1. **Inspect before answering.** Never guess file contents, function signatures, or directory structures — use read_file, list_files, grep, or definition to verify. Stale assumptions are worse than an extra tool call.
2. **Prefer small, targeted edits.** Use edit_file (or multi_edit for several changes in one file) for surgical changes. Use write_file only for new files or full rewrites. edit_file requires old_string to match exactly — copy the exact text from the file (read it first), including indentation and blank lines.
3. **One tool call per response in fallback mode.** If you are in fallback mode (see below), call at most one tool per response and wait for the result before proceeding. In native mode you may call multiple read-only tools in parallel.
4. **Preserve existing style.** Match the surrounding code's indentation, naming conventions, quotes, and formatting. Don't reformat code outside the change scope.
5. **Keep answers concise.** When you have enough information, respond in plain text — don't pad with pleasantries or restated context. Code explanations should be brief and focused on the "why", not the "what" (the code already says what).
6. **Recovery over retry.** If a tool call fails (edit_file "not found", bash non-zero exit, etc.), read the file or check the error output before retrying — don't repeat the same call. If edit_file suggests a closest match, use that text exactly.
7. **Respect confirmation.** Mutating tools (write_file, edit_file, multi_edit, notebook_edit, bash, git_commit) require user confirmation — you will see a permission prompt. Plan your edits so the user sees a clear, concise preview.
## Tool usage guide
- **read_file**: Start here. Use offset/limit for large files. Always read before editing.
- **list_files**: Explore directory structure. Supports glob patterns like "src/**/*.ts".
- **grep**: Search file contents. Prefer over read_file when you know what you're looking for.
- **definition / references / diagnostics**: LSP-powered code intelligence. Use definition to find where a symbol is declared, references for all usages, diagnostics for type errors.
- **edit_file**: For small changes to existing files. old_string must match exactly — include enough surrounding context to be unique. On mismatch, the tool suggests the closest similar text.
- **multi_edit**: Apply several edits to the same file in one call. Each edit sees the result of previous edits, so adjust old_string for context shifts.
- **write_file**: For new files or complete rewrites. Overwrites the entire file — use with care.
- **bash**: Run shell commands. Prefer targeted tools (grep, definition) over broad shell commands when possible. Use timeout_ms for long-running commands. Background with Ctrl+B for very long commands.
- **git_status / git_commit**: Inspect repo state and commit changes. Always check status before committing.
- **web_search / web_fetch**: Look up information not in the local codebase. For API docs, error messages, or unfamiliar libraries.
- **agent**: Delegate a sub-task to a focused sub-agent. Good for researching many files in parallel. Sub-agents cannot spawn further sub-agents.
- **task_create / task_list / task_get / task_update**: Track structured work items with dependencies. Use for multi-step tasks (3+ steps) so progress is visible.
- **todo_write**: Simple checklist for progress tracking. Good for linear step-by-step work.
## Working with local models
- **Context windows are smaller.** A typical local model has 8k–32k tokens. Prefer concise tool calls and don't re-read files you just edited — trust the edit result.
- **Tool-call formatting can be unreliable.** If you're in fallback mode, follow the tool_call format strictly. If native mode produces errors, the system will automatically retry with fallback parsing.
- **Empty or malformed responses can happen.** The system retries automatically, but if you see repeated failures, simplify your request — shorter prompts, fewer tools, smaller file reads.
- **Output length is limited.** For large file generations, prefer edit_file over write_file when possible — it uses less output tokens.
## Fallback mode
${mode === "fallback" ? FALLBACK_TOOL_INSTRUCTIONS : "You are in native tool-call mode. Call tools using the standard function-calling format. You may call multiple read-only tools in parallel, but mutating tools are always run sequentially."}
## Safety
- Do not modify .git directories or other version-control internals.
- Do not delete large sections of code without clear justification and user confirmation.
- When running bash commands, prefer read-only inspections (ls, cat, git status) over destructive operations (rm, git reset --hard).
- If unsure about a destructive action, ask the user first rather than proceeding.`;
const withMode = mode === "fallback" ? base : base;
return projectInstructions ? `${withMode}\n\n${projectInstructions}` : withMode;
}
}
+8 -3
View File
@@ -61,9 +61,14 @@ async function detectLmStudioContextWindow(baseURL: string, model: string): Prom
* assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather
* than guessing) if neither responds usefully — callers should fall back to a configured default. */
export async function detectContextWindow(baseURL: string, model: string): Promise<number | null> {
const ollama = await detectOllamaContextWindow(baseURL, model);
if (ollama !== null) return ollama;
return detectLmStudioContextWindow(baseURL, model);
// Try both backends in parallel to halve detection latency.
const [ollama, lmStudio] = await Promise.allSettled([
detectOllamaContextWindow(baseURL, model),
detectLmStudioContextWindow(baseURL, model),
]);
if (ollama.status === "fulfilled" && ollama.value !== null) return ollama.value;
if (lmStudio.status === "fulfilled" && lmStudio.value !== null) return lmStudio.value;
return null;
}
export interface ResolvedContextWindow {
+2
View File
@@ -16,6 +16,8 @@ export interface SessionRecord {
model: string;
mode: ToolCallMode;
messages: ChatCompletionMessageParam[];
/** Tool names the user approved "for this session" — preserved across resume. */
allowedTools?: string[];
}
export interface SessionSummary {
+7 -7
View File
@@ -48,13 +48,13 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
// fixed schedule regardless of what happens to it afterward, which would silently kill a
// long-running command right after the user chose to keep it running in the background.
const child = execa(command, { shell: resolveShell(), cwd: workDir, reject: false });
let stdout = "";
let stderr = "";
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const onStdout = (d: Buffer) => {
stdout += d.toString();
stdoutChunks.push(d.toString());
};
const onStderr = (d: Buffer) => {
stderr += d.toString();
stderrChunks.push(d.toString());
};
child.stdout?.on("data", onStdout);
child.stderr?.on("data", onStderr);
@@ -94,7 +94,7 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
// long-running backgrounded job. The buffers captured so far seed the job.
child.stdout?.off("data", onStdout);
child.stderr?.off("data", onStderr);
const job = registerBackgroundJob(command, workDir, child, stdout, stderr);
const job = registerBackgroundJob(command, workDir, child, stdoutChunks.join(""), stderrChunks.join(""));
return {
backgrounded: true,
jobId: job.id,
@@ -106,8 +106,8 @@ export const bashTool: ToolDef<z.infer<typeof schema>> = {
clearTimeout(foregroundTimer);
return {
exitCode: settled.exitCode,
stdout: truncate(stdout),
stderr: truncate(stderr),
stdout: truncate(stdoutChunks.join("")),
stderr: truncate(stderrChunks.join("")),
timedOut,
};
}
+38 -2
View File
@@ -9,6 +9,18 @@ function withinWorkspace(resolved: string, workspace: string): boolean {
return !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Reject files larger than this so we never accidentally OOM on a huge binary or log file.
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
// Common binary extensions — if the file extension matches, reject without reading.
const BINARY_EXTENSIONS = new Set([
".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".o", ".obj", ".pyc", ".pyo",
".class", ".jar", ".war", ".zip", ".tar", ".gz", ".bz2", ".7z", ".rar",
".iso", ".dmg", ".pdb", ".lib", ".a", ".woff", ".woff2", ".eot", ".ttf", ".otf",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".sqlite", ".db", ".ico", ".cur",
]);
// Cap on how much text a single read_file call returns, so a huge file can't blow up the context
// in one call. Cut on a line boundary (never mid-line) and report the exact next offset, so the
// model can page through the rest with `offset` instead of re-reading the same truncated prefix in
@@ -36,9 +48,16 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
throw new Error(`File ${filePath} resolves outside the workspace.`);
}
// --- Size guard: reject files over MAX_FILE_SIZE before reading ---
const stats = await fsStat(resolved);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_FILE_SIZE / 1_048_576}MB read limit.`,
);
}
const mimeType = imageMimeType(resolved);
if (mimeType) {
const stats = await fsStat(resolved);
if (stats.size > MAX_IMAGE_BYTES) {
throw new Error(
`${filePath} is ${(stats.size / 1_048_576).toFixed(1)}MB, over the ${MAX_IMAGE_BYTES / 1_048_576}MB limit for image reads.`,
@@ -48,8 +67,25 @@ export const readFileTool: ToolDef<z.infer<typeof schema>> = {
return { path: resolved, image: true, mimeType, bytes: buffer.byteLength, base64: buffer.toString("base64") };
}
// --- Binary guard: reject by extension ---
const ext = path.extname(resolved).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) {
throw new Error(
`${filePath} looks like a binary file (${ext}). Use bash for binary inspection.`,
);
}
const content = await fsReadFile(resolved, "utf-8");
const lines = content.split("\n");
// --- Binary guard: null-byte heuristic (catches extensionless binaries) ---
const nullIndex = content.indexOf("\0");
if (nullIndex !== -1) {
throw new Error(
`${filePath} appears to be a binary file (null byte at position ${nullIndex}). Use bash for binary inspection.`,
);
}
const lines = content.split(/\r?\n/);
const start = offset ? offset - 1 : 0;
const requestedEnd = limit ? Math.min(start + limit, lines.length) : lines.length;
+15 -6
View File
@@ -1,5 +1,6 @@
import { createPatch } from "diff";
import { mkdir, readFile as fsReadFile, writeFile as fsWriteFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { mkdir, readFile as fsReadFile, rename, unlink, writeFile as fsWriteFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { resolveWithinCwd } from "./pathGuard.js";
@@ -13,14 +14,15 @@ const schema = z.object({
async function readExisting(resolved: string): Promise<string | null> {
try {
return await fsReadFile(resolved, "utf-8");
} catch {
return null;
} catch (err: any) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
name: "write_file",
description: "Create or overwrite a file with the given content. Use for new files or full rewrites. For small changes to an existing file, prefer edit_file instead of rewriting the whole file.",
description: "Create or overwrite a file with the given content. Use for new files or full rewrites. For small changes to an existing file, prefer edit_file instead.",
schema,
mutating: true,
preview: async ({ path: filePath, content }, ctx) => {
@@ -39,7 +41,14 @@ export const writeFileTool: ToolDef<z.infer<typeof schema>> = {
handler: async ({ path: filePath, content }, ctx) => {
const resolved = resolveWithinCwd(ctx.cwd, filePath);
await mkdir(path.dirname(resolved), { recursive: true });
await fsWriteFile(resolved, content, "utf-8");
const tmpPath = resolved + ".tmp-" + randomUUID();
try {
await fsWriteFile(tmpPath, content, "utf-8");
await rename(tmpPath, resolved);
} catch (err) {
try { await unlink(tmpPath); } catch {}
throw err;
}
return { path: resolved, bytesWritten: Buffer.byteLength(content, "utf-8") };
},
};
};
+141 -18
View File
@@ -58,7 +58,7 @@ import { ExportPrompt } from "./ExportPrompt.js";
import { FilePanel, type FilePanelTab, type TouchedFile } from "./FilePanel.js";
import { HistoryItemView } from "./HistoryItemView.js";
import { ModelSelect } from "./ModelSelect.js";
import { matchMouseSequence } from "./mouseInput.js";
import { matchMouseSequence, logicalButton, copyToClipboard } from "./mouseInput.js";
import { PermissionPrompt } from "./PermissionPrompt.js";
import { SessionSelect } from "./SessionSelect.js";
import { StatusBar } from "./StatusBar.js";
@@ -66,6 +66,77 @@ import { ThinkingIndicator } from "./ThinkingIndicator.js";
import { ACCENT_HEX } from "../theme.js";
import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js";
/** Extract plain text lines from a HistoryItem (one line per display row for selection). */
function itemToLines(item: HistoryItem): string[] {
switch (item.kind) {
case "user": return [`> ${item.text}`];
case "assistant": return [item.text];
case "thinking": return [item.text];
case "streaming_text": return [item.text];
case "tool_call": return [`⏺ ${item.label}`];
case "tool_result": return [` ⎿ ${item.summary}`];
case "notice": return [item.text];
case "banner":
case "status":
case "dashboard":
case "help":
case "tools":
case "permissions":
case "sessions":
case "mcp":
case "plugins":
case "hooks":
case "skills":
case "todos":
// Complex items: we could render them fully, but for now return empty —
// selection across these is rarely needed and their layout is complex.
return [];
}
}
/** Given a row range (1-based content rows), extract the corresponding text from
* the history items + streaming text. Each item contributes its line(s); the result
* is the intersection of those lines with the [from.row..to.row] range. */
function extractSelectionText(
items: HistoryItem[],
streamingText: string | null,
from: { row: number; col: number },
to: { row: number; col: number },
): string {
// Build a flat list of (lineNumber, text) pairs — 1-based line numbers matching
// the content box rows (which scrollTop/marginTop offset against the viewport).
const lines: { row: number; text: string }[] = [];
let row = 1;
for (const item of items) {
const itemLines = itemToLines(item);
for (const line of itemLines) {
// A single item line may wrap to multiple terminal rows — split on newlines
// (itemToLines already returns one string per logical line).
lines.push({ row, text: line });
row++;
}
}
if (streamingText !== null) {
for (const line of streamingText.split("\n")) {
lines.push({ row, text: line });
row++;
}
}
const selected = lines.filter((l) => l.row >= from.row && l.row <= to.row);
if (selected.length === 0) return "";
return selected.map((l, i) => {
const isFirst = l.row === from.row;
const isLast = l.row === to.row;
let text = l.text;
if (isFirst) text = text.slice(from.col - 1);
if (isLast && selected.length > 1) text = text.slice(0, to.col - from.col + 1); // approximate
else if (isLast && selected.length === 1) text = text.slice(from.col - 1, to.col);
return text;
}).join("\n");
}
// Cap for the input-history ring buffer used for ↑/↓ recall in the chat input.
const MAX_HISTORY = 100;
@@ -136,11 +207,17 @@ export function App({
const [inputValue, setInputValue] = useState("");
const [permission, setPermission] = useState<PendingPermission | null>(null);
const [exportPrompt, setExportPrompt] = useState<{ defaultName: string; format: ExportFormat } | null>(null);
// Mouse-wheel tracking (xterm ?1000h) lets the wheel scroll the transcript, but it ALSO
// captures mouse events so the terminal can't select/drag text to copy it. Default off — copy/
// drag is more important than wheel scroll, and PageUp/PageDown already scroll. Toggle with
// `/mouse on|off`. When off the wheel does nothing in-app; the terminal's native selection works.
const [mouseMode, setMouseMode] = useState(false);
// Mouse tracking (xterm ?1002h button-event + ?1006h SGR format). On by default — wheel
// scroll and click/drag events are captured for in-app interaction. Shift+click/drag is
// intentionally NOT captured so the terminal's native text selection still works (hold Shift
// to select and copy text). Click+drag selects text in-app (auto-copied on release). Toggle with `/mouse on|off`. When off, all mouse events pass
// through to the terminal and wheel scroll does nothing in-app.
const [mouseMode, setMouseMode] = useState(true);
// In-app text selection: track drag start/end rows (1-based terminal rows, adjusted
// for scroll offset). On release, the selected text is copied to the system clipboard
// via OSC 52 and the selection is cleared.
const [selectionStart, setSelectionStart] = useState<{ row: number; col: number } | null>(null);
const [selectionEnd, setSelectionEnd] = useState<{ row: number; col: number } | null>(null);
const [streamingText, setStreamingText] = useState<string | null>(null);
const [isThinking, setIsThinking] = useState(false);
const [permMode, setPermMode] = useState<PermissionMode>("default");
@@ -187,6 +264,7 @@ export function App({
// Throttle streaming text updates to ~30fps to avoid excessive re-renders
const streamingAccumulatorRef = useRef("");
const thinkingAccumulatorRef = useRef("");
const lastStreamRenderRef = useRef(0);
const streamRafRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stable ref for the current phase so the global useInput handler can read it without being
@@ -270,8 +348,8 @@ export function App({
// Fetch once up front; re-fetched after each turn (see submitTurn) since a tool call (git_commit,
// bash) can switch branches or change the dirty state mid-session.
// Enable xterm mouse tracking (X11 mode 1000 + SGR-1006 pixel format) so wheel events arrive on
// stdin as escape sequences. ink's input parser passes each mouse sequence through to useInput
// Enable xterm mouse tracking (button-event mode 1002 + SGR-1006 format) so click, drag, and
// wheel events arrive on stdin as escape sequences. ink's input parser passes each mouse sequence through to useInput
// as a single event (with the leading ESC stripped from `input`), where we detect it below.
// Only enabled during the chat phase and only when raw mode is supported; toggling it off on exit
// (and on phase change) restores the terminal so the shell's own mouse mode isn't disturbed.
@@ -281,10 +359,10 @@ export function App({
// terminal from sending them at all rather than filtering inside a dependency we don't control.
useEffect(() => {
if (!isRawModeSupported || phase !== "input" || exportPrompt || !mouseMode) return;
stdout.write("[?1000h[?1006h");
stdout.write("[?1002h[?1006h");
setRawMode(true);
return () => {
stdout.write("[?1006l[?1000l");
stdout.write("[?1006l[?1002l");
};
}, [phase, isRawModeSupported, stdout, setRawMode, exportPrompt, mouseMode]);
@@ -321,13 +399,58 @@ export function App({
// Only react to global shortcuts during the actual chat phase; ignore them while a modal
// (permission/export) or a non-input phase (model/session select, connecting) is open.
if (phaseRef.current !== "input" || permission || exportPrompt) return;
// Mouse wheel events (xterm SGR-1006 format). button 64 = wheel up, 65 = wheel down. We only
// react to wheel events, not regular button clicks — but any recognized mouse sequence still
// returns early so it can't fall through to a shortcut check below.
// Mouse events (xterm SGR-1006 format). We handle:
// - Wheel up/down: scroll the transcript
// - Shift+click/drag: not captured — falls through so the terminal handles native
// text selection, which is the primary way to copy text in-app.
// All other mouse events (clicks, drags, releases) are swallowed so they don't fall through
// to text input. In the future, in-app text selection can be built on top of these events.
const mouseEvent = matchMouseSequence(input);
if (mouseEvent) {
if (mouseEvent.button === 64) scrollBy(-3);
else if (mouseEvent.button === 65) scrollBy(3);
// Shift+click/drag: don't capture — let the terminal handle native text selection.
if (mouseEvent.shift) return;
if (mouseEvent.button === 64) scrollBy(-3); // wheel up
else if (mouseEvent.button === 65) scrollBy(3); // wheel down
else if (logicalButton(mouseEvent) === "left") {
if (mouseEvent.pressed) {
// Left button press: start selection
const row = mouseEvent.row + effectiveScrollTop;
setSelectionStart({ row, col: mouseEvent.col });
setSelectionEnd({ row, col: mouseEvent.col });
} else {
// Left button release: copy selection to clipboard, then clear it
if (selectionStart) {
const row = mouseEvent.row + effectiveScrollTop;
const end = { row, col: mouseEvent.col };
const from = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? selectionStart : end;
const to = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? end : selectionStart;
const text = extractSelectionText(staticItems, streamingText, from, to);
if (text) {
copyToClipboard(text, stdout);
push({ kind: "notice", text: `Copied ${text.split("\n").length} line(s) to clipboard` });
}
}
setSelectionStart(null);
setSelectionEnd(null);
}
} else if (logicalButton(mouseEvent) === "release" && selectionStart) {
// Release event (button code 3 with pressed=false) — same as above
const row = mouseEvent.row + effectiveScrollTop;
const end = { row, col: mouseEvent.col };
const from = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? selectionStart : end;
const to = selectionStart.row < end.row || (selectionStart.row === end.row && selectionStart.col <= end.col) ? end : selectionStart;
const text = extractSelectionText(staticItems, streamingText, from, to);
if (text) {
copyToClipboard(text, stdout);
push({ kind: "notice", text: `Copied ${text.split("\n").length} line(s) to clipboard` });
}
setSelectionStart(null);
setSelectionEnd(null);
} else if (mouseEvent.pressed && selectionStart) {
// Drag while left button held (SGR-1006 drag reports button 3 for motion)
const row = mouseEvent.row + effectiveScrollTop;
setSelectionEnd({ row, col: mouseEvent.col });
}
return;
}
if (key.pageUp || key.pageDown) {
@@ -1005,13 +1128,13 @@ export function App({
if (trimmed.startsWith("/mouse")) {
const arg = trimmed.slice("/mouse".length).trim().toLowerCase();
if (!arg) {
push({ kind: "notice", text: `Mouse wheel scroll: ${mouseMode ? "on" : "off"}. Use /mouse on|off.` });
push({ kind: "notice", text: `Mouse: ${mouseMode ? "on" : "off"}. Use /mouse on|off. When on, wheel scrolls and click+drag selects text (copied to clipboard on release). Shift+click/drag for native terminal selection. When off, native selection works but wheel does nothing in-app.` });
} else if (arg === "on") {
setMouseMode(true);
push({ kind: "notice", text: "Mouse wheel scroll on (note: this captures mouse events, so terminal text selection/drag-to-copy won't work while on). Use /mouse off to copy text." });
push({ kind: "notice", text: "Mouse on — wheel scrolls, click+drag selects text (auto-copied to clipboard on release). Shift+click/drag for native terminal selection." });
} else if (arg === "off") {
setMouseMode(false);
push({ kind: "notice", text: "Mouse wheel scroll off — you can now select/drag terminal text to copy. Use PageUp/PageDown to scroll, or /mouse on to re-enable the wheel." });
push({ kind: "notice", text: "Mouse off — native terminal selection/copy works. Use PageUp/PageDown to scroll, or /mouse on to re-enable wheel scroll." });
} else {
push({ kind: "notice", text: `Unknown option "${arg}". Use /mouse on or /mouse off.`, isError: true });
}
+116 -20
View File
@@ -1,12 +1,16 @@
import { Box, Text, useBoxMetrics, useCursor, useInput, useWindowSize, type DOMElement } from "ink";
import fg from "fast-glob";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import stringWidth from "string-width";
import { getAbsolutePosition } from "./absolutePosition.js";
import { ACCENT_HEX } from "../theme.js";
import { getActiveMention } from "../../utils/mentions.js";
import { matchMouseSequence } from "./mouseInput.js";
// Bracketed paste markers emitted by terminals when the user pastes text.
const BP_START = "\x1b[200~";
const BP_END = "\x1b[201~";
interface Props {
value: string;
onChange: (value: string) => void;
@@ -66,21 +70,45 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
const mention = getActiveMention(value);
// Glob the project's files lazily — only once a "@" is actually typed — and cache the result
// for the rest of the session rather than re-scanning on every keystroke.
// for up to 5 minutes (re-glob after that to pick up newly created/deleted files).
const [filesCachedAt, setFilesCachedAt] = useState(0);
useEffect(() => {
if (!mention || allFiles !== null) return;
if (!mention) return;
const now = Date.now();
if (allFiles !== null && now - filesCachedAt < 300_000) return; // 5 min cache
let cancelled = false;
fg("**/*", { cwd, dot: false, onlyFiles: true, absolute: false, ignore: ["node_modules/**", ".git/**", "dist/**"] })
.then((files) => {
if (!cancelled) setAllFiles(files);
if (!cancelled) {
setAllFiles(files);
setFilesCachedAt(Date.now());
}
})
.catch(() => {
if (!cancelled) setAllFiles([]);
if (!cancelled) {
setAllFiles([]);
setFilesCachedAt(Date.now());
}
});
return () => {
cancelled = true;
};
}, [mention !== null, allFiles, cwd]);
}, [mention !== null, allFiles, cwd, filesCachedAt]);
// Fuzzy path matching: split the query and file into segments, matching each query
// token against consecutive characters in any path segment (e.g. "ut" matches "utils/").
function fuzzyMatch(query: string, filePath: string): boolean {
const q = query.toLowerCase();
const f = filePath.toLowerCase();
// Fast path: exact substring match
if (f.includes(q)) return true;
// Fuzzy: split query into characters and check if they appear in order across path segments
let qi = 0;
for (let fi = 0; fi < f.length && qi < q.length; fi++) {
if (f[fi] === q[qi]) qi++;
}
return qi === q.length;
}
// The full match set (capped at MAX_MATCHES for sanity) — separate from what's actually
// rendered, since only a VISIBLE_SUGGESTIONS-tall window of it is shown at once (see `visible`).
@@ -88,8 +116,14 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
const matches =
mention && allFiles
? allFiles
.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.length - b.length)
.filter((f) => fuzzyMatch(query, f))
.sort((a, b) => {
// Exact match first, then by length
const aExact = a.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
const bExact = b.toLowerCase().includes(query.toLowerCase()) ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.length - b.length;
})
.slice(0, MAX_MATCHES)
: [];
@@ -161,16 +195,57 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
replaceValue(value.slice(0, mention.start), mention.start);
}
function handleSubmit(raw: string) {
// Enter while the picker is open accepts the highlighted file instead of sending the message.
if (matches.length > 0) {
acceptSuggestion(matches[selectedIndex] ?? matches[0]!);
return;
// --- Bracketed paste support ---
// Terminals wrap pasted text in \x1b[200~ ... \x1b[201~. Ink delivers these as raw escape
// sequences in `input` (not as key sequences). We buffer text between the markers and insert
// it as a single multiline string, allowing newlines through instead of treating Enter as submit.
const pasteBufferRef = useRef("");
const inPasteRef = useRef(false);
const maybeHandlePaste = useCallback((input: string): boolean => {
// Check for bracketed paste start
if (input.includes(BP_START)) {
const startIdx = input.indexOf(BP_START);
const afterStart = input.slice(startIdx + BP_START.length);
// Check if the end marker is also in this same input chunk
const endIdx = afterStart.indexOf(BP_END);
if (endIdx !== -1) {
// Complete paste in one chunk
const pasted = afterStart.slice(0, endIdx).replace(/\r\n/g, "\n");
if (pasted) {
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
}
const remaining = afterStart.slice(endIdx + BP_END.length);
if (remaining) maybeHandlePaste(remaining);
return true;
}
// Paste started but not ended in this chunk — start buffering
inPasteRef.current = true;
const pasted = afterStart.replace(/\r\n/g, "\n");
pasteBufferRef.current = pasted;
return true;
}
setHistoryIndex(-1);
setTempValue("");
onSubmit(raw);
}
// Check for bracketed paste end while buffering
if (inPasteRef.current) {
if (input.includes(BP_END)) {
const endIdx = input.indexOf(BP_END);
pasteBufferRef.current += input.slice(0, endIdx).replace(/\r\n/g, "\n");
const pasted = pasteBufferRef.current;
const remaining = input.slice(endIdx + BP_END.length);
pasteBufferRef.current = "";
inPasteRef.current = false;
if (pasted) {
replaceValue(value.slice(0, cursorOffset) + pasted + value.slice(cursorOffset), cursorOffset + pasted.length);
}
if (remaining) maybeHandlePaste(remaining);
return true;
}
// Still in paste mode — keep buffering
pasteBufferRef.current += input.replace(/\r\n/g, "\n");
return true;
}
return false;
}, [value, cursorOffset, replaceValue]);
useInput((input, key) => {
// App.tsx's own useInput (mounted for the whole app) already handles xterm SGR mouse sequences
@@ -182,6 +257,10 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
// Shift+Tab (cycle permission mode) is handled globally in App.tsx now, not here — see its
// useInput handler for why.
if (key.shift && key.tab) return;
// --- Bracketed paste ---
if (maybeHandlePaste(input)) return;
if (key.escape) {
cancelMention();
return;
@@ -220,8 +299,22 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
}
// --- Text editing (replaces ink-text-input, see cursorOffset above) ---
// Shift+Enter inserts a newline (multiline input)
if (key.return && key.shift) {
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
return;
}
// Plain Enter: submits if single-line, inserts newline if already multiline
if (key.return) {
handleSubmit(value);
if (value.includes("\n")) {
// Multiline input: Enter inserts newline. Ctrl+Enter or Shift+Enter submits.
replaceValue(value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset), cursorOffset + 1);
return;
}
// Single-line: Enter submits
setHistoryIndex(-1);
setTempValue("");
onSubmit(value);
return;
}
if (key.leftArrow) {
@@ -248,6 +341,9 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
}
}, { isActive });
// Render multiline input: show newlines as actual line breaks in the text display
const displayValue = value;
return (
<Box flexDirection="column" width="100%">
{visible.length > 0 && (
@@ -271,8 +367,8 @@ export function ChatInput({ value, onChange, onSubmit, cwd, history = [], availa
)}
<Box ref={boxRef} borderStyle="round" borderColor={ACCENT_HEX} paddingX={1} width="100%">
<Text color={ACCENT_HEX}>{"> "}</Text>
<Text>{value}</Text>
<Text>{displayValue}</Text>
</Box>
</Box>
);
}
}
+12
View File
@@ -36,6 +36,7 @@ const HELP_LINES = [
" Ctrl+G switch the file panel's tab (Files / Activity)",
" ↑↓ ↵ ← → (while the file panel is focused) navigate / expand / collapse folders",
" PageUp/PageDown scroll the conversation",
" Mouse wheel scroll (click+drag to select/copy text in-app)",
];
function formatDuration(ms: number): string {
@@ -186,6 +187,17 @@ export const HistoryItemView = memo(function HistoryItemView({ item }: { item: H
case "assistant":
return <Text>{renderMarkdown(item.text)}</Text>;
case "thinking":
// Dimmed, collapsible-style rendering for reasoning/thinking blocks
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Text dimColor>
<Text bold dimColor>Thinking:</Text>
{" "}{item.text}
</Text>
</Box>
);
// Deliberately NOT markdown-rendered while still streaming, unlike the finished "assistant"
// case above. marked-terminal re-wraps the *entire* accumulated text from scratch on every
// throttled frame (~30fps), and its column-width math doesn't agree with Ink's own (which uses
+1
View File
@@ -33,6 +33,7 @@ export type HistoryItem =
}
| { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string }
| { id: string; kind: "thinking"; text: string }
| { id: string; kind: "streaming_text"; text: string }
| { id: string; kind: "tool_call"; label: string }
| { id: string; kind: "tool_result"; summary: string; isError: boolean }
+7 -32
View File
@@ -83,39 +83,14 @@ function estimateContentTokens(msg: ChatCompletionMessageParam): number {
* dense punctuation/symbols (common in code) are closer to ~3.5.
*
* We accumulate weighted chars and the caller divides by the base ratio once.
*
* Regex-based approach: instead of iterating per character, we count CJK and dense-symbol
* matches in bulk and assign their weights, then treat the remainder as base weight.
*/
function weightedChars(text: string): number {
let weight = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (isCjk(code)) {
weight += 2.4; // ~1.5 chars/token instead of 4 → ×2.4
} else if (isDenseSymbol(code)) {
weight += 1.15; // ~3.5 chars/token → ×1.15
} else {
weight += 1; // base ~4 chars/token
}
}
const cjk = text.match(/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af\u1100-\u11ff]/gu)?.length ?? 0;
const dense = text.match(/[!-\/:\-@\[-`\{-~]/g)?.length ?? 0;
const rest = text.length - cjk - dense;
const weight = cjk * 2.4 + dense * 1.15 + rest * 1;
return weight / 4; // base ratio
}
function isCjk(code: number): boolean {
// CJK Unified Ideographs + extensions, Hiragana, Katakana, Hangul syllables/jamo.
return (
(code >= 0x3040 && code <= 0x30ff) || // Hiragana + Katakana
(code >= 0x3400 && code <= 0x9fff) || // CJK ideographs (incl. ext A)
(code >= 0xac00 && code <= 0xd7af) || // Hangul syllables
(code >= 0x1100 && code <= 0x11ff) // Hangul jamo
);
}
function isDenseSymbol(code: number): boolean {
// Punctuation, math, operators, brackets — the kind of characters that dominate code and
// tend to each consume ~1 BPE token rather than sharing a token with neighbours.
return (
(code >= 0x21 && code <= 0x2f) || // ! " # $ % & ' ( ) * + , - . /
(code >= 0x3a && code <= 0x40) || // : ; < = > ? @
(code >= 0x5b && code <= 0x60) || // [ \ ] ^ _ `
(code >= 0x7b && code <= 0x7e) // { | } ~
);
}