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:
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) // { | } ~
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user