@turri-dev/cli 0.1.3-next.26f08c5 → 0.1.3-next.2babf06

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/extension.js CHANGED
@@ -5,7 +5,7 @@ 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, compositeTuiLine, getCapabilities, getPngDimensions, isKeyRelease, isKeyRepeat, matchesKey, renderImage, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
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";
@@ -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 } 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";
@@ -1468,9 +1470,17 @@ function registerJobTools(pi, jobs, cwd) {
1468
1470
  return { content: [{ type: "text", text }], details: {} };
1469
1471
  },
1470
1472
  });
1471
- // The side panel: the regeneration - and the pulses behind p - over the whole screen. Its key (PANEL_KEY) opens it; the
1472
- // key again, or esc, closes it and goes back to the chat. While it shows, every key is its own.
1473
+ // The side panel: the regeneration - and the pulses behind p - over the whole screen, or beside the chat (s, from one to
1474
+ // the other). Its key (PANEL_KEY) opens it; the key again closes it and goes back to the chat, as esc does over the
1475
+ // screen - beside the chat, esc gives the chat its keys back and the panel stays, and the key gives them to the panel.
1476
+ // While the panel has the keys, every key is its own.
1473
1477
  let panel;
1478
+ // Beside the chat or over it: as the person last left it, for the conversation.
1479
+ let panelDocked = false;
1480
+ // The terminal the panel is drawn on, for its width: set as it opens.
1481
+ let panelTerminal;
1482
+ /** The columns the panel takes beside the chat - 0 over the whole screen, or when the terminal is too narrow to share. */
1483
+ const panelBeside = () => (panelDocked && panelTerminal !== undefined ? dockedWidth(panelTerminal.columns) : 0);
1474
1484
  // What is open in the pulse list and the map, and where their cursors are: the conversation's, as left each time it opens.
1475
1485
  const jobsTree = createJobsTreeState();
1476
1486
  const mapState = createSpecMapState();
