@polycode-projects/the-mechanical-code-talker 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ROADMAP.md +113 -11
- package/bin/tmct.mjs +45 -0
- package/package.json +3 -1
- package/src/ask-vocab.mjs +1 -1
- package/src/ask.mjs +94 -2
- package/src/chat.mjs +86 -19
- package/src/concept.mjs +5 -0
- package/src/conformance.mjs +1 -1
- package/src/corpus/templates.mjs +1 -1
- package/src/finish.mjs +1 -1
- package/src/hash.mjs +1 -1
- package/src/interpret/normalize.mjs +28 -1
- package/src/interpret/strategies/keywords.mjs +2 -2
- package/src/providers/bootstrap.mjs +1 -1
- package/src/providers/fixture.mjs +1 -1
- package/src/providers/graph-service.mjs +1 -1
- package/src/repository-interface.mjs +1 -1
- package/src/router/guardrail.mjs +120 -0
- package/src/router/planner.mjs +168 -0
- package/src/router/registry.mjs +271 -0
- package/src/router/resolver.mjs +293 -0
- package/src/server-http.mjs +296 -0
- package/src/syllogise.mjs +0 -0
- package/src/tui/app.mjs +63 -14
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// server-http.mjs — `tmct serve`: an Anthropic Messages API-compatible HTTP
|
|
2
|
+
// endpoint (POST /v1/messages) over tmct's existing zero-model engine.
|
|
3
|
+
//
|
|
4
|
+
// This is Phase A of the capability-router plan (PLAN_CAPABILITY_ROUTER.md): the
|
|
5
|
+
// COMMON INTERFACE a tool-loop client (Claude Code) already speaks. It is a
|
|
6
|
+
// deterministic serialization/HTTP shim — NO model, ever. A request carries
|
|
7
|
+
// { model, messages[], tools[], max_tokens, system? }; a response is a message
|
|
8
|
+
// with `content` blocks (text and/or tool_use) and a `stop_reason`:
|
|
9
|
+
//
|
|
10
|
+
// - TEXT ANSWER (stop_reason "end_turn"): the latest user text is run through
|
|
11
|
+
// runTurn (src/chat.mjs) over the configured graph — the same cited,
|
|
12
|
+
// read-only answer the chat surface gives. Emitted when no tools are
|
|
13
|
+
// declared, or when nothing maps to a declared graph-query tool.
|
|
14
|
+
// - TOOL_USE (stop_reason "tool_use"): when tools[] are declared and the
|
|
15
|
+
// request maps to a declared graph-query tool, a { type:"tool_use", id,
|
|
16
|
+
// name, input } block is emitted — `name`+`input` are backed by dispatchTool
|
|
17
|
+
// (src/server.mjs). The caller executes it and returns a tool_result block;
|
|
18
|
+
// the next request closes the loop with an end_turn text answer.
|
|
19
|
+
//
|
|
20
|
+
// bedrock-meter-pluggable: every response's `usage` is { input_tokens: 0,
|
|
21
|
+
// output_tokens: 0 } — tmct is the $0 floor, priced as free by the meter.
|
|
22
|
+
//
|
|
23
|
+
// NOTE: src/server.mjs is the TOOL-DISPATCH layer (dispatchTool), NOT an HTTP
|
|
24
|
+
// server; this module is the HTTP surface and imports that layer's exports.
|
|
25
|
+
|
|
26
|
+
import { createServer } from "node:http";
|
|
27
|
+
import { runTurn, COMMANDS, asBareCommand, isConversational } from "./chat.mjs";
|
|
28
|
+
import { TOOLS } from "./server.mjs";
|
|
29
|
+
import { parseEntities } from "./codegraph.mjs";
|
|
30
|
+
import { uuidv7 } from "./uuid.mjs";
|
|
31
|
+
import * as defaultSource from "./source.mjs";
|
|
32
|
+
|
|
33
|
+
/** The zero usage every response carries — the meter prices tmct as the $0 floor. */
|
|
34
|
+
const ZERO_USAGE = { input_tokens: 0, output_tokens: 0 };
|
|
35
|
+
|
|
36
|
+
/** The tmct tools dispatchTool can back (the set the shim will emit a tool_use for).
|
|
37
|
+
* A declared tool outside this set is ignored for emission (the request falls
|
|
38
|
+
* through to a text answer) — the shim never emits a call it cannot ground. The
|
|
39
|
+
* COMMANDS map (chat.mjs) names the richer graph tools; TOOLS names the hot
|
|
40
|
+
* catalog. Their union is what dispatchTool serves. */
|
|
41
|
+
const BACKED_TOOLS = new Set([
|
|
42
|
+
...TOOLS.map((t) => t.name),
|
|
43
|
+
...Object.values(COMMANDS).map((s) => s.tool),
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Flatten a message's `content` (a string OR a content-block array) into plain
|
|
47
|
+
* text — concatenating the `text` blocks. Non-text blocks are ignored here. */
|
|
48
|
+
function textOfContent(content) {
|
|
49
|
+
if (typeof content === "string") return content;
|
|
50
|
+
if (!Array.isArray(content)) return "";
|
|
51
|
+
return content
|
|
52
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
53
|
+
.map((b) => b.text)
|
|
54
|
+
.join("\n")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The last message with the given role, or null. */
|
|
59
|
+
function lastMessageOfRole(messages, role) {
|
|
60
|
+
if (!Array.isArray(messages)) return null;
|
|
61
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
62
|
+
if (messages[i] && messages[i].role === role) return messages[i];
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The first tool_result block in a message's content array, or null. The caller
|
|
68
|
+
* returns one of these after executing a tool_use — its presence means the loop
|
|
69
|
+
* is closing and we answer with end_turn. */
|
|
70
|
+
function firstToolResult(message) {
|
|
71
|
+
const content = message && message.content;
|
|
72
|
+
if (!Array.isArray(content)) return null;
|
|
73
|
+
return content.find((b) => b && b.type === "tool_result") || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Render a tool_result block's `content` (string OR block array OR arbitrary
|
|
77
|
+
* value) back to text — what the caller reported when it executed the tool. */
|
|
78
|
+
function toolResultText(block) {
|
|
79
|
+
const c = block && block.content;
|
|
80
|
+
if (typeof c === "string") return c;
|
|
81
|
+
if (Array.isArray(c)) return textOfContent(c);
|
|
82
|
+
if (c == null) return "";
|
|
83
|
+
try { return JSON.stringify(c); } catch { return String(c); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether a user turn maps to a DECLARED, dispatch-backed graph-query
|
|
88
|
+
* tool, and bind its arguments. Deterministic, in-ethos (no NL guessing beyond
|
|
89
|
+
* the chat surface's own command routing):
|
|
90
|
+
*
|
|
91
|
+
* 1. A slash/bare command that names a tmct tool ("describe X", "/callers X",
|
|
92
|
+
* "untested") → that tool with its argument bound from the exact arg key the
|
|
93
|
+
* dispatchTool switch reads (COMMANDS in chat.mjs). Only when the tool is
|
|
94
|
+
* declared by the caller.
|
|
95
|
+
* 2. Otherwise, a non-conversational structural question → tmct_ask{query:…},
|
|
96
|
+
* when tmct_ask is declared. Small-talk (isConversational) never emits a
|
|
97
|
+
* call — it falls through to a text answer.
|
|
98
|
+
*
|
|
99
|
+
* Returns { name, input } or null (→ answer as text).
|
|
100
|
+
*/
|
|
101
|
+
export function selectTool(text, declaredNames) {
|
|
102
|
+
const t = String(text || "").trim();
|
|
103
|
+
if (!t) return null;
|
|
104
|
+
|
|
105
|
+
// 1. explicit command form → a specific tool, argument bound
|
|
106
|
+
const cmdLine = t.startsWith("/") ? t : asBareCommand(t);
|
|
107
|
+
if (cmdLine) {
|
|
108
|
+
const [first, ...restTok] = cmdLine.replace(/^\//, "").split(/\s+/);
|
|
109
|
+
const spec = COMMANDS[String(first).toLowerCase()];
|
|
110
|
+
if (spec && declaredNames.has(spec.tool) && BACKED_TOOLS.has(spec.tool)) {
|
|
111
|
+
const input = {};
|
|
112
|
+
if (spec.arg) {
|
|
113
|
+
const val = restTok.join(" ").trim();
|
|
114
|
+
if (val) input[spec.arg] = val;
|
|
115
|
+
// an entity command with no argument can't bind a call — fall through
|
|
116
|
+
else if (!spec.optional) return askFallback(t, declaredNames);
|
|
117
|
+
}
|
|
118
|
+
return { name: spec.tool, input };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2. structural question → tmct_ask, unless it's small-talk
|
|
123
|
+
return askFallback(t, declaredNames);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The tmct_ask fallback: emit tmct_ask{query} for a non-conversational line when
|
|
127
|
+
* the caller declared tmct_ask; otherwise null (→ text answer). */
|
|
128
|
+
function askFallback(text, declaredNames) {
|
|
129
|
+
if (declaredNames.has("tmct_ask") && BACKED_TOOLS.has("tmct_ask") && !isConversational(text)) {
|
|
130
|
+
return { name: "tmct_ask", input: { query: text } };
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Build the assistant message envelope shared by every branch. */
|
|
136
|
+
function assistantMessage(model, content, stopReason) {
|
|
137
|
+
return {
|
|
138
|
+
id: `msg_${uuidv7().replace(/-/g, "")}`,
|
|
139
|
+
type: "message",
|
|
140
|
+
role: "assistant",
|
|
141
|
+
model: model || "tmct",
|
|
142
|
+
content,
|
|
143
|
+
stop_reason: stopReason,
|
|
144
|
+
stop_sequence: null,
|
|
145
|
+
usage: { ...ZERO_USAGE },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Produce the Messages-API response for one request body. Pure over its inputs
|
|
151
|
+
* (the loaded graph + config), so it is unit-testable without a socket.
|
|
152
|
+
* - a returned tool_result → end_turn text (relay the tool's output)
|
|
153
|
+
* - a mapped, declared graph tool → tool_use
|
|
154
|
+
* - otherwise → end_turn text via runTurn
|
|
155
|
+
*/
|
|
156
|
+
export async function respondToMessages(body, { config, graph, source = defaultSource } = {}) {
|
|
157
|
+
const { model, messages, tools } = body || {};
|
|
158
|
+
const declaredNames = new Set(
|
|
159
|
+
(Array.isArray(tools) ? tools : []).map((t) => t && t.name).filter(Boolean),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Closing the loop: the caller executed our tool_use and returned a
|
|
163
|
+
// tool_result. Relay it as the final, cited answer with end_turn.
|
|
164
|
+
const lastUser = lastMessageOfRole(messages, "user");
|
|
165
|
+
const tr = firstToolResult(lastUser);
|
|
166
|
+
if (tr) {
|
|
167
|
+
const text = toolResultText(tr) || "(the tool returned no output)";
|
|
168
|
+
return assistantMessage(model, [{ type: "text", text }], "end_turn");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const userText = textOfContent(lastUser && lastUser.content);
|
|
172
|
+
|
|
173
|
+
// tool_use emission: a declared, dispatch-backed graph tool the request maps to.
|
|
174
|
+
if (declaredNames.size) {
|
|
175
|
+
const sel = selectTool(userText, declaredNames);
|
|
176
|
+
if (sel) {
|
|
177
|
+
const block = {
|
|
178
|
+
type: "tool_use",
|
|
179
|
+
id: `toolu_${uuidv7().replace(/-/g, "")}`,
|
|
180
|
+
name: sel.name,
|
|
181
|
+
input: sel.input,
|
|
182
|
+
};
|
|
183
|
+
return assistantMessage(model, [block], "tool_use");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// text answer: the cited, read-only answer the chat surface gives. memoryDir is
|
|
188
|
+
// null so the endpoint is PURE — no session artifacts, no writes, deterministic.
|
|
189
|
+
const { answer } = await runTurn(userText, { config, graph, source, memoryDir: null });
|
|
190
|
+
return assistantMessage(model, [{ type: "text", text: answer }], "end_turn");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Self-description payload (GET /) — lets a routing target discover the endpoint
|
|
194
|
+
* and the tools tmct can back with just an HTTP GET. */
|
|
195
|
+
function describe(config) {
|
|
196
|
+
return {
|
|
197
|
+
service: "tmct",
|
|
198
|
+
description: "Anthropic Messages API-compatible, deterministic, no-LLM graph router (the $0 floor).",
|
|
199
|
+
endpoint: { method: "POST", path: "/v1/messages" },
|
|
200
|
+
graph: config && config.graphFile,
|
|
201
|
+
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema })),
|
|
202
|
+
usage_pricing: ZERO_USAGE,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readBody(req, limit = 5 * 1024 * 1024) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const chunks = [];
|
|
209
|
+
let size = 0;
|
|
210
|
+
req.on("data", (c) => {
|
|
211
|
+
size += c.length;
|
|
212
|
+
if (size > limit) { reject(new Error("request body too large")); req.destroy(); return; }
|
|
213
|
+
chunks.push(c);
|
|
214
|
+
});
|
|
215
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
216
|
+
req.on("error", reject);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sendJson(res, status, obj) {
|
|
221
|
+
const payload = JSON.stringify(obj);
|
|
222
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
223
|
+
res.end(payload);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** An Anthropic-style error envelope. */
|
|
227
|
+
function sendError(res, status, type, message) {
|
|
228
|
+
sendJson(res, status, { type: "error", error: { type, message } });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Start the HTTP server. Loads the graph once (tolerant: a missing artifact is
|
|
233
|
+
* the empty bootstrap graph, never an error). Returns { server, url, host, port,
|
|
234
|
+
* config, close } — `close()` shuts the socket cleanly (no hanging handles).
|
|
235
|
+
*
|
|
236
|
+
* config — { graphFile } (build via configFor(repoPath) in bin/tmct.mjs)
|
|
237
|
+
* host — bind address (default 127.0.0.1)
|
|
238
|
+
* port — TCP port; 0 picks an ephemeral port (tests)
|
|
239
|
+
*/
|
|
240
|
+
export async function startServer({ config, host = "127.0.0.1", port = 0, source = defaultSource } = {}) {
|
|
241
|
+
if (!config || !config.graphFile) throw new Error("startServer requires config.graphFile");
|
|
242
|
+
// Load the graph once, up front. A missing artifact loads as the empty
|
|
243
|
+
// bootstrap graph — runTurn tolerates it (an honest empty/orienting answer).
|
|
244
|
+
const graph = parseEntities(await source.fetchEntities(config));
|
|
245
|
+
|
|
246
|
+
const server = createServer(async (req, res) => {
|
|
247
|
+
try {
|
|
248
|
+
const url = new URL(req.url, "http://localhost");
|
|
249
|
+
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/v1/models")) {
|
|
250
|
+
sendJson(res, 200, describe(config));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (url.pathname !== "/v1/messages") {
|
|
254
|
+
sendError(res, 404, "not_found_error", `no route ${req.method} ${url.pathname}`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (req.method !== "POST") {
|
|
258
|
+
sendError(res, 405, "invalid_request_error", "POST /v1/messages");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let body;
|
|
262
|
+
try {
|
|
263
|
+
body = JSON.parse((await readBody(req)) || "{}");
|
|
264
|
+
} catch {
|
|
265
|
+
sendError(res, 400, "invalid_request_error", "request body is not valid JSON");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!body || !Array.isArray(body.messages)) {
|
|
269
|
+
sendError(res, 400, "invalid_request_error", "`messages` array is required");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const out = await respondToMessages(body, { config, graph, source });
|
|
273
|
+
sendJson(res, 200, out);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
sendError(res, 500, "api_error", e && e.message ? e.message : String(e));
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await new Promise((resolve, reject) => {
|
|
280
|
+
server.once("error", reject);
|
|
281
|
+
server.listen(port, host, () => { server.removeListener("error", reject); resolve(); });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const addr = server.address();
|
|
285
|
+
const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
|
286
|
+
const url = `http://${host}:${boundPort}`;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
server,
|
|
290
|
+
host,
|
|
291
|
+
port: boundPort,
|
|
292
|
+
url,
|
|
293
|
+
config,
|
|
294
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
295
|
+
};
|
|
296
|
+
}
|
package/src/syllogise.mjs
CHANGED
|
Binary file
|
package/src/tui/app.mjs
CHANGED
|
@@ -69,6 +69,40 @@ export function wrapLines(lines, width) {
|
|
|
69
69
|
return out;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// ---- line editor (pure, cursor-aware — readline-style in-line editing) ----
|
|
73
|
+
// The input line is a (value, cursor) pair: `cursor` is an index in [0, value.length]
|
|
74
|
+
// naming the gap BEFORE which the next character lands. Left/right move it; typing and
|
|
75
|
+
// backspace act AT it, so a message can be edited mid-line and resubmitted. Pure so
|
|
76
|
+
// node:test exercises the editing without a terminal.
|
|
77
|
+
|
|
78
|
+
/** Insert `str` at the cursor; the cursor advances past it. */
|
|
79
|
+
export function insertAt(value, cursor, str) {
|
|
80
|
+
const c = clampCursor(value, cursor);
|
|
81
|
+
return { value: value.slice(0, c) + str + value.slice(c), cursor: c + str.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Delete the character BEFORE the cursor (Backspace); the cursor steps left. A no-op
|
|
85
|
+
* at column 0. (This shell treats Backspace and Delete alike — delete-before-cursor.) */
|
|
86
|
+
export function backspaceAt(value, cursor) {
|
|
87
|
+
const c = clampCursor(value, cursor);
|
|
88
|
+
if (c <= 0) return { value, cursor: 0 };
|
|
89
|
+
return { value: value.slice(0, c - 1) + value.slice(c), cursor: c - 1 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clamp a cursor index into [0, value.length]. */
|
|
93
|
+
export function clampCursor(value, cursor) {
|
|
94
|
+
return Math.max(0, Math.min(String(value).length, Number(cursor) || 0));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Split the value around the cursor for rendering: the text before it, the single
|
|
98
|
+
* character UNDER it (a space when the cursor sits past the end), and the text after
|
|
99
|
+
* that character — so the caret block can highlight `at` in place. */
|
|
100
|
+
export function inputCells(value, cursor) {
|
|
101
|
+
const c = clampCursor(value, cursor);
|
|
102
|
+
const s = String(value);
|
|
103
|
+
return { before: s.slice(0, c), at: s.slice(c, c + 1) || " ", after: s.slice(c + 1) };
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
/** Terminal size, live across resizes (falls back to 80×24 off-TTY). */
|
|
73
107
|
function useTerminalSize() {
|
|
74
108
|
const { stdout } = useStdout();
|
|
@@ -90,6 +124,7 @@ export function App({ session }) {
|
|
|
90
124
|
const { columns, rows } = useTerminalSize();
|
|
91
125
|
const [items, setItems] = useState([]);
|
|
92
126
|
const [input, setInput] = useState("");
|
|
127
|
+
const [cursor, setCursor] = useState(0); // caret position within `input` (readline-style)
|
|
93
128
|
const [prompt, setPrompt] = useState(session.promptFor());
|
|
94
129
|
const [busy, setBusy] = useState(false);
|
|
95
130
|
// Command history (up/down arrow recall, readline-style). `history` is oldest→newest;
|
|
@@ -111,10 +146,14 @@ export function App({ session }) {
|
|
|
111
146
|
}
|
|
112
147
|
};
|
|
113
148
|
|
|
149
|
+
// Set the editable line to a whole string with the caret at its end (history recall,
|
|
150
|
+
// submit-reset) — one place so `input` and `cursor` never drift apart.
|
|
151
|
+
const setLine = (value) => { setInput(value); setCursor(value.length); };
|
|
152
|
+
|
|
114
153
|
const trySubmit = (raw) => {
|
|
115
154
|
if (busy) return; // one turn at a time — the engine is deterministic and fast
|
|
116
155
|
const line = String(raw).trim();
|
|
117
|
-
|
|
156
|
+
setLine("");
|
|
118
157
|
setHistCursor(-1); // any submit resets history navigation to the live input
|
|
119
158
|
if (line) {
|
|
120
159
|
// record for up-arrow recall; collapse an immediate duplicate of the last line
|
|
@@ -125,30 +164,36 @@ export function App({ session }) {
|
|
|
125
164
|
|
|
126
165
|
useInput((ch, key) => {
|
|
127
166
|
if (key.return) { trySubmit(input); return; }
|
|
128
|
-
|
|
129
|
-
|
|
167
|
+
// Left/right arrow + Ctrl-A/E: move the caret so a typed line can be edited mid-string
|
|
168
|
+
// and resubmitted (not just appended to / backspaced from the end).
|
|
169
|
+
if (key.leftArrow) { setCursor((c) => clampCursor(input, c - 1)); return; }
|
|
170
|
+
if (key.rightArrow) { setCursor((c) => clampCursor(input, c + 1)); return; }
|
|
171
|
+
if (key.ctrl && ch === "a") { setCursor(0); return; } // home
|
|
172
|
+
if (key.ctrl && ch === "e") { setCursor(input.length); return; } // end
|
|
173
|
+
if (key.backspace || key.delete) { const r = backspaceAt(input, cursor); setInput(r.value); setCursor(r.cursor); return; }
|
|
174
|
+
if (key.ctrl && ch === "u") { setLine(""); return; }
|
|
130
175
|
// Up/down arrow: recall previous prompts (readline-style), oldest→newest history.
|
|
131
176
|
if (key.upArrow) {
|
|
132
177
|
if (!history.length) return;
|
|
133
178
|
const nc = Math.min(histCursor + 1, history.length - 1);
|
|
134
179
|
setHistCursor(nc);
|
|
135
|
-
|
|
180
|
+
setLine(history[history.length - 1 - nc]);
|
|
136
181
|
return;
|
|
137
182
|
}
|
|
138
183
|
if (key.downArrow) {
|
|
139
|
-
if (histCursor <= 0) { setHistCursor(-1);
|
|
184
|
+
if (histCursor <= 0) { setHistCursor(-1); setLine(""); return; } // back to a fresh line
|
|
140
185
|
const nc = histCursor - 1;
|
|
141
186
|
setHistCursor(nc);
|
|
142
|
-
|
|
187
|
+
setLine(history[history.length - 1 - nc]);
|
|
143
188
|
return;
|
|
144
189
|
}
|
|
145
|
-
if (key.ctrl || key.meta || key.escape || key.tab
|
|
190
|
+
if (key.ctrl || key.meta || key.escape || key.tab) return;
|
|
146
191
|
if (!ch) return;
|
|
147
192
|
// A PASTED chunk arrives as one multi-char event; a newline inside it means
|
|
148
193
|
// "submit this line" (one line per turn — the readline shell's per-line read).
|
|
149
194
|
const nl = ch.search(/[\r\n]/);
|
|
150
|
-
if (nl === -1) {
|
|
151
|
-
trySubmit(input + ch.slice(0, nl));
|
|
195
|
+
if (nl === -1) { const r = insertAt(input, cursor, ch); setInput(r.value); setCursor(r.cursor); return; }
|
|
196
|
+
trySubmit(input.slice(0, cursor) + ch.slice(0, nl) + input.slice(cursor));
|
|
152
197
|
});
|
|
153
198
|
|
|
154
199
|
// The pane's line budget: full height minus the input line and the status bar.
|
|
@@ -163,11 +208,15 @@ export function App({ session }) {
|
|
|
163
208
|
h(Text, { key: `l${i}`, wrap: "truncate-end" }, line === "" ? " " : line)),
|
|
164
209
|
),
|
|
165
210
|
h(Box, { height: 1 },
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
211
|
+
(() => {
|
|
212
|
+
const { before, at, after } = inputCells(input, cursor);
|
|
213
|
+
return h(Text, { wrap: "truncate-end" },
|
|
214
|
+
h(Text, { bold: true }, prompt),
|
|
215
|
+
before,
|
|
216
|
+
busy ? h(Text, { dimColor: true }, "…") : h(Text, { inverse: true }, at), // caret block over the char at the cursor
|
|
217
|
+
busy ? null : after,
|
|
218
|
+
);
|
|
219
|
+
})(),
|
|
171
220
|
),
|
|
172
221
|
h(Box, { height: 1 },
|
|
173
222
|
h(Text, { dimColor: true, wrap: "truncate-end" },
|