@turri-dev/cli 0.1.2 → 0.1.3-next.162a2c3
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/README.md +1 -1
- package/dist/chat-skills.js +1 -1
- package/dist/cli-options.js +82 -0
- package/dist/config.js +26 -3
- package/dist/conform-cli.js +13 -5
- package/dist/environment.js +19 -0
- package/dist/extension.js +174 -62
- package/dist/identity.js +1 -1
- package/dist/init-config.js +3 -3
- package/dist/loop-cli.js +157 -29
- package/dist/loop-tools.js +1 -1
- package/dist/pi-launch.js +18 -4
- package/dist/pi.js +14 -2
- package/dist/picture-row.js +21 -0
- package/dist/plain/errors.js +1 -1
- package/dist/plain/files.js +1 -1
- package/dist/plain/liquid.js +2 -2
- package/dist/question-dialog.js +5 -1
- package/dist/regenerate/activity.js +19 -0
- package/dist/regenerate/confine.js +1 -0
- package/dist/regenerate/conformance-agents.js +220 -11
- package/dist/regenerate/conformance.js +87 -33
- package/dist/regenerate/debugger.js +27 -3
- package/dist/regenerate/environment-error.js +63 -9
- package/dist/regenerate/loop.js +78 -6
- package/dist/regenerate/panel-dock.js +86 -0
- package/dist/regenerate/prompt.js +4 -2
- package/dist/regenerate/regenerate.js +24 -6
- package/dist/regenerate/release.js +8 -6
- package/dist/regenerate/requires.js +55 -2
- package/dist/regenerate/side-panel.js +14 -4
- package/dist/regenerate/stop-report.js +13 -0
- package/dist/regenerate/test-scripts.js +45 -9
- package/dist/regenerate/unit-review.js +11 -0
- package/dist/regenerate-cli.js +24 -29
- package/dist/resume-line.js +4 -0
- package/dist/run-report.js +289 -0
- package/dist/setup.js +35 -16
- package/dist/spec-diff-cli.js +17 -6
- package/dist/spec-tools.js +8 -44
- package/docs/config.md +25 -13
- package/docs/development.md +7 -0
- package/docs/headless.md +170 -16
- package/docs/installation.md +1 -1
- package/docs/parser.md +1 -1
- package/docs/regenerating.md +72 -17
- package/docs/using.md +16 -5
- package/genome/skills/implement-conformance-testing-script/SKILL.md +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -212,7 +212,7 @@ It runs the interview skill, asks you about the problem, the stack and the produ
|
|
|
212
212
|
model and cost - and the queue behind it;
|
|
213
213
|
- ask to try it at any time - "let me try it" - and **`release`** puts what is built so far in
|
|
214
214
|
`dist/<module>` and **`activate`** starts it, installing what it needs - nothing to approve;
|
|
215
|
-
- the **side panel** (`ctrl+h`) takes the screen and opens on **regen**
|
|
215
|
+
- the **side panel** (`ctrl+h`) takes the screen and opens on **regen** - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`;
|
|
216
216
|
- **`/halt`** halts a pulse, **`/lineage`** shows every generation of a
|
|
217
217
|
module, and the **`drift`** tool says what is left to regenerate;
|
|
218
218
|
- **`/btw <question>`** asks turri something on the side - answered at once, even in the middle of a skill's interview
|
package/dist/chat-skills.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* message, a deploy - is offered as pi offers it.
|
|
12
12
|
*/
|
|
13
13
|
/** What a skill says it is about, that turri covers: ***plain specs and their testing scripts. */
|
|
14
|
-
const ABOUT_PLAIN = /\*\*\*plain|\\\*\\\*\\\*plain|\.plain\b
|
|
14
|
+
const ABOUT_PLAIN = /\*\*\*plain|\\\*\\\*\\\*plain|\.plain\b|functional specs?\b|testing scripts?\b|run_unittests|run_conformance_tests/i;
|
|
15
15
|
/** Whether turri has what a pi skill offers: it runs a skill of that name itself, or the skill is about ***plain. */
|
|
16
16
|
export function supersededByTurri(skill, turriRuns) {
|
|
17
17
|
return turriRuns.has(skill.name) || ABOUT_PLAIN.test(`${skill.name} ${skill.description}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { conformanceOption, overrideConfig, reviewOption, splitModelSpec } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The command line of turri's commands: their words, and the options that stand for the config's own keys - kept apart
|
|
4
|
+
* from the commands that use them, so a light one (doctor) reads them as a regenerate does without loading it.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* A command's arguments: the positional ones, `--flag`s and `--name value` options (`--name=value` too) -
|
|
8
|
+
* `--coding-model` is `--model`. An option it does not take is refused, rather than taking the word after it, and so is an
|
|
9
|
+
* option with no value - the end of the words, or another option where its value should be.
|
|
10
|
+
*/
|
|
11
|
+
export function parseArgs(argv, flags, usage, valued = []) {
|
|
12
|
+
const positional = [];
|
|
13
|
+
const options = {};
|
|
14
|
+
const takes = new Set(["--model", ...valued]);
|
|
15
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
16
|
+
const arg = argv[i];
|
|
17
|
+
const [name, inline] = arg.startsWith("--") && arg.includes("=") ? [arg.slice(0, arg.indexOf("=")), arg.slice(arg.indexOf("=") + 1)] : [arg, undefined];
|
|
18
|
+
const option = name === "--coding-model" ? "--model" : name;
|
|
19
|
+
if (arg === "-h" || arg === "--help") {
|
|
20
|
+
console.log(usage);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
else if (flags.includes(option) && inline === undefined)
|
|
24
|
+
options[option.slice(2)] = "yes";
|
|
25
|
+
else if (takes.has(option)) {
|
|
26
|
+
// Its value is the next word - never another option, which would be taken as the value and lost.
|
|
27
|
+
const value = inline ?? argv[i + 1];
|
|
28
|
+
if (value === undefined || (inline === undefined && value.startsWith("--"))) {
|
|
29
|
+
console.error(`${usage}\n\n${name} needs a value${value !== undefined ? `, not the option ${value}` : ""} - ${name}=<value> gives one that starts with --.`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (inline === undefined)
|
|
33
|
+
i += 1;
|
|
34
|
+
options[option.slice(2)] = value;
|
|
35
|
+
}
|
|
36
|
+
else if (arg.startsWith("-")) {
|
|
37
|
+
console.error(`${usage}\n\nNo such option: ${name}.`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
else
|
|
41
|
+
positional.push(arg);
|
|
42
|
+
}
|
|
43
|
+
return { positional, options };
|
|
44
|
+
}
|
|
45
|
+
/** An option's value, as parseArgs left it: refused when it was given with none. */
|
|
46
|
+
export function optionValue(options, name) {
|
|
47
|
+
const value = options[name];
|
|
48
|
+
if (name in options && (value === undefined || value.trim() === ""))
|
|
49
|
+
failOption(`--${name} needs a value`);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The options that stand for the config's own keys - `--harness-model`, `--coding-model` (`--model`), `--judge-model`
|
|
54
|
+
* and, as asked, `--review` and `--conformance-tests` - read as if config.turri.yaml had them, in their place in the
|
|
55
|
+
* order the config resolves them in (overrideConfig): a run, or doctor, with them is the same as one with them in the
|
|
56
|
+
* file. Exits on a value it cannot read.
|
|
57
|
+
*/
|
|
58
|
+
export function configKeyOptions(options, take = {}) {
|
|
59
|
+
const tests = take.conformance === true ? optionValue(options, "conformance-tests") : undefined;
|
|
60
|
+
const setting = tests !== undefined ? conformanceOption(tests) : undefined;
|
|
61
|
+
if (tests !== undefined && setting === undefined)
|
|
62
|
+
failOption(`--conformance-tests takes all, chosen, module or false, not ${tests}`);
|
|
63
|
+
const reviewed = take.review === true ? optionValue(options, "review") : undefined;
|
|
64
|
+
const review = reviewed !== undefined ? reviewOption(reviewed) : undefined;
|
|
65
|
+
if (reviewed !== undefined && review === undefined)
|
|
66
|
+
failOption(`--review takes true or false, not ${reviewed}`);
|
|
67
|
+
const model = (name, key) => {
|
|
68
|
+
const spec = optionValue(options, name);
|
|
69
|
+
return spec !== undefined ? { [key]: splitModelSpec(spec) } : {};
|
|
70
|
+
};
|
|
71
|
+
overrideConfig({
|
|
72
|
+
...model("harness-model", "harnessModel"),
|
|
73
|
+
...model("model", "codingModel"),
|
|
74
|
+
...model("judge-model", "judgeModel"),
|
|
75
|
+
...(setting !== undefined ? { conformanceTests: setting } : {}),
|
|
76
|
+
...(review !== undefined ? { review } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
function failOption(message) {
|
|
80
|
+
console.error(message);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -49,7 +49,7 @@ import { projectDirOf } from "./regenerate/paths.js";
|
|
|
49
49
|
* as `coding-model`, which it was before there were two.
|
|
50
50
|
*
|
|
51
51
|
* Keys are written with dashes; underscores are read the same (`conformance_tests`). Keys it
|
|
52
|
-
* does not know -
|
|
52
|
+
* does not know - another tool's `config.yaml`, read while it is the old file, may carry several - are ignored, and so is a value of
|
|
53
53
|
* the wrong type. Everything is optional, and what is given on the command line wins.
|
|
54
54
|
*
|
|
55
55
|
* A project whose parts do not share a stack - a Python backend and a React frontend beside each other - keeps a
|
|
@@ -189,8 +189,9 @@ export const ROLE_THINKING = {
|
|
|
189
189
|
diagnosis: "medium",
|
|
190
190
|
};
|
|
191
191
|
/** The roles on the conversation's side: without their own entry they take the chat's model, not `default`. */
|
|
192
|
-
// The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low.
|
|
193
|
-
|
|
192
|
+
// The release is not: it follows steps - a script, a start command - and runs on the coding model, thinking low. Nor
|
|
193
|
+
// is the diagnosis: a post-mortem of a job that failed, read by the person - it runs on the coding model, thinking medium.
|
|
194
|
+
const CHAT_SIDE = ["chat", "chooser", "planner"];
|
|
194
195
|
function isThinkingLevel(value) {
|
|
195
196
|
return typeof value === "string" && THINKING_LEVELS.includes(value);
|
|
196
197
|
}
|
|
@@ -385,6 +386,9 @@ function text(value) {
|
|
|
385
386
|
* not parse.
|
|
386
387
|
*/
|
|
387
388
|
export function readProjectConfig(root, stack) {
|
|
389
|
+
return withRunOverrides(readStackedConfig(root, stack));
|
|
390
|
+
}
|
|
391
|
+
function readStackedConfig(root, stack) {
|
|
388
392
|
const project = readConfigFile(join(root, projectConfigFile(root)));
|
|
389
393
|
if (stack === undefined)
|
|
390
394
|
return project;
|
|
@@ -403,6 +407,25 @@ export function readProjectConfig(root, stack) {
|
|
|
403
407
|
merged.models = joinModels(project.models, own.models);
|
|
404
408
|
return withoutUndefined(merged);
|
|
405
409
|
}
|
|
410
|
+
let runOverrides = {};
|
|
411
|
+
/** Read a command's options as the config's keys for the rest of this process. */
|
|
412
|
+
export function overrideConfig(overrides) {
|
|
413
|
+
runOverrides = { ...overrides };
|
|
414
|
+
}
|
|
415
|
+
/** `--conformance-tests <value>`: the config's own values - all (or true), chosen, module, false. Undefined for anything else. */
|
|
416
|
+
export function conformanceOption(value) {
|
|
417
|
+
return conformanceSetting(value);
|
|
418
|
+
}
|
|
419
|
+
/** `--review <value>`: the config's own, true or false. Undefined for anything else. */
|
|
420
|
+
export function reviewOption(value) {
|
|
421
|
+
const word = value.trim().toLowerCase();
|
|
422
|
+
return word === "true" ? true : word === "false" ? false : undefined;
|
|
423
|
+
}
|
|
424
|
+
/** The config with the command's options in place of the keys they name. */
|
|
425
|
+
function withRunOverrides(config) {
|
|
426
|
+
const given = Object.entries(runOverrides).filter(([, value]) => value !== undefined);
|
|
427
|
+
return given.length === 0 ? config : { ...config, ...Object.fromEntries(given) };
|
|
428
|
+
}
|
|
406
429
|
/** A module's `models:` over the project's, role by role - each entry taking what it leaves out from the project's; the chat's the project's alone. */
|
|
407
430
|
function joinModels(project, own) {
|
|
408
431
|
if (own === undefined)
|
package/dist/conform-cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { parseArgs
|
|
1
|
+
import { parseArgs } from "./cli-options.js";
|
|
2
|
+
import { headlessOptions, runLoop } from "./loop-cli.js";
|
|
2
3
|
const USAGE = `usage: turri conform <file.plain> [frid ...] [options]
|
|
3
4
|
|
|
4
5
|
Writes and runs the black-box conformance tests of a module's functional specs - those named, or
|
|
@@ -8,8 +9,14 @@ module inherits, a failure fixed in the test or the code, whichever is wrong. Ev
|
|
|
8
9
|
tested goes in one pulse, as far as the size guardrail lets.
|
|
9
10
|
|
|
10
11
|
--one-by-one a pulse a functional spec instead
|
|
11
|
-
--model <
|
|
12
|
-
|
|
12
|
+
--harness-model <p/id> config.turri.yaml's harness-model for this run: the planner, the conformance chooser, a diagnosis
|
|
13
|
+
--coding-model <p/id> its coding-model, --model too: the agents that build
|
|
14
|
+
--judge-model <p/id> its judge-model: the judge and the test review
|
|
15
|
+
--review <true|false> its review: whether the code and its tests are reviewed once they pass
|
|
16
|
+
--build-dir <dir> as the run ends, export the code there (see turri regenerate --help)
|
|
17
|
+
--report <file.json> write a report of the run there (see turri regenerate --help)`;
|
|
18
|
+
const { positional, options } = parseArgs(process.argv.slice(2), ["--one-by-one"], USAGE, ["--build-dir", "--report", "--harness-model", "--judge-model", "--review"]);
|
|
19
|
+
const { report, buildDir } = headlessOptions(options, false);
|
|
13
20
|
const [plainFile, ...frids] = positional;
|
|
14
21
|
if (plainFile === undefined) {
|
|
15
22
|
console.error(USAGE);
|
|
@@ -17,10 +24,11 @@ if (plainFile === undefined) {
|
|
|
17
24
|
}
|
|
18
25
|
runLoop({
|
|
19
26
|
plainFile,
|
|
20
|
-
...(
|
|
27
|
+
...(buildDir !== undefined ? { buildDir } : {}),
|
|
28
|
+
...(report !== undefined ? { report } : {}),
|
|
29
|
+
command: ["conform", ...process.argv.slice(2)],
|
|
21
30
|
ask: (loop) => loop.conformance(plainFile, {
|
|
22
31
|
...(frids.length > 0 ? { frids } : {}),
|
|
23
|
-
...(options.model !== undefined ? { model: options.model } : {}),
|
|
24
32
|
...(options["one-by-one"] !== undefined ? { separately: true } : {}),
|
|
25
33
|
}),
|
|
26
34
|
}).catch((error) => {
|
package/dist/environment.js
CHANGED
|
@@ -68,3 +68,22 @@ export function turriOnPath() {
|
|
|
68
68
|
const found = which.status === 0 ? which.stdout.trim().split("\n")[0] : undefined;
|
|
69
69
|
return found === "" ? undefined : found;
|
|
70
70
|
}
|
|
71
|
+
/** turri's version, and the commit when it runs from a clone. */
|
|
72
|
+
export function turriIdentity() {
|
|
73
|
+
let version = null;
|
|
74
|
+
try {
|
|
75
|
+
version = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version ?? null;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* no package.json: left out */
|
|
79
|
+
}
|
|
80
|
+
if (!existsSync(join(PACKAGE_ROOT, ".git")))
|
|
81
|
+
return { version, git_commit: null, git_dirty: null };
|
|
82
|
+
const commit = spawnSync("git", ["-C", PACKAGE_ROOT, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
83
|
+
const status = spawnSync("git", ["-C", PACKAGE_ROOT, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" });
|
|
84
|
+
return {
|
|
85
|
+
version,
|
|
86
|
+
git_commit: commit.status === 0 ? commit.stdout.trim() : null,
|
|
87
|
+
git_dirty: status.status === 0 ? status.stdout.trim() !== "" : null,
|
|
88
|
+
};
|
|
89
|
+
}
|
package/dist/extension.js
CHANGED
|
@@ -5,11 +5,11 @@ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
|
5
5
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
6
|
import { conversationCommand, conversationSettled, conversationWriting } from "./regenerate/conversation-writes.js";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { allocateImageId, Box,
|
|
8
|
+
import { allocateImageId, Box, getCapabilities, getPngDimensions, isKeyRelease, isKeyRepeat, matchesKey, renderImage, Text, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
9
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { Type } from "typebox";
|
|
11
11
|
import { CODING_MODEL_ENV } from "./chat-models.js";
|
|
12
|
-
import { AGENT_ROLES, CONFIG_FILE, configForSpec, hasProjectConfig, LEGACY_CONFIG_FILE, projectConfigFile, stackConfigFileIn, stackOf, stacksOf, withStack, modelSpec, projectModels, readProjectConfig, roleModel, splitModelSpec } from "./config.js";
|
|
12
|
+
import { AGENT_ROLES, CONFIG_FILE, configForSpec, hasProjectConfig, LEGACY_CONFIG_FILE, projectConfigFile, stackConfigFileIn, stackOf, stacksOf, withStack, modelOnly, modelSpec, projectModels, readProjectConfig, roleModel, splitModelSpec } from "./config.js";
|
|
13
13
|
import { skillRunDir } from "./regenerate/paths.js";
|
|
14
14
|
import { sweepOrphanedGroups } from "./regenerate/shell.js";
|
|
15
15
|
import { readScript, scriptAgainstMachine, stackOnMachine, toolchainsBlock } from "./stack.js";
|
|
@@ -31,6 +31,8 @@ import { whyFailed } from "./regenerate/diagnose-job.js";
|
|
|
31
31
|
import { createWorkLoop, describeModules, isUpToDate, previewJobs } from "./regenerate/loop.js";
|
|
32
32
|
import { brandBlue, plainPaint } from "./regenerate/plain-palette.js";
|
|
33
33
|
import { createJobPanel, didNotPass, keyPressed, PANEL_KEY, spinner } from "./regenerate/job-panel.js";
|
|
34
|
+
import { clickToFocus, dockedWidth, narrowChat, shade, shadeChat } from "./regenerate/panel-dock.js";
|
|
35
|
+
import { overPictureRow, PICTURE_AT } from "./picture-row.js";
|
|
34
36
|
import { skillAboutLine, skillProgressLine } from "./skill-progress.js";
|
|
35
37
|
import { createSidePanel } from "./regenerate/side-panel.js";
|
|
36
38
|
import { createSpecMapSource, defaultMapFiles } from "./regenerate/spec-map.js";
|
|
@@ -52,6 +54,7 @@ import { registerStacks } from "./stacks.js";
|
|
|
52
54
|
import { catalogueLine, discoverSkills, leftOutNote } from "./skills.js";
|
|
53
55
|
import { supersededByTurri } from "./chat-skills.js";
|
|
54
56
|
import { TURRI_IDENTITY } from "./identity.js";
|
|
57
|
+
import { turriIdentity } from "./environment.js";
|
|
55
58
|
import { conformanceChooser } from "./regenerate/choose-conformance.js";
|
|
56
59
|
import { answerSideQuestion, askWithAsides, BTW_HOW, runningSkill, skillRunStarted, skillSessionText, transcriptOf } from "./btw.js";
|
|
57
60
|
import { createJobRegistry, defaultParallelJobs, describeJob, formatDuration, pulseName, shouldWait, STOPPED_ON_REQUEST, } from "./regenerate/jobs.js";
|
|
@@ -87,15 +90,23 @@ aside) {
|
|
|
87
90
|
// The question, with what the asker gives to read for it - a brief to confirm - above it; what it said of its work
|
|
88
91
|
// before asking is kept in its record, never put in the dialog.
|
|
89
92
|
// A dialog of turri's own: a long brief scrolls above the question, and the options always show (question-dialog.ts).
|
|
90
|
-
const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) =>
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
const dialog = (question, context, options) => ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
94
|
+
const shown = new QuestionDialog({
|
|
95
|
+
question,
|
|
96
|
+
...(context !== undefined ? { context } : {}),
|
|
97
|
+
...(options !== undefined ? { options } : {}),
|
|
98
|
+
rows: () => tui.terminal.rows,
|
|
99
|
+
style: { accent: (text) => theme.fg("accent", text), muted: (text) => theme.fg("muted", text), bold: (text) => theme.bold(text) },
|
|
100
|
+
done: (answer) => {
|
|
101
|
+
if (questionShown === shown)
|
|
102
|
+
questionShown = undefined;
|
|
103
|
+
done(answer);
|
|
104
|
+
},
|
|
105
|
+
requestRender: () => tui.requestRender(),
|
|
106
|
+
});
|
|
107
|
+
questionShown = shown;
|
|
108
|
+
return shown;
|
|
109
|
+
});
|
|
99
110
|
const asking = (asked) => runAsk(asked, {
|
|
100
111
|
select: (title, options, context) => dialog(title, context, options),
|
|
101
112
|
input: (title, context) => dialog(title, context),
|
|
@@ -114,6 +125,12 @@ aside) {
|
|
|
114
125
|
}
|
|
115
126
|
/** The side panel, as a question open in the chat reaches it: set once the extension has its panel. */
|
|
116
127
|
let questionPeek;
|
|
128
|
+
/**
|
|
129
|
+
* The question open in the chat now, if any. The side panel gives the keys back to it: pi would give them to what had the
|
|
130
|
+
* focus when the panel first opened, and a panel kept beside the chat outlives that question - the keys would go to a
|
|
131
|
+
* dialog already answered, and nothing would take them.
|
|
132
|
+
*/
|
|
133
|
+
let questionShown;
|
|
117
134
|
/** A widget with nothing to draw: below the editor, it takes no line. */
|
|
118
135
|
const PEEK_KEY_WIDGET = "turri-peek-key";
|
|
119
136
|
/**
|
|
@@ -183,7 +200,8 @@ export default function (pi) {
|
|
|
183
200
|
projectDir: cwd,
|
|
184
201
|
harnessDir: PACKAGE_ROOT,
|
|
185
202
|
jobLog,
|
|
186
|
-
model:
|
|
203
|
+
// The coding model the job ran on - its model alone: the diagnosis thinks as its role has it.
|
|
204
|
+
model: modelSpec(modelOnly(job.model ?? modelSpec(projectModels(readProjectConfig(cwd)).coding))),
|
|
187
205
|
});
|
|
188
206
|
return diagnosis !== undefined ? describeDiagnosis(diagnosis) : undefined;
|
|
189
207
|
},
|
|
@@ -1467,9 +1485,17 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1467
1485
|
return { content: [{ type: "text", text }], details: {} };
|
|
1468
1486
|
},
|
|
1469
1487
|
});
|
|
1470
|
-
// The side panel: the regeneration - and the pulses behind p - over the whole screen
|
|
1471
|
-
// key
|
|
1488
|
+
// The side panel: the regeneration - and the pulses behind p - over the whole screen, or beside the chat (s, from one to
|
|
1489
|
+
// the other). Its key (PANEL_KEY) opens it; the key again closes it and goes back to the chat, as esc does over the
|
|
1490
|
+
// screen - beside the chat, esc gives the chat its keys back and the panel stays, and the key gives them to the panel.
|
|
1491
|
+
// While the panel has the keys, every key is its own.
|
|
1472
1492
|
let panel;
|
|
1493
|
+
// Beside the chat or over it: as the person last left it, for the conversation.
|
|
1494
|
+
let panelDocked = false;
|
|
1495
|
+
// The terminal the panel is drawn on, for its width: set as it opens.
|
|
1496
|
+
let panelTerminal;
|
|
1497
|
+
/** The columns the panel takes beside the chat - 0 over the whole screen, or when the terminal is too narrow to share. */
|
|
1498
|
+
const panelBeside = () => (panelDocked && panelTerminal !== undefined ? dockedWidth(panelTerminal.columns) : 0);
|
|
1473
1499
|
// What is open in the pulse list and the map, and where their cursors are: the conversation's, as left each time it opens.
|
|
1474
1500
|
const jobsTree = createJobsTreeState();
|
|
1475
1501
|
const mapState = createSpecMapState();
|
|
@@ -1496,14 +1522,71 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1496
1522
|
}
|
|
1497
1523
|
needsLooking.delete(jobs);
|
|
1498
1524
|
if (panel !== undefined) {
|
|
1525
|
+
panel.keepChatFocus();
|
|
1499
1526
|
panel.handle?.setHidden(false);
|
|
1527
|
+
panel.keys = true;
|
|
1500
1528
|
panel.handle?.focus();
|
|
1501
1529
|
return;
|
|
1502
1530
|
}
|
|
1503
1531
|
// Not awaited: the panel lives on while the jobs it shows run.
|
|
1504
1532
|
void ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
1505
|
-
|
|
1533
|
+
// What the chat shows when the panel takes the keys - its editor, or a pi dialog - for when it gives them back.
|
|
1534
|
+
const focusedNow = () => tui.getFocusedComponent?.() ?? null;
|
|
1535
|
+
let chatFocus = focusedNow();
|
|
1536
|
+
const keepChatFocus = () => {
|
|
1537
|
+
const focused = focusedNow();
|
|
1538
|
+
if (focused !== null && focused !== component && !(focused instanceof QuestionDialog))
|
|
1539
|
+
chatFocus = focused;
|
|
1540
|
+
};
|
|
1541
|
+
// The keys back to the chat: to the question open there now, else to what it showed - never to a question answered
|
|
1542
|
+
// while the panel was open.
|
|
1543
|
+
const giveBack = () => entry.handle?.unfocus({ target: questionShown ?? chatFocus });
|
|
1544
|
+
const close = () => {
|
|
1545
|
+
giveBack();
|
|
1546
|
+
done(undefined);
|
|
1547
|
+
};
|
|
1548
|
+
panelTerminal = tui.terminal;
|
|
1506
1549
|
let component;
|
|
1550
|
+
// Beside the chat: the chat drawn narrower, on the left, and a click on either side giving it the keys - undone
|
|
1551
|
+
// when the panel goes over the screen, or closes.
|
|
1552
|
+
let undock;
|
|
1553
|
+
const layOut = () => {
|
|
1554
|
+
if (panelDocked && undock === undefined) {
|
|
1555
|
+
const widen = narrowChat(tui, panelBeside);
|
|
1556
|
+
const unwatch = clickToFocus(tui, () => tui.terminal.columns - panelBeside(), (side) => {
|
|
1557
|
+
if (panelBeside() === 0)
|
|
1558
|
+
return;
|
|
1559
|
+
if (side === "chat")
|
|
1560
|
+
toChat();
|
|
1561
|
+
else
|
|
1562
|
+
toPanel();
|
|
1563
|
+
});
|
|
1564
|
+
// The side without the keys in the shade: the chat while the panel has them.
|
|
1565
|
+
const unshade = shadeChat(tui, () => panelBeside() > 0 && entry.keys);
|
|
1566
|
+
undock = () => {
|
|
1567
|
+
unshade();
|
|
1568
|
+
unwatch();
|
|
1569
|
+
widen();
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
else if (!panelDocked && undock !== undefined) {
|
|
1573
|
+
undock();
|
|
1574
|
+
undock = undefined;
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
// Beside the chat, back to it: the chat has the keys, the panel stays.
|
|
1578
|
+
const toChat = () => {
|
|
1579
|
+
entry.keys = false;
|
|
1580
|
+
giveBack();
|
|
1581
|
+
tui.requestRender();
|
|
1582
|
+
};
|
|
1583
|
+
const toPanel = () => {
|
|
1584
|
+
keepChatFocus();
|
|
1585
|
+
entry.keys = true;
|
|
1586
|
+
entry.handle?.focus();
|
|
1587
|
+
tui.requestRender();
|
|
1588
|
+
};
|
|
1589
|
+
const back = () => (panelBeside() > 0 ? toChat() : close());
|
|
1507
1590
|
// Opened, it reads the files again at once: they may have changed while it was closed.
|
|
1508
1591
|
specMap.refresh();
|
|
1509
1592
|
const view = createSidePanel({
|
|
@@ -1513,6 +1596,14 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1513
1596
|
mapState,
|
|
1514
1597
|
rows: () => tui.terminal.rows,
|
|
1515
1598
|
key: PANEL_KEY,
|
|
1599
|
+
dock: {
|
|
1600
|
+
docked: () => panelBeside() > 0,
|
|
1601
|
+
toggle: () => {
|
|
1602
|
+
panelDocked = panelBeside() === 0;
|
|
1603
|
+
layOut();
|
|
1604
|
+
tui.requestRender();
|
|
1605
|
+
},
|
|
1606
|
+
},
|
|
1516
1607
|
style: {
|
|
1517
1608
|
heading: (text) => theme.bold(theme.fg("accent", text)),
|
|
1518
1609
|
muted: (text) => theme.fg("muted", text),
|
|
@@ -1527,22 +1618,25 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1527
1618
|
// What conformance tests show does what its spec says: brand blue.
|
|
1528
1619
|
conforms: brandBlue(theme),
|
|
1529
1620
|
},
|
|
1530
|
-
back
|
|
1621
|
+
back,
|
|
1531
1622
|
close,
|
|
1532
1623
|
// A module that needs the person: the chat takes it up with them, in their words - what went wrong, and what now.
|
|
1533
1624
|
discuss: (page) => {
|
|
1534
|
-
|
|
1625
|
+
back();
|
|
1535
1626
|
const why = page.codebase?.stuck?.why;
|
|
1536
1627
|
pi.sendUserMessage(`regen says ${page.module} needs me: ${page.status}. What went wrong, and what do we do about it?${why !== undefined ? `\n\n(turri's reason: ${why})` : ""}`, { deliverAs: "followUp" });
|
|
1537
1628
|
},
|
|
1538
1629
|
// Something took the keys' focus while the panel showed - a question in the chat - and waits there.
|
|
1539
1630
|
waiting: () => {
|
|
1631
|
+
if (!entry.keys)
|
|
1632
|
+
return false;
|
|
1540
1633
|
const focused = tui.getFocusedComponent?.() ?? null;
|
|
1541
1634
|
return focused !== null && focused !== component;
|
|
1542
1635
|
},
|
|
1543
1636
|
});
|
|
1544
|
-
const entry = { view, close };
|
|
1637
|
+
const entry = { view, close, keepChatFocus, keys: true };
|
|
1545
1638
|
panel = entry;
|
|
1639
|
+
layOut();
|
|
1546
1640
|
// Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
|
|
1547
1641
|
// background takes the focus, but no key reaches it - an enter never answers it unseen. It waits in the chat
|
|
1548
1642
|
// until the panel closes, and has the keys then. Mouse events are the screen's, before this sees them.
|
|
@@ -1564,6 +1658,22 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1564
1658
|
}
|
|
1565
1659
|
if (!state)
|
|
1566
1660
|
return undefined;
|
|
1661
|
+
// Beside the chat, ⌥⌘← and ⌥⌘→ go to the side they point at, whichever has the keys - on a terminal that tells
|
|
1662
|
+
// turri of ⌘ (the Kitty keyboard protocol). Its release goes nowhere.
|
|
1663
|
+
if (panelBeside() > 0 && (matchesKey(data, SIDE_KEYS.chat) || matchesKey(data, SIDE_KEYS.panel))) {
|
|
1664
|
+
if (!isKeyRelease(data)) {
|
|
1665
|
+
if (matchesKey(data, SIDE_KEYS.chat))
|
|
1666
|
+
toChat();
|
|
1667
|
+
else
|
|
1668
|
+
toPanel();
|
|
1669
|
+
}
|
|
1670
|
+
return { consume: true };
|
|
1671
|
+
}
|
|
1672
|
+
// Beside the chat, with the chat's keys: they are the chat's - PANEL_KEY among them, for the shortcut that gives
|
|
1673
|
+
// them to the panel. A click on the panel gives it the focus, and the keys with it.
|
|
1674
|
+
if (!entry.keys && entry.handle?.isFocused() !== true)
|
|
1675
|
+
return undefined;
|
|
1676
|
+
entry.keys = true;
|
|
1567
1677
|
// A terminal on the Kitty keyboard protocol reports a key's release, and listeners are given it: the release of
|
|
1568
1678
|
// the ctrl+h that opened the panel would close it again at once, and every arrow would move twice. Only
|
|
1569
1679
|
// presses count - and ctrl+h held down, repeating, does not open and close it over and over.
|
|
@@ -1590,7 +1700,13 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1590
1700
|
}, WATCH_REDRAW_MS);
|
|
1591
1701
|
timer.unref?.();
|
|
1592
1702
|
component = {
|
|
1593
|
-
|
|
1703
|
+
// Beside the chat, a rule down its left edge between them - and the panel in the shade while the chat has the keys.
|
|
1704
|
+
render: (width) => {
|
|
1705
|
+
if (panelBeside() === 0)
|
|
1706
|
+
return view.render(width);
|
|
1707
|
+
const lines = view.render(width - 2).map((line) => `${theme.fg("borderMuted", "│")} ${line}`);
|
|
1708
|
+
return entry.keys ? lines : lines.map(shade);
|
|
1709
|
+
},
|
|
1594
1710
|
// Reached only if the listener above is gone: the same, so the panel never passes a key on.
|
|
1595
1711
|
handleInput: (data) => {
|
|
1596
1712
|
if (isKeyRepeat(data) && matchesKey(data, PANEL_KEY))
|
|
@@ -1606,6 +1722,7 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1606
1722
|
dispose: () => {
|
|
1607
1723
|
unlisten();
|
|
1608
1724
|
clearInterval(timer);
|
|
1725
|
+
undock?.();
|
|
1609
1726
|
if (panel === entry)
|
|
1610
1727
|
panel = undefined;
|
|
1611
1728
|
},
|
|
@@ -1613,34 +1730,36 @@ function registerJobTools(pi, jobs, cwd) {
|
|
|
1613
1730
|
return component;
|
|
1614
1731
|
}, {
|
|
1615
1732
|
overlay: true,
|
|
1616
|
-
// The whole screen
|
|
1617
|
-
overlayOptions: {
|
|
1733
|
+
// The whole screen, or the columns on the right beside the chat - read at each frame, so it follows the terminal.
|
|
1734
|
+
overlayOptions: {
|
|
1735
|
+
get width() {
|
|
1736
|
+
return panelBeside() > 0 ? panelBeside() : "100%";
|
|
1737
|
+
},
|
|
1738
|
+
maxHeight: "100%",
|
|
1739
|
+
anchor: "top-right",
|
|
1740
|
+
margin: 0,
|
|
1741
|
+
},
|
|
1618
1742
|
onHandle: (handle) => {
|
|
1619
1743
|
if (panel !== undefined)
|
|
1620
1744
|
panel.handle = handle;
|
|
1621
1745
|
},
|
|
1622
1746
|
});
|
|
1623
1747
|
};
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
// in each pulse's log.
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
return;
|
|
1636
|
-
openPanel(ctx, view);
|
|
1637
|
-
},
|
|
1638
|
-
});
|
|
1639
|
-
}
|
|
1748
|
+
// /pulses opens the side panel on every pulse, regenerating, planned and finished, and what each regenerates; regen -
|
|
1749
|
+
// the spec, with how far the code has been regenerated into it - is PANEL_KEY's, no command of its own beside
|
|
1750
|
+
// /regenerate. What the agents did is in each pulse's log.
|
|
1751
|
+
pi.registerCommand("pulses", {
|
|
1752
|
+
description: `The pulses, in the side panel: every pulse, regenerating, planned and finished, and what each regenerates. ${PANEL_KEY} opens the panel too.`,
|
|
1753
|
+
handler: async (_args, ctx) => {
|
|
1754
|
+
if (!ctx.hasUI)
|
|
1755
|
+
return;
|
|
1756
|
+
openPanel(ctx, "pulses");
|
|
1757
|
+
},
|
|
1758
|
+
});
|
|
1640
1759
|
panelOpeners.set(jobs, (ctx) => openPanel(ctx));
|
|
1641
1760
|
questionPeek = {
|
|
1642
1761
|
open: (ctx) => openPanel(ctx),
|
|
1643
|
-
shown: () => panel !== undefined,
|
|
1762
|
+
shown: () => panel !== undefined && panel.keys,
|
|
1644
1763
|
};
|
|
1645
1764
|
pi.registerShortcut(PANEL_KEY, {
|
|
1646
1765
|
description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
|
|
@@ -1711,8 +1830,12 @@ export const JOB_FINISHED_REACTION = [
|
|
|
1711
1830
|
"A pulse that stopped on its environment - \"stopped: turri's sandbox kept its agents from…\" or \"stopped: the",
|
|
1712
1831
|
"environment lacks…\" - stops the same way again until that is fixed: never regenerate it before. Heal it, then regenerate the",
|
|
1713
1832
|
"module to resume:",
|
|
1714
|
-
"- turri's sandbox kept the agents from a folder on this machine: nothing is missing. Add the folder its report
|
|
1715
|
-
" `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
|
|
1833
|
+
"- turri's sandbox kept the agents from reading a folder on this machine: nothing is missing. Add the folder its report",
|
|
1834
|
+
" names to `toolchains:` in config.turri.yaml - a list, keeping what is there - say so in a line, and resume.",
|
|
1835
|
+
"- turri's sandbox kept the agents from writing a file - a lock a build tool takes, say: no setting lets them, and",
|
|
1836
|
+
" `toolchains:` does not - never add it there, and never resume before the fix. Do what the report says if it is the",
|
|
1837
|
+
" project's to change - a tool told to use `$HOME` in the build's own files - say so, and resume; otherwise tell the",
|
|
1838
|
+
" person, in a line or two, what the report says and that \"resume\" picks it up once it is done.",
|
|
1716
1839
|
"- the machine lacks a toolchain or a tool - a JDK, Maven, Gradle, Go, uv, Node, a system library: first find out, in your",
|
|
1717
1840
|
" shell, whether it is installed somewhere off the PATH (`command -v`, Homebrew's opt folders, /Library/Java/",
|
|
1718
1841
|
" JavaVirtualMachines, a version manager the person uses - mise, asdf, sdkman, nvm). Then ask the person with ask_user",
|
|
@@ -1724,6 +1847,8 @@ export const JOB_FINISHED_REACTION = [
|
|
|
1724
1847
|
" to do, and that \"resume\" picks it up once it is in.",
|
|
1725
1848
|
"- a credential `agent-env:` names that is not there: never make one up - tell the person where it is read from.",
|
|
1726
1849
|
].join("\n");
|
|
1850
|
+
/** The keys that go to the chat and to the panel, side by side: ⌥⌘← and ⌥⌘→. */
|
|
1851
|
+
const SIDE_KEYS = { chat: "alt+super+left", panel: "alt+super+right" };
|
|
1727
1852
|
/** Opens the side panel, and goes back to it from the conversation. */
|
|
1728
1853
|
/** The modules in this project that have a history, by name. */
|
|
1729
1854
|
function modulesWithHistory(cwd) {
|
|
@@ -2158,7 +2283,8 @@ function registerBranding(pi, ruleCount, skills) {
|
|
|
2158
2283
|
if (data === undefined)
|
|
2159
2284
|
return undefined;
|
|
2160
2285
|
const lines = [
|
|
2161
|
-
`${theme.fg("accent", "turri")} ${theme.fg("muted", `v${data.version}`)}
|
|
2286
|
+
`${theme.fg("accent", "turri")} ${theme.fg("muted", `v${data.version}`)}` +
|
|
2287
|
+
(data.commit !== undefined ? theme.fg("dim", ` from a clone, ${data.commit}${data.changed ? " with changes" : ""}`) : ""),
|
|
2162
2288
|
theme.fg("text", "regenerative software - the code is regenerated from the spec"),
|
|
2163
2289
|
theme.fg("muted", `${data.rules} rules · ${data.skills} skill${data.skills === 1 ? "" : "s"}` +
|
|
2164
2290
|
(data.projectSkills !== undefined && data.projectSkills > 0 ? ` (${data.projectSkills} the project's)` : "") +
|
|
@@ -2180,8 +2306,11 @@ function registerBranding(pi, ruleCount, skills) {
|
|
|
2180
2306
|
if (ctx.hasUI)
|
|
2181
2307
|
animateJellyfish(ctx);
|
|
2182
2308
|
try {
|
|
2309
|
+
const identity = turriIdentity();
|
|
2183
2310
|
pi.appendEntry(BANNER_ENTRY, {
|
|
2184
|
-
version:
|
|
2311
|
+
version: identity.version ?? "0.0.0",
|
|
2312
|
+
commit: identity.git_commit?.slice(0, 7),
|
|
2313
|
+
changed: identity.git_dirty === true,
|
|
2185
2314
|
rules: ruleCount,
|
|
2186
2315
|
skills: skills.skills.length,
|
|
2187
2316
|
projectSkills: skills.skills.filter((skill) => skill.origin !== "turri").length,
|
|
@@ -2234,8 +2363,6 @@ function animateJellyfish(ctx) {
|
|
|
2234
2363
|
}, { placement: "belowEditor" });
|
|
2235
2364
|
}
|
|
2236
2365
|
const JELLYFISH_CLOCK_KEY = "turri-jellyfish";
|
|
2237
|
-
/** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
|
|
2238
|
-
const PICTURE_AT = "\x1b[2G";
|
|
2239
2366
|
/** The columns the banner's picture takes from the left edge, while it shows one; 0 when it does not. */
|
|
2240
2367
|
let pictureRight = 0;
|
|
2241
2368
|
/**
|
|
@@ -2250,14 +2377,7 @@ function drawOverlaysBesidePicture(tui) {
|
|
|
2250
2377
|
return;
|
|
2251
2378
|
target.turriPicture = true;
|
|
2252
2379
|
const original = target.compositeLineAt.bind(tui);
|
|
2253
|
-
target.compositeLineAt = (base, overlay, col, width, total) =>
|
|
2254
|
-
const at = base.lastIndexOf(PICTURE_AT);
|
|
2255
|
-
const picture = at === -1 ? "" : base.slice(at + PICTURE_AT.length);
|
|
2256
|
-
if (!/^(\x1b\[\d+A)?(\x1b_G|\x1b\]1337;File=)/.test(picture))
|
|
2257
|
-
return original(base, overlay, col, width, total);
|
|
2258
|
-
const drawn = compositeTuiLine(base.slice(0, at), overlay, col, width, total);
|
|
2259
|
-
return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture}` : drawn;
|
|
2260
|
-
};
|
|
2380
|
+
target.compositeLineAt = (base, overlay, col, width, total) => overPictureRow(base, overlay, col, width, total, pictureRight) ?? original(base, overlay, col, width, total);
|
|
2261
2381
|
}
|
|
2262
2382
|
/** Columns between the jellyfish and the lines beside it. */
|
|
2263
2383
|
const JELLYFISH_GAP = 3;
|
|
@@ -2289,7 +2409,8 @@ class Banner {
|
|
|
2289
2409
|
const art = this.art;
|
|
2290
2410
|
const placed = art?.kind === "image" ? this.placeImage(art, Math.max(JELLYFISH_ROWS, this.lines.length), width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN) : art?.kind === "text" && art.columns <= width - 2 - JELLYFISH_GAP - BANNER_TEXT_MIN ? art : undefined;
|
|
2291
2411
|
const left = placed !== undefined ? 1 + placed.columns + JELLYFISH_GAP : 1;
|
|
2292
|
-
|
|
2412
|
+
// Wrapped in the columns beside the picture, never cut short - the chat beside the docked panel is narrow.
|
|
2413
|
+
const text = this.lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width - 1 - left)));
|
|
2293
2414
|
const height = Math.max(text.length, placed?.rows ?? 0);
|
|
2294
2415
|
const above = Math.floor((height - text.length) / 2);
|
|
2295
2416
|
const drawn = placed?.kind === "text" ? placed.frames[jellyfishFrame % placed.frames.length] : undefined;
|
|
@@ -2361,12 +2482,3 @@ function jellyfishArt(appearance, ink) {
|
|
|
2361
2482
|
return undefined;
|
|
2362
2483
|
}
|
|
2363
2484
|
}
|
|
2364
|
-
function packageVersion() {
|
|
2365
|
-
try {
|
|
2366
|
-
const raw = readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8");
|
|
2367
|
-
return JSON.parse(raw).version ?? "0.0.0";
|
|
2368
|
-
}
|
|
2369
|
-
catch {
|
|
2370
|
-
return "0.0.0";
|
|
2371
|
-
}
|
|
2372
|
-
}
|