@@ -1498,13 +1508,51 @@ function registerJobTools(pi, jobs, cwd) {
1498
1508
  needsLooking.delete(jobs);
1499
1509
  if (panel !== undefined) {
1500
1510
  panel.handle?.setHidden(false);
1511
+ panel.keys = true;
1501
1512
  panel.handle?.focus();
1502
1513
  return;
1503
1514
  }
1504
1515
  // Not awaited: the panel lives on while the jobs it shows run.
1505
1516
  void ctx.ui.custom((tui, theme, _keybindings, done) => {
1506
1517
  const close = () => done(undefined);
1518
+ panelTerminal = tui.terminal;
1507
1519
  let component;
1520
+ // Beside the chat: the chat drawn narrower, on the left, and a click on either side giving it the keys - undone
1521
+ // when the panel goes over the screen, or closes.
1522
+ let undock;
1523
+ const layOut = () => {
1524
+ if (panelDocked && undock === undefined) {
1525
+ const widen = narrowChat(tui, panelBeside);
1526
+ const unwatch = clickToFocus(tui, () => tui.terminal.columns - panelBeside(), (side) => {
1527
+ if (panelBeside() === 0)
1528
+ return;
1529
+ if (side === "chat")
1530
+ toChat();
1531
+ else
1532
+ toPanel();
1533
+ });
1534
+ undock = () => {
1535
+ unwatch();
1536
+ widen();
1537
+ };
1538
+ }
1539
+ else if (!panelDocked && undock !== undefined) {
1540
+ undock();
1541
+ undock = undefined;
1542
+ }
1543
+ };
1544
+ // Beside the chat, back to it: the chat has the keys, the panel stays.
1545
+ const toChat = () => {
1546
+ entry.keys = false;
1547
+ entry.handle?.unfocus();
1548
+ tui.requestRender();
1549
+ };
1550
+ const toPanel = () => {
1551
+ entry.keys = true;
1552
+ entry.handle?.focus();
1553
+ tui.requestRender();
1554
+ };
1555
+ const back = () => (panelBeside() > 0 ? toChat() : close());
1508
1556
  // Opened, it reads the files again at once: they may have changed while it was closed.
1509
1557
  specMap.refresh();
1510
1558
  const view = createSidePanel({
@@ -1514,6 +1562,14 @@ function registerJobTools(pi, jobs, cwd) {
1514
1562
  mapState,
1515
1563
  rows: () => tui.terminal.rows,
1516
1564
  key: PANEL_KEY,
1565
+ dock: {
1566
+ docked: () => panelBeside() > 0,
1567
+ toggle: () => {
1568
+ panelDocked = panelBeside() === 0;
1569
+ layOut();
1570
+ tui.requestRender();
1571
+ },
1572
+ },
1517
1573
  style: {
1518
1574
  heading: (text) => theme.bold(theme.fg("accent", text)),
1519
1575
  muted: (text) => theme.fg("muted", text),
@@ -1528,22 +1584,25 @@ function registerJobTools(pi, jobs, cwd) {
1528
1584
  // What conformance tests show does what its spec says: brand blue.
1529
1585
  conforms: brandBlue(theme),
1530
1586
  },
1531
- back: close,
1587
+ back,
1532
1588
  close,
1533
1589
  // A module that needs the person: the chat takes it up with them, in their words - what went wrong, and what now.
1534
1590
  discuss: (page) => {
1535
- close();
1591
+ back();
1536
1592
  const why = page.codebase?.stuck?.why;
1537
1593
  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" });
1538
1594
  },
1539
1595
  // Something took the keys' focus while the panel showed - a question in the chat - and waits there.
1540
1596
  waiting: () => {
1597
+ if (!entry.keys)
1598
+ return false;
1541
1599
  const focused = tui.getFocusedComponent?.() ?? null;
1542
1600
  return focused !== null && focused !== component;
1543
1601
  },
1544
1602
  });
1545
- const entry = { view, close };
1603
+ const entry = { view, close, keys: true };
1546
1604
  panel = entry;
1605
+ layOut();
1547
1606
  // Every key goes to the panel while it shows, whatever has the focus: a question the chat model asks in the
1548
1607
  // background takes the focus, but no key reaches it - an enter never answers it unseen. It waits in the chat
1549
1608
  // until the panel closes, and has the keys then. Mouse events are the screen's, before this sees them.
@@ -1565,6 +1624,11 @@ function registerJobTools(pi, jobs, cwd) {
1565
1624
  }
1566
1625
  if (!state)
1567
1626
  return undefined;
1627
+ // Beside the chat, with the chat's keys: they are the chat's - PANEL_KEY among them, for the shortcut that gives
1628
+ // them to the panel. A click on the panel gives it the focus, and the keys with it.
1629
+ if (!entry.keys && entry.handle?.isFocused() !== true)
1630
+ return undefined;
1631
+ entry.keys = true;
1568
1632
  // A terminal on the Kitty keyboard protocol reports a key's release, and listeners are given it: the release of
1569
1633
  // the ctrl+h that opened the panel would close it again at once, and every arrow would move twice. Only
1570
1634
  // presses count - and ctrl+h held down, repeating, does not open and close it over and over.
@@ -1591,7 +1655,8 @@ function registerJobTools(pi, jobs, cwd) {
1591
1655
  }, WATCH_REDRAW_MS);
1592
1656
  timer.unref?.();
1593
1657
  component = {
1594
- render: (width) => view.render(width),
1658
+ // Beside the chat, a rule down its left edge between them.
1659
+ render: (width) => (panelBeside() > 0 ? view.render(width - 2).map((line) => `${theme.fg("borderMuted", "│")} ${line}`) : view.render(width)),
1595
1660
  // Reached only if the listener above is gone: the same, so the panel never passes a key on.
1596
1661
  handleInput: (data) => {
1597
1662
  if (isKeyRepeat(data) && matchesKey(data, PANEL_KEY))
@@ -1607,6 +1672,7 @@ function registerJobTools(pi, jobs, cwd) {
1607
1672
  dispose: () => {
1608
1673
  unlisten();
1609
1674
  clearInterval(timer);
1675
+ undock?.();
1610
1676
  if (panel === entry)
1611
1677
  panel = undefined;
1612
1678
  },
@@ -1614,8 +1680,15 @@ function registerJobTools(pi, jobs, cwd) {
1614
1680
  return component;
1615
1681
  }, {
1616
1682
  overlay: true,
1617
- // The whole screen: the panel or the chat, one at a time.
1618
- overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", margin: 0 },
1683
+ // The whole screen, or the columns on the right beside the chat - read at each frame, so it follows the terminal.
1684
+ overlayOptions: {
1685
+ get width() {
1686
+ return panelBeside() > 0 ? panelBeside() : "100%";
1687
+ },
1688
+ maxHeight: "100%",
1689
+ anchor: "top-right",
1690
+ margin: 0,
1691
+ },
1619
1692
  onHandle: (handle) => {
1620
1693
  if (panel !== undefined)
1621
1694
  panel.handle = handle;
@@ -1641,7 +1714,7 @@ function registerJobTools(pi, jobs, cwd) {
1641
1714
  panelOpeners.set(jobs, (ctx) => openPanel(ctx));
1642
1715
  questionPeek = {
1643
1716
  open: (ctx) => openPanel(ctx),
1644
- shown: () => panel !== undefined,
1717
+ shown: () => panel !== undefined && panel.keys,
1645
1718
  };
1646
1719
  pi.registerShortcut(PANEL_KEY, {
1647
1720
  description: "Peek at regen and the pulses - a question waiting in the chat waits for you - or go back to it",
@@ -2239,8 +2312,6 @@ function animateJellyfish(ctx) {
2239
2312
  }, { placement: "belowEditor" });
2240
2313
  }
2241
2314
  const JELLYFISH_CLOCK_KEY = "turri-jellyfish";
2242
- /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
2243
- const PICTURE_AT = "\x1b[2G";
2244
2315
  /** The columns the banner's picture takes from the left edge, while it shows one; 0 when it does not. */
2245
2316
  let pictureRight = 0;
2246
2317
  /**
@@ -2255,14 +2326,7 @@ function drawOverlaysBesidePicture(tui) {
2255
2326
  return;
2256
2327
  target.turriPicture = true;
2257
2328
  const original = target.compositeLineAt.bind(tui);
2258
- target.compositeLineAt = (base, overlay, col, width, total) => {
2259
- const at = base.lastIndexOf(PICTURE_AT);
2260
- const picture = at === -1 ? "" : base.slice(at + PICTURE_AT.length);
2261
- if (!/^(\x1b\[\d+A)?(\x1b_G|\x1b\]1337;File=)/.test(picture))
2262
- return original(base, overlay, col, width, total);
2263
- const drawn = compositeTuiLine(base.slice(0, at), overlay, col, width, total);
2264
- return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture}` : drawn;
2265
- };
2329
+ target.compositeLineAt = (base, overlay, col, width, total) => overPictureRow(base, overlay, col, width, total, pictureRight) ?? original(base, overlay, col, width, total);
2266
2330
  }
2267
2331
  /** Columns between the jellyfish and the lines beside it. */
2268
2332
  const JELLYFISH_GAP = 3;
@@ -2294,7 +2358,8 @@ class Banner {
2294
2358
  const art = this.art;
2295
2359
  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;
2296
2360
  const left = placed !== undefined ? 1 + placed.columns + JELLYFISH_GAP : 1;
2297
- const text = this.lines.map((line) => truncateToWidth(line, Math.max(1, width - 1 - left)));
2361
+ // Wrapped in the columns beside the picture, never cut short - the chat beside the docked panel is narrow.
2362
+ const text = this.lines.flatMap((line) => wrapTextWithAnsi(line, Math.max(1, width - 1 - left)));
2298
2363
  const height = Math.max(text.length, placed?.rows ?? 0);
2299
2364
  const above = Math.floor((height - text.length) / 2);
2300
2365
  const drawn = placed?.kind === "text" ? placed.frames[jellyfishFrame % placed.frames.length] : undefined;
@@ -0,0 +1,21 @@
1
+ import { compositeTuiLine } from "@earendil-works/pi-tui";
2
+ /** What the banner puts between a row's text and the picture after it: back to the jellyfish's column. */
3
+ export const PICTURE_AT = "\x1b[2G";
4
+ /** The picture's escape sequence, as the banner writes it after PICTURE_AT: Kitty's, in chunks, or iTerm2's - climbing first on iTerm2. */
5
+ const PICTURE = /^(?:\x1b\[\d+A)?(?:(?:\x1b_G[^\x1b]*\x1b\\)+|\x1b\]1337;File=[^\x07]*\x07)/;
6
+ /**
7
+ * The banner's picture row with an overlay drawn over its text, the picture after it - left out while the overlay
8
+ * reaches over the picture, `pictureRight` columns from the left edge. Undefined for a row that holds no picture.
9
+ *
10
+ * What follows the picture - the padding a row narrower than the screen is given, the chat's beside the docked panel -
11
+ * is the row's text: written after the picture, from the picture's column, it would blank the row.
12
+ */
13
+ export function overPictureRow(base, overlay, col, width, total, pictureRight) {
14
+ const at = base.lastIndexOf(PICTURE_AT);
15
+ const picture = at === -1 ? null : PICTURE.exec(base.slice(at + PICTURE_AT.length));
16
+ if (picture === null)
17
+ return undefined;
18
+ const after = base.slice(at + PICTURE_AT.length + picture[0].length);
19
+ const drawn = compositeTuiLine(`${base.slice(0, at)}${after}`, overlay, col, width, total);
20
+ return col >= pictureRight ? `${drawn}${PICTURE_AT}${picture[0]}` : drawn;
21
+ }
@@ -0,0 +1,57 @@
1
+ import { HStack } from "@earendil-works/pi-tui";
2
+ /**
3
+ * The side panel beside the chat rather than over it: the chat drawn on the left in what the panel leaves it, the panel
4
+ * laid over the columns on the right.
5
+ *
6
+ * pi draws its screen one of two ways, and the chat is narrowed in each as it is drawn: full screen (pi's default), a
7
+ * layout - its root put in a row beside a filler as wide as the panel; the regular screen, the lines its render gives -
8
+ * asked for in fewer columns.
9
+ */
10
+ /** Narrower than this, the chat and the panel have no room side by side: the panel takes the whole screen. */
11
+ const SHARED_FROM = 100;
12
+ /** The columns the panel takes beside the chat - 0 when the terminal is too narrow to share. */
13
+ export function dockedWidth(columns) {
14
+ return columns < SHARED_FROM ? 0 : Math.max(48, Math.floor(columns * 0.45));
15
+ }
16
+ /**
17
+ * The chat narrowed by `panelWidth()` columns, read at each frame - 0 gives it the whole width. Returns what gives it the
18
+ * whole width back for good.
19
+ */
20
+ export function narrowChat(tui, panelWidth) {
21
+ const screen = tui;
22
+ if (typeof screen.setLayoutRoot === "function" && screen.layoutRoot !== undefined) {
23
+ const root = screen.layoutRoot;
24
+ // A line as wide as the panel: the row gives the filler what it measures, the chat the rest.
25
+ const filler = { render: () => [" ".repeat(panelWidth())], invalidate() { } };
26
+ screen.setLayoutRoot(new HStack([{ component: root, basis: 0, grow: 1 }, { component: filler }]));
27
+ return () => screen.setLayoutRoot(root);
28
+ }
29
+ // Read from the prototype: the screen's own render, not one laid over it.
30
+ const render = Object.getPrototypeOf(tui).render;
31
+ screen.render = function (width) {
32
+ return render.call(this, Math.max(1, width - panelWidth()));
33
+ };
34
+ return () => {
35
+ screen.render = render;
36
+ };
37
+ }
38
+ /**
39
+ * A press of a mouse button, as pi's full screen takes it - `side` told whether it was in the chat, left of `chatWidth()`
40
+ * columns, or in the panel - before the press goes on as it would have. Motion and the wheel are not presses. pi's
41
+ * regular screen reads no mouse: nothing is done there. Returns what takes the watch off.
42
+ */
43
+ export function clickToFocus(tui, chatWidth, side) {
44
+ const screen = tui;
45
+ const handle = screen.handleMouseEvent;
46
+ if (typeof handle !== "function")
47
+ return () => { };
48
+ screen.handleMouseEvent = function (raw) {
49
+ // 32: motion; 64: the wheel.
50
+ if (raw.release !== true && (raw.button & (32 | 64)) === 0)
51
+ side(raw.x < chatWidth() ? "chat" : "panel");
52
+ return handle.call(this, raw);
53
+ };
54
+ return () => {
55
+ screen.handleMouseEvent = handle;
56
+ };
57
+ }
@@ -12,6 +12,12 @@ export function createSidePanel(options) {
12
12
  let drawnTree = { rows: [], rowStarts: [], lineCount: 0 };
13
13
  let drawnMap = { rows: [], rowStarts: [], lineCount: 0 };
14
14
  let pages = [];
15
+ /** The keys back to the chat - and `s`, from over the screen to beside the chat and back. */
16
+ const backKeys = () => {
17
+ if (options.dock === undefined)
18
+ return [`esc or ${options.key} back to the chat`];
19
+ return options.dock.docked() ? ["s full screen", "esc to the chat", `${options.key} close`] : ["s beside the chat", `esc or ${options.key} back to the chat`];
20
+ };
15
21
  const waitingNote = () =>
16
22
  // A question asked while it showed waits in the chat, unanswered: the person is told it is there.
17
23
  options.waiting?.() === true ? ` ${style.warning?.("your question is waiting") ?? style.accent("your question is waiting")} - ${options.key} or esc to answer it, as you left it` : "";
@@ -59,7 +65,7 @@ export function createSidePanel(options) {
59
65
  /** The rule under the top. */
60
66
  const rule = (width) => style.muted("─".repeat(Math.max(1, width)));
61
67
  const renderPulses = (width, rows) => {
62
- const keys = ["↑↓ move", "enter open/close", "←→", "o/c all", "pgup pgdn scroll", ...(options.map !== undefined ? ["p regen"] : []), `esc or ${options.key} back to the chat`];
68
+ const keys = ["↑↓ move", "enter open/close", "←→", "o/c all", "pgup pgdn scroll", ...(options.map !== undefined ? ["p regen"] : []), ...backKeys()];
63
69
  const top = topBlock([`${style.heading("pulses")}${waitingNote()}`], keys, width);
64
70
  // Under the top: the rule, and a line of space - as the map has.
65
71
  const bodyHeight = Math.max(3, rows - top.length - 2);
@@ -77,7 +83,7 @@ export function createSidePanel(options) {
77
83
  pages = options.map();
78
84
  const now = Date.now();
79
85
  if (pages.length === 0) {
80
- const top = topBlock([`${style.heading("regen")}${waitingNote()}`], ["p pulses", `esc or ${options.key} back to the chat`], width);
86
+ const top = topBlock([`${style.heading("regen")}${waitingNote()}`], ["p pulses", ...backKeys()], width);
81
87
  lastBodyHeight = Math.max(3, rows - top.length - 1);
82
88
  drawnMap = { rows: [], rowStarts: [], lineCount: 0 };
83
89
  const body = [style.muted(" No .plain file to map: name one to regenerate, or set module: in config.turri.yaml."), ...new Array(Math.max(0, lastBodyHeight - 1)).fill("")];
@@ -87,7 +93,7 @@ export function createSidePanel(options) {
87
93
  const tabbed = pages.length > 1;
88
94
  const page = shownPage(pages, map);
89
95
  const discuss = page.stuck && options.discuss !== undefined ? [`${DISCUSS_KEY} talk it through in the chat`] : [];
90
- const keys = [...discuss, "↑↓ move", "enter open/close", ...(tabbed ? ["←→ module"] : []), "o/c all", "pgup pgdn", "p pulses", `esc or ${options.key} back to the chat`];
96
+ const keys = [...discuss, "↑↓ move", "enter open/close", ...(tabbed ? ["←→ module"] : []), "o/c all", "pgup pgdn", "p pulses", ...backKeys()];
91
97
  // The legend's box in the upper right corner, over what is under it; too narrow a panel has none.
92
98
  const box = width >= 60 ? legendBox(legendLines(page, style)) : [];
93
99
  const room = box.length > 0 ? width - visibleWidth(box[0]) - 1 : width;
@@ -210,7 +216,7 @@ export function createSidePanel(options) {
210
216
  }
211
217
  catch (error) {
212
218
  // A frame that throws is never drawn - nor is anything after it: the screen would freeze. What failed is shown instead.
213
- const top = topBlock([`${style.heading(map.view === "map" ? "regen" : "pulses")}${waitingNote()}`], [...(options.map !== undefined ? ["p the other view"] : []), `esc or ${options.key} back to the chat`], width);
219
+ const top = topBlock([`${style.heading(map.view === "map" ? "regen" : "pulses")}${waitingNote()}`], [...(options.map !== undefined ? ["p the other view"] : []), ...backKeys()], width);
214
220
  const lines = (error instanceof Error ? (error.stack ?? error.message) : String(error)).split("\n").slice(0, Math.max(1, rows - top.length - 1));
215
221
  return [
216
222
  ...top,
@@ -225,6 +231,10 @@ export function createSidePanel(options) {
225
231
  options.back();
226
232
  return;
227
233
  }
234
+ if (options.dock !== undefined && data === "s") {
235
+ options.dock.toggle();
236
+ return;
237
+ }
228
238
  if (options.map !== undefined && data === "p") {
229
239
  if (map.view === "map")
230
240
  toPulses();
@@ -609,19 +609,34 @@ function timedOutFailure(run, what) {
609
609
  `the same ${limit} seconds, and a skipped or deleted test does not count as a fix.`,
610
610
  ].join("\n");
611
611
  }
612
+ /** A command `timeout` could not start, or a shell could not find - on a line of its own. */
613
+ const COULD_NOT_RUN = /^(?:[^:]*: )?failed to run command [‘'`"]?([\w.+-]+)[’'`"]?: No such file or directory$/;
614
+ const NOT_FOUND = /^(?:[^:]*: )?(?:line \d+: )?([\w.+-]+): command not found$/;
612
615
  /**
613
616
  * What a failed run says is not installed where it ran - the command, when its output names it - or undefined when
614
- * the run failed for another reason. Exit 69 is the testing scripts' own way to say a toolchain is missing; exit 127,
615
- * or a shell or `timeout` that could not find the command, says it as well.
617
+ * the run failed for another reason. A missing command is the cause only on evidence that it ended the run: exit 69,
618
+ * the testing scripts' own way to say a toolchain is missing, or 127, what a shell or `timeout` gives for a command it
619
+ * could not find - else a command not found on the last line the run printed, nothing having run after it. Earlier in
620
+ * the output it is a warning, not the failure: a shell profile's, an optional tool probed for, a fallback taken - and a
621
+ * run that failed for its own reasons is the agent's to fix, not a stop for the person.
616
622
  */
617
623
  export function missingToolchain(run) {
618
- const named = /failed to run command [‘'`"]?([\w.+-]+)[’'`"]?: No such file or directory/.exec(run.output) ??
619
- /(?:^|\n)(?:[^\n:]*: )?(?:line \d+: )?([\w.+-]+): command not found/.exec(run.output);
620
- if (named !== null)
621
- return { command: named[1] };
622
- if (run.exitCode === 69 || run.exitCode === 127)
624
+ if (run.exitCode === 0)
625
+ return undefined;
626
+ const lines = run.output.split("\n").map((line) => line.trim()).filter((line) => line !== "");
627
+ const named = (line) => COULD_NOT_RUN.exec(line)?.[1] ?? NOT_FOUND.exec(line)?.[1];
628
+ if (run.exitCode === 69 || run.exitCode === 127) {
629
+ // The command is the last one named: the one the run stopped on.
630
+ for (let at = lines.length - 1; at >= 0; at -= 1) {
631
+ const command = named(lines[at]);
632
+ if (command !== undefined)
633
+ return { command };
634
+ }
623
635
  return {};
624
- return undefined;
636
+ }
637
+ const last = lines.at(-1);
638
+ const command = last !== undefined ? named(last) : undefined;
639
+ return command !== undefined ? { command } : undefined;
625
640
  }
626
641
  /** Where `command` is on this machine's PATH, or undefined when it is not there. */
627
642
  export function onThisMachine(command) {
@@ -75,6 +75,7 @@ trust `turri-oss/turri`, the workflow `publish.yml` and the environment `npm`. R
75
75
  | `src/regenerate/confine.ts`, `src/regenerate/agent-shell.ts`, `src/regenerate/command-check.ts`, `src/regenerate/os-sandbox.ts`, `src/regenerate/audit.ts`, `src/regenerate/agent-env.ts`, `src/regenerate/shell.ts` | the file-tool fence and the walls around every agent command: the check, the OS sandbox, the audit, the module's environment, the host runner - see [sandbox](sandbox.md) |
76
76
  | `src/regenerate/engine.ts` | choosing and resolving render models |
77
77
  | `src/regenerate/job-panel.ts`, `src/regenerate/transcript-view.ts`, `src/regenerate/diff-view.ts` | the job panel, the side panel's colours, and a spec diff as they show it - highlighted and banded |
78
+ | `src/regenerate/side-panel.ts`, `src/regenerate/panel-dock.ts` | the side panel, and the chat narrowed beside it when it is docked |
78
79
  | `src/regenerate/spec-map.ts`, `src/regenerate/spec-map-view.ts` | regen: each item's mark, from the records, the `.plain` files and the work loop's plan (`mapPlan`), and the map drawn |
79
80
  | `src/regenerate/debug-log.ts` | each render agent's whole conversation, always streamed to `.turri/debug/` |
80
81
  | `genome/` | what turri knows and can do: `genome/skills/`, the skill library, and `genome/rules/`, the \*\*\*plain authoring rules |
@@ -218,7 +218,7 @@ and the `implement-unit-testing-script` and `implement-conformance-testing-scrip
218
218
  - **The formatting is the agents'.** The harness formats nothing: the scripts check, and leave their inputs as they
219
219
  found them. Every agent that writes code or tests is told to run, in its shell, the formatter its script checks with,
220
220
  on the files it changes, before the final run.
221
- - **A toolchain missing** where the checks run - exit 69, 127, `command not found` - stops the pulse as it does for the
221
+ - **A toolchain missing** where the checks run - exit 69, 127, a `command not found` the run ended on - stops the pulse as it does for the
222
222
  tests: the person installs it, the agents install nothing.
223
223
 
224
224
  ### Checking its own work
@@ -507,8 +507,10 @@ your own environment with this machine's toolchains, turri's model providers' AP
507
507
  and whatever they do is your doing. That is the only way the two scripts are ever run: the harness's own runs, and the
508
508
  agents' `run_unit_tests` and `run_conformance_tests`, which run them exactly as the harness does - through the host
509
509
  runner, in a process group of their own, so the time limit and a stopped job end the script and all it started. A
510
- toolchain the script needs and this machine lacks - exit 69, exit 127, or a `command not found` / `failed to run
511
- command` in its output (`missingToolchain`) - is yours to install, never the agent's: the agent is told to report it
510
+ toolchain the script needs and this machine lacks - exit 69, exit 127, or a run that ended on a `command not found` /
511
+ `failed to run command`, the last line it printed (`missingToolchain`) - is yours to install. Only on that evidence: a
512
+ `command not found` earlier in the output, the run going on after it and failing for its own reasons, is a warning -
513
+ a shell profile's, an optional tool probed for - and the failure is the agent's to fix. A missing toolchain is yours to install, never the agent's: the agent is told to report it
512
514
  with `report_environment_error` rather than work around it, and the pulse stops at the harness's first run of either
513
515
  script - unit or conformance - that cannot find it, rolled back, saying what to install (see
514
516
  [when the environment lacks something](#when-the-environment-lacks-something)). The code and the tests an agent wrote run unfenced inside the script too: what an agent runs with its own
@@ -562,7 +564,7 @@ It is raised two ways:
562
564
  is shown its output once, and a report made again after that stands. A report that stands ends its turn, the pulse
563
565
  stops, and what it said goes to you as it is;
564
566
  - **by the harness itself**: a testing script - the unit or the conformance one, in a code pulse, a conformance pulse or
565
- the whole-suite run - that cannot find its toolchain (exit 69 or 127, `command not found`), or
567
+ the whole-suite run - that cannot find its toolchain (exit 69 or 127, a `command not found` the run ended on), or
566
568
  a credential `agent-env:` names that is not there when the pulse starts - before any agent does. What the harness
567
569
  found it works out at once - the diagnosis, not after a second failure - to say how to fix it on this machine.
568
570
 
package/docs/using.md CHANGED
@@ -130,7 +130,10 @@ come waits for a free slot, and says so.
130
130
  says `ctrl+h shows this pulse`; **`/regen`** on regen, **`/pulses`** on the pulses; not `ctrl+j`, which a terminal without the Kitty
131
131
  keyboard protocol sends as a line feed, the same as `shift+enter`, and which a dialog takes for
132
132
  `enter`; both stay pi's new-line keys. `ctrl+h` is the byte old terminals sent for backspace; a
133
- terminal set to send `^H` for its Backspace key would open the panel on it) takes the whole screen - the panel or the chat, one at a time.
133
+ terminal set to send `^H` for its Backspace key would open the panel on it) takes the whole screen - the panel or the chat, one at a time -
134
+ or, `s`, sits beside the chat: the chat on the left, the panel in the right 45% of the screen, and `s` again
135
+ over the whole screen. It opens as it was last left, for the conversation; a terminal under 100 columns has
136
+ no room for both and shows the panel over the whole screen.
134
137
  It opens on **regen**: the spec itself, in full, with how much of it the code has been regenerated from drawn
135
138
  onto it.
136
139
 
@@ -214,7 +217,11 @@ come waits for a free slot, and says so.
214
217
  closes when it moves to another section. What is open is kept while the panel is closed. What the agents
215
218
  said and did is not shown - it is in each pulse's log (see [Agent logs](#agent-logs)).
216
219
 
217
- `ctrl+h` again, or `esc`, closes it and goes back to the chat. While it shows, every key is the panel's,
220
+ `ctrl+h` again, or `esc`, closes it and goes back to the chat. Beside the chat, a click decides which side has
221
+ the keys - the chat or the panel, the one clicked - and `esc` gives the chat the keys too, the panel staying and
222
+ following the pulses; `ctrl+h` gives the panel the keys again, and from the panel closes it. (pi's regular screen,
223
+ `--tui-mode regular`, reads no mouse: there it is the keys alone.)
224
+ While the panel has the keys, every key is the panel's,
218
225
  whatever else happens: a question the chat model asks in the background (`ask_user`) waits in the chat,
219
226
  unanswered - an `enter` in the panel never answers it - and the panel's top line says "the chat is waiting
220
227
  for your answer". Back in the chat, the question is there, with the keys.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.3-next.26f08c5",
3
+ "version": "0.1.3-next.2babf06",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {