@vincemakes/kiso-code 0.31.0 → 0.32.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/dist/auth/oauth/chatgpt.js +6 -0
- package/dist/auth/oauth/index.d.ts +5 -0
- package/dist/breaker.d.ts +57 -0
- package/dist/breaker.js +119 -0
- package/dist/chat.d.ts +30 -0
- package/dist/chat.js +112 -5
- package/dist/config.d.ts +3 -0
- package/dist/config.js +20 -0
- package/dist/dispatch.d.ts +24 -4
- package/dist/dispatch.js +257 -6
- package/dist/external-editor.d.ts +30 -0
- package/dist/external-editor.js +55 -0
- package/dist/index.js +303 -34
- package/dist/state.d.ts +35 -0
- package/dist/state.js +27 -0
- package/dist/trust-ui.js +111 -18
- package/dist/update-check.d.ts +9 -3
- package/dist/update-check.js +27 -28
- package/package.json +16 -16
- package/dist/diff.d.ts +0 -32
- package/dist/diff.js +0 -122
package/dist/dispatch.js
CHANGED
|
@@ -8,8 +8,10 @@ import { newSessionId } from "./session-id.js";
|
|
|
8
8
|
import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
|
|
9
9
|
import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
|
|
10
10
|
import { clipboardWrite, lastAnswer } from "./clipboard.js";
|
|
11
|
-
import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
|
|
11
|
+
import { agentModel, body, bodyLog, codingToolOptions, kisoHome, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setModelChoice, setLastBinding } from "./state.js";
|
|
12
12
|
import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
|
|
13
|
+
import { shellTool } from "@vincemakes/kiso-tools-node";
|
|
14
|
+
import { join } from "node:path";
|
|
13
15
|
/** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
|
|
14
16
|
* and its default, from the registry, shown wherever the profile is listed
|
|
15
17
|
* — kiso never silently drops a level, and now it shows the ones it takes.
|
|
@@ -22,6 +24,43 @@ function effortNote(p) {
|
|
|
22
24
|
return "effort: unknown";
|
|
23
25
|
return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
|
|
24
26
|
}
|
|
27
|
+
/** OR-7 — the pick panel's second axis, as DATA. `effortNote` above stays
|
|
28
|
+
* the PRINTED list's string (a machine-readable surface whose bytes do
|
|
29
|
+
* not move); the panel gets the levels structured so its cursor can walk
|
|
30
|
+
* them, and so the bracket can mean "the cursor is here" rather than
|
|
31
|
+
* "the registry default".
|
|
32
|
+
*
|
|
33
|
+
* Where the cursor starts: the session's CURRENT effort when this model
|
|
34
|
+
* has that level, else the model's default, else nowhere — a row whose
|
|
35
|
+
* registry default is null (the model page states none) marks nothing,
|
|
36
|
+
* and enter on it applies the profile alone. Nothing is invented. */
|
|
37
|
+
function effortAxis(p) {
|
|
38
|
+
const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
|
|
39
|
+
if (reasoning === null)
|
|
40
|
+
return {};
|
|
41
|
+
const effort = reasoning.effort;
|
|
42
|
+
if (effort === null)
|
|
43
|
+
return {};
|
|
44
|
+
const levels = effort.levels;
|
|
45
|
+
const carried = lastBinding()?.reasoning?.effort;
|
|
46
|
+
const carriedIdx = carried === undefined || carried === "default" ? -1 : levels.indexOf(carried);
|
|
47
|
+
const defaultIdx = effort.default === null ? -1 : levels.indexOf(effort.default);
|
|
48
|
+
const level = carriedIdx >= 0 ? carriedIdx : defaultIdx;
|
|
49
|
+
// the forbidden set is per THINKING mode, and the panel applies its
|
|
50
|
+
// pick as thinking "default" — the same setting `/model <p> <e>`
|
|
51
|
+
// builds — so only pairs forbidden at that mode are dimmed here.
|
|
52
|
+
const disabled = (reasoning.forbidden ?? []).filter((f) => f.thinking === "default").map((f) => levels.indexOf(f.effort)).filter((i) => i >= 0);
|
|
53
|
+
return {
|
|
54
|
+
levels,
|
|
55
|
+
...(level >= 0 ? { level } : {}),
|
|
56
|
+
...(disabled.length > 0 ? { disabled } : {}),
|
|
57
|
+
// the sentence the coordination note asked for: said only when the
|
|
58
|
+
// cursor could NOT land where the previous selection asked.
|
|
59
|
+
...(carried !== undefined && carried !== "default" && carriedIdx < 0 && defaultIdx >= 0
|
|
60
|
+
? { levelNote: `effort ${carried} \u2192 ${levels[defaultIdx]}: the nearest this model supports` }
|
|
61
|
+
: {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
25
64
|
/** What signs a profile in, for the listing: the env var's name, `oauth`
|
|
26
65
|
* for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
|
|
27
66
|
function signInNote(p) {
|
|
@@ -40,8 +79,98 @@ import { oauthTokenThunk } from "./auth/token.js";
|
|
|
40
79
|
* replay routes through it too — a queued "/last" must never become a
|
|
41
80
|
* user turn (v2c: the rl lives in main, so lines arrive earlier and the
|
|
42
81
|
* queue is the common path). */
|
|
82
|
+
/** §2.2 — the `!` command in flight, if one is. A `!` command and a model
|
|
83
|
+
* run are never both running (the dispatcher is serialized on the chain),
|
|
84
|
+
* so one slot is the whole state. */
|
|
85
|
+
let activeBang = null;
|
|
86
|
+
/** esc while a `!` command runs kills it — the SAME abort the shell tool
|
|
87
|
+
* gives a model's call, so the whole process group goes, children
|
|
88
|
+
* included. Exported because the esc key reaches the editor first: chat
|
|
89
|
+
* offers it here before it reaches a model run, and only one of the two
|
|
90
|
+
* can be in flight. */
|
|
91
|
+
export function abortBangCommand() {
|
|
92
|
+
if (activeBang === null)
|
|
93
|
+
return false;
|
|
94
|
+
activeBang.abort();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** §2.2 — what a submitted line asks for, if it asks for a shell at all.
|
|
98
|
+
* Split out from the dispatcher so the rule is testable without a
|
|
99
|
+
* terminal: `null` means this is not the gesture and the line is a turn.
|
|
100
|
+
*
|
|
101
|
+
* `!!` is checked before `!` because the longer prefix is the narrower
|
|
102
|
+
* claim; testing `!` first would swallow both. */
|
|
103
|
+
export function parseBang(trimmed) {
|
|
104
|
+
// a multi-line paste that merely begins with `!` is prose, the same
|
|
105
|
+
// rule `/` already follows one branch below.
|
|
106
|
+
if (trimmed.includes("\n") || !trimmed.startsWith("!"))
|
|
107
|
+
return null;
|
|
108
|
+
const send = !trimmed.startsWith("!!");
|
|
109
|
+
return { command: trimmed.slice(send ? 1 : 2).trim(), send };
|
|
110
|
+
}
|
|
111
|
+
/** §2.2 — `!cmd` runs a command and SENDS it, `!!cmd` runs one and shows
|
|
112
|
+
* it. Both go through the shell tool's own runner, so the timeout (30 s),
|
|
113
|
+
* the output cap (100,000 chars, which names what it dropped), the
|
|
114
|
+
* whole-tree kill and the STRIPPED environment are the tool's and not a
|
|
115
|
+
* second set of rules. The `!`/`!!` difference is what the MODEL sees,
|
|
116
|
+
* never what leaves the machine.
|
|
117
|
+
*
|
|
118
|
+
* No approval panel, at any tier including plan: the human typed the
|
|
119
|
+
* command. That is not a policy exemption, it is structural — this
|
|
120
|
+
* dispatcher only ever sees lines a human submitted, so a model cannot
|
|
121
|
+
* reach this branch. */
|
|
122
|
+
function runBang(command, send, ctx) {
|
|
123
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
activeBang = controller;
|
|
126
|
+
try {
|
|
127
|
+
const tool = shellTool(codingToolOptions());
|
|
128
|
+
const result = await tool.execute({ command }, { signal: controller.signal });
|
|
129
|
+
// one fenced block, read as a terminal transcript: the command
|
|
130
|
+
// and what it printed. The tool's own overflow note rides inside
|
|
131
|
+
// `content`, so a capped run says so in the same words a model's
|
|
132
|
+
// call would.
|
|
133
|
+
const block = ["```console", `$ ${command}`, result.content, "```"].join("\n");
|
|
134
|
+
if (send) {
|
|
135
|
+
// an ORDINARY user turn: durable, in the log, and the model
|
|
136
|
+
// sees exactly what the human saw.
|
|
137
|
+
ctx.submitTurn(block);
|
|
138
|
+
return; // submitTurn owns the prompt from here
|
|
139
|
+
}
|
|
140
|
+
bodyLog(block, "words");
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
activeBang = null;
|
|
144
|
+
}
|
|
145
|
+
ctx.input.prompt();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
43
148
|
export function dispatch(line, ctx) {
|
|
44
149
|
const trimmed = line.trim();
|
|
150
|
+
// §2.2 — the `!` gesture is the COMPOSER's alone. A piped session, `-p`
|
|
151
|
+
// and `--task-file` keep `!` as ordinary text: those lines are content,
|
|
152
|
+
// and a headless run that shelled out because a prompt began with `!`
|
|
153
|
+
// would be executing its input.
|
|
154
|
+
if (dock.active && !trimmed.includes("\n")) {
|
|
155
|
+
if (trimmed.startsWith("\\!")) {
|
|
156
|
+
// the ONE escape: `\!` sends the literal text starting with `!`.
|
|
157
|
+
ctx.submitTurn(line.replace("\\!", "!"));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const bang = parseBang(trimmed);
|
|
161
|
+
if (bang !== null) {
|
|
162
|
+
const { command, send } = bang;
|
|
163
|
+
if (command === "") {
|
|
164
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
165
|
+
bodyLog("!<command> runs it and sends the result · !!<command> runs it and shows it here · \\! sends a literal !");
|
|
166
|
+
ctx.input.prompt();
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
runBang(command, send, ctx);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
45
174
|
if (trimmed === "/help") {
|
|
46
175
|
// KC3.5 slice ⓪ (the extraction): the ROWS moved to the terminal
|
|
47
176
|
// layer's strings module (helpRows — the KC3 §1 pattern: what the
|
|
@@ -133,6 +262,75 @@ export function dispatch(line, ctx) {
|
|
|
133
262
|
ctx.input.prompt();
|
|
134
263
|
return;
|
|
135
264
|
}
|
|
265
|
+
if (trimmed === "\x07editor") {
|
|
266
|
+
// §2.4 — ctrl+g hands the composer to the human's own editor.
|
|
267
|
+
//
|
|
268
|
+
// The split: the EDITOR owns the terminal handover (raw mode, the
|
|
269
|
+
// mouse, bracketed paste — see `externalEdit`), and this owns the
|
|
270
|
+
// spawn, because the tui package is pure terminal and spawning is
|
|
271
|
+
// not. Neither reaches into the other.
|
|
272
|
+
//
|
|
273
|
+
// $VISUAL before $EDITOR — VISUAL is the variable for a full-screen
|
|
274
|
+
// editor and EDITOR is the fallback — and NO guess beyond them. In
|
|
275
|
+
// an environment that sets neither, dropping someone into an editor
|
|
276
|
+
// they did not choose is the product deciding for them.
|
|
277
|
+
//
|
|
278
|
+
// It never SUBMITS. What comes back is text in the composer and
|
|
279
|
+
// enter is still the human's: one key that both opens an editor and
|
|
280
|
+
// sends is a gesture with no way back.
|
|
281
|
+
const bin = process.env.VISUAL ?? process.env.EDITOR ?? "";
|
|
282
|
+
if (bin.trim() === "") {
|
|
283
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
284
|
+
bodyLog("no editor configured — set $VISUAL or $EDITOR to open one with ctrl+g");
|
|
285
|
+
ctx.input.prompt();
|
|
286
|
+
});
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// The process spawn lives in its own module, reached HERE and not
|
|
290
|
+
// imported at the top. The PTY manifest gate classifies a test by
|
|
291
|
+
// the RESOURCE closure of what it imports, so a static process
|
|
292
|
+
// import in the dispatcher moves every test that imports the
|
|
293
|
+
// dispatcher into the single-file-serial pool — it took `!cmd`'s
|
|
294
|
+
// millisecond-long parser test with it. Loading the module at the
|
|
295
|
+
// key makes the closure honest: importing the dispatcher cannot
|
|
296
|
+
// start a process.
|
|
297
|
+
//
|
|
298
|
+
// (The gate scans SOURCE, comments included, so naming the node
|
|
299
|
+
// module here in prose would reclassify this file just as surely as
|
|
300
|
+
// importing it. Deliberately over-wide — see the gate's own note.)
|
|
301
|
+
//
|
|
302
|
+
// Joining the chain is a FIX, not the price of that. Handing the
|
|
303
|
+
// terminal to a full-screen editor while a turn is streaming rows
|
|
304
|
+
// into that same terminal gives the child a screen kiso is still
|
|
305
|
+
// painting over. An idle chain resolves on the next microtask, so
|
|
306
|
+
// the press that is not mid-turn is unchanged.
|
|
307
|
+
ctx.chainRef.current = ctx.chainRef.current.then(async () => {
|
|
308
|
+
const { runExternalEditor } = await import("./external-editor.js");
|
|
309
|
+
const root = join(kisoHome(), "tmp");
|
|
310
|
+
ctx.input.externalEdit?.((text) => runExternalEditor(bin, root, text, (msg) => body.notice(msg)));
|
|
311
|
+
// the external program drew over the screen; the model is the only
|
|
312
|
+
// authority on what should be there (R14).
|
|
313
|
+
body.reprint();
|
|
314
|
+
ctx.input.prompt();
|
|
315
|
+
});
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (trimmed === "\x14think") {
|
|
319
|
+
// §2.3 — ctrl+t folds the committed thinking blocks, and folds them
|
|
320
|
+
// back. DC-50's mechanism, not a second one: one boolean, then the
|
|
321
|
+
// session is reprinted, so the blocks already on screen obey the
|
|
322
|
+
// switch rather than only the next ones.
|
|
323
|
+
//
|
|
324
|
+
// The folded row is `foldThinking`'s, which is what the PIPE writes
|
|
325
|
+
// — so thinking has two renderings in the product and not three,
|
|
326
|
+
// and the pipe's byte-identity gate is this row's gate too.
|
|
327
|
+
//
|
|
328
|
+
// The live `thinking…` placeholder is NOT touched: it belongs to
|
|
329
|
+
// the live region, and this switch is about committed blocks.
|
|
330
|
+
body.toggleThinking();
|
|
331
|
+
ctx.input.prompt();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
136
334
|
if (trimmed === "\x18copy" || trimmed === "/copy") {
|
|
137
335
|
// E1 §3 — the last answer onto the clipboard, as RAW MARKDOWN.
|
|
138
336
|
//
|
|
@@ -316,13 +514,17 @@ export function dispatch(line, ctx) {
|
|
|
316
514
|
// surface and this round moves no bytes on one.
|
|
317
515
|
if (arg === "" && dock.active && ctx.input.panelAsk !== undefined) {
|
|
318
516
|
const names = Object.keys(configModels);
|
|
517
|
+
let pickedLevel;
|
|
319
518
|
const picked = await new Promise((resolve) => {
|
|
320
519
|
ctx.input.panelAsk(modelPickView({
|
|
321
520
|
header: `model — current: ${agentModel}`,
|
|
322
521
|
options: names.map((name) => {
|
|
323
522
|
const profile = configModels[name];
|
|
324
|
-
|
|
325
|
-
|
|
523
|
+
// the note keeps what tells two rows apart; the levels
|
|
524
|
+
// left it for the axis below (they were being cut off
|
|
525
|
+
// the end of the note column at 100 columns).
|
|
526
|
+
const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
|
|
527
|
+
return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · "), ...effortAxis(profile) };
|
|
326
528
|
}),
|
|
327
529
|
// PH-1a (finding PH-F4): the example must be a syntax
|
|
328
530
|
// directWriteProfile actually ACCEPTS — the old
|
|
@@ -337,14 +539,30 @@ export function dispatch(line, ctx) {
|
|
|
337
539
|
// DC-12 (design §4): a panel WAITING ON A HUMAN says ❯. The
|
|
338
540
|
// else-branch keeps ▸ — it names the current tier, which is
|
|
339
541
|
// what ▸ means everywhere else.
|
|
340
|
-
ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) =>
|
|
542
|
+
ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => {
|
|
543
|
+
// OR-7: the level rides beside the result, so a pick
|
|
544
|
+
// with no second axis is byte-for-byte the old one.
|
|
545
|
+
if (v.action === "picked")
|
|
546
|
+
pickedLevel = v.level;
|
|
547
|
+
resolve(v.action === "picked" ? v.result : null);
|
|
548
|
+
});
|
|
341
549
|
});
|
|
342
550
|
ctx.paintIdle();
|
|
343
551
|
if (picked === null) {
|
|
344
552
|
ctx.input.prompt();
|
|
345
553
|
return; // esc — nothing switched, nothing said
|
|
346
554
|
}
|
|
347
|
-
|
|
555
|
+
if ("index" in picked) {
|
|
556
|
+
// OR-7: the panel hands back both axes and the command
|
|
557
|
+
// string is where they meet — everything below is the
|
|
558
|
+
// typed `/model <profile> <effort>` path, unchanged, so
|
|
559
|
+
// the refusal wording and the durable revision are its.
|
|
560
|
+
const name = names[picked.index];
|
|
561
|
+
const level = pickedLevel === undefined ? undefined : effortAxis(configModels[name]).levels?.[pickedLevel];
|
|
562
|
+
arg = level === undefined ? name : `${name} ${level}`;
|
|
563
|
+
}
|
|
564
|
+
else
|
|
565
|
+
arg = picked.custom;
|
|
348
566
|
}
|
|
349
567
|
if (arg === "") {
|
|
350
568
|
bodyLog(`model: ${agentModel}`);
|
|
@@ -373,7 +591,13 @@ export function dispatch(line, ctx) {
|
|
|
373
591
|
bodyLog(`no such model profile: ${profName}`);
|
|
374
592
|
}
|
|
375
593
|
else if (!profileAvailable(profile)) {
|
|
376
|
-
|
|
594
|
+
// the PROFILE name, not the whole argument: what is
|
|
595
|
+
// unavailable is the credential, and an effort token
|
|
596
|
+
// riding along ("claude high: unavailable") reads as
|
|
597
|
+
// though the level were part of the problem. The panel
|
|
598
|
+
// began producing that token in OR-7; the typed path
|
|
599
|
+
// could always produce it too.
|
|
600
|
+
bodyLog(unavailableReason(profName, profile));
|
|
377
601
|
}
|
|
378
602
|
else {
|
|
379
603
|
const auth = authForProfile(profName, profile);
|
|
@@ -430,6 +654,18 @@ export function dispatch(line, ctx) {
|
|
|
430
654
|
setLastBinding(binding);
|
|
431
655
|
setAgentModel(profile.model, profile.baseUrl);
|
|
432
656
|
setCurrentModelName(arg);
|
|
657
|
+
// §2.5: the ONE source a reload reads for the model — a
|
|
658
|
+
// switch made here must survive the rebuild.
|
|
659
|
+
setModelChoice(arg);
|
|
660
|
+
// OR-7 (owner, 2026-09-09): the status row is repainted BY the
|
|
661
|
+
// switch — it used to keep the old model until the next
|
|
662
|
+
// recap repainted it, so "takes effect on the next turn"
|
|
663
|
+
// read as "did not take". Painted BEFORE the notice so the
|
|
664
|
+
// frame that carries the notice already carries the row.
|
|
665
|
+
// DF-0311-F1: repainted with NO meter — the 0.31.1 dogfood
|
|
666
|
+
// (and the owner's) read `gpt-6-astra · CH 92%`, the new
|
|
667
|
+
// model beside the previous model's figure.
|
|
668
|
+
ctx.modelSwitched();
|
|
433
669
|
body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
|
|
434
670
|
}
|
|
435
671
|
}
|
|
@@ -565,6 +801,21 @@ export function dispatch(line, ctx) {
|
|
|
565
801
|
ctx.requestSwitch(newSessionId(sessionsDir()));
|
|
566
802
|
return;
|
|
567
803
|
}
|
|
804
|
+
if (trimmed === "/reload") {
|
|
805
|
+
// §2.5: extensions, skills and config, read again into a session that
|
|
806
|
+
// keeps its conversation. It is a REBUILD — the tool registry is
|
|
807
|
+
// built in the agent's constructor and has no unregister, so nothing
|
|
808
|
+
// short of constructing the agent again can drop a tool or add one.
|
|
809
|
+
// The durable log is untouched: a reload is invisible in the record,
|
|
810
|
+
// because nothing the model said or did changes.
|
|
811
|
+
if (ctx.isRunning()) {
|
|
812
|
+
body.notice("[/reload] a run is in flight — let it finish (esc stops it), then reload");
|
|
813
|
+
ctx.input.prompt();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
ctx.requestReload();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
568
819
|
if (trimmed === "/resume" || trimmed.startsWith("/resume ")) {
|
|
569
820
|
// the mini-spec: the in-session door to the durable sessions —
|
|
570
821
|
// /resume <id> switches directly; bare /resume opens the SAME
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.4 — the RUN half of ctrl+g.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher routes and this runs: it owns the temp file and the
|
|
5
|
+
* spawn, and knows nothing about the composer, the body or the session.
|
|
6
|
+
* Everything it needs arrives as an argument, which is what lets the
|
|
7
|
+
* dispatcher reach it through `await import(...)` at the call rather than
|
|
8
|
+
* a static import.
|
|
9
|
+
*
|
|
10
|
+
* That laziness is load-bearing, not stylistic. The PTY manifest gate
|
|
11
|
+
* classifies a test by the RESOURCE closure of what it imports, so a
|
|
12
|
+
* static `node:child_process` anywhere in dispatch.ts reclassifies every
|
|
13
|
+
* test that imports dispatch — a millisecond-long parser test for `!cmd`
|
|
14
|
+
* was moved into the single-file-serial pool by this module's first
|
|
15
|
+
* shape. A module the dispatcher only loads when the key is pressed is
|
|
16
|
+
* honestly outside that closure: nothing that merely imports the
|
|
17
|
+
* dispatcher can spawn anything.
|
|
18
|
+
*
|
|
19
|
+
* The failure contract is the whole point of the return type: `null`
|
|
20
|
+
* means KEEP WHAT WAS THERE. A failed edit must not eat what the human
|
|
21
|
+
* had already written.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Run `bin` on `text` and return what came back, or `null` when the edit
|
|
25
|
+
* failed — in which case `onFailure` has already been told, in one line,
|
|
26
|
+
* what went wrong.
|
|
27
|
+
*
|
|
28
|
+
* @param root the directory temp files live under (`$KISO_HOME/tmp`)
|
|
29
|
+
*/
|
|
30
|
+
export declare function runExternalEditor(bin: string, root: string, text: string, onFailure: (line: string) => void): string | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §2.4 — the RUN half of ctrl+g.
|
|
3
|
+
*
|
|
4
|
+
* The dispatcher routes and this runs: it owns the temp file and the
|
|
5
|
+
* spawn, and knows nothing about the composer, the body or the session.
|
|
6
|
+
* Everything it needs arrives as an argument, which is what lets the
|
|
7
|
+
* dispatcher reach it through `await import(...)` at the call rather than
|
|
8
|
+
* a static import.
|
|
9
|
+
*
|
|
10
|
+
* That laziness is load-bearing, not stylistic. The PTY manifest gate
|
|
11
|
+
* classifies a test by the RESOURCE closure of what it imports, so a
|
|
12
|
+
* static `node:child_process` anywhere in dispatch.ts reclassifies every
|
|
13
|
+
* test that imports dispatch — a millisecond-long parser test for `!cmd`
|
|
14
|
+
* was moved into the single-file-serial pool by this module's first
|
|
15
|
+
* shape. A module the dispatcher only loads when the key is pressed is
|
|
16
|
+
* honestly outside that closure: nothing that merely imports the
|
|
17
|
+
* dispatcher can spawn anything.
|
|
18
|
+
*
|
|
19
|
+
* The failure contract is the whole point of the return type: `null`
|
|
20
|
+
* means KEEP WHAT WAS THERE. A failed edit must not eat what the human
|
|
21
|
+
* had already written.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
/**
|
|
27
|
+
* Run `bin` on `text` and return what came back, or `null` when the edit
|
|
28
|
+
* failed — in which case `onFailure` has already been told, in one line,
|
|
29
|
+
* what went wrong.
|
|
30
|
+
*
|
|
31
|
+
* @param root the directory temp files live under (`$KISO_HOME/tmp`)
|
|
32
|
+
*/
|
|
33
|
+
export function runExternalEditor(bin, root, text, onFailure) {
|
|
34
|
+
// per INVOCATION, not per run: a directory per call is how the tree
|
|
35
|
+
// grew 400,000 of them once.
|
|
36
|
+
mkdirSync(root, { recursive: true });
|
|
37
|
+
const dir = mkdtempSync(join(root, "compose-"));
|
|
38
|
+
const file = join(dir, "message.md"); // .md so the editor lights it up
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(file, text, "utf8");
|
|
41
|
+
const r = spawnSync(bin, [file], { stdio: "inherit", shell: false });
|
|
42
|
+
if (r.error !== undefined || (r.status !== null && r.status !== 0)) {
|
|
43
|
+
onFailure(`[ctrl+g] ${bin} exited ${r.status ?? "abnormally"} — the composer is unchanged`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return readFileSync(file, "utf8").replace(/\n+$/, "");
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
onFailure(`[ctrl+g] ${err instanceof Error ? err.message : String(err)} — the composer is unchanged`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|