@north-light/crouter 0.3.159 → 0.3.160
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/api/dto/nodes.d.ts +3 -0
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/workflow-codification.md +82 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -3
- package/dist/clients/attach/chrome/bash-jobs.js +15 -8
- package/dist/clients/attach/chrome/canvas-panels.js +10 -3
- package/dist/clients/attach/chrome/roster.d.ts +7 -2
- package/dist/clients/attach/chrome/roster.js +87 -3
- package/dist/clients/attach/input/controller.d.ts +3 -0
- package/dist/clients/attach/input/controller.js +1 -0
- package/dist/clients/attach/render/assistant-message.d.ts +13 -0
- package/dist/clients/attach/render/assistant-message.js +55 -0
- package/dist/clients/attach/render/chat-view.d.ts +29 -0
- package/dist/clients/attach/render/chat-view.js +233 -12
- package/dist/clients/attach/render/context-message.js +1 -1
- package/dist/clients/attach/render/edit-diff.js +20 -1
- package/dist/clients/attach/render/tool-calls.d.ts +32 -0
- package/dist/clients/attach/render/tool-calls.js +251 -0
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +3 -0
- package/dist/clients/attach/slash/dispatch.d.ts +4 -0
- package/dist/clients/attach/slash/dispatch.js +8 -0
- package/dist/clients/attach/viewer.js +512 -508
- package/dist/commands/cron.js +2 -2
- package/dist/commands/node.js +3 -3
- package/dist/commands/search.js +1 -1
- package/dist/commands/sys/config.js +10 -2
- package/dist/commands/sys/doctor.js +22 -1
- package/dist/commands/sys/setup-core.d.ts +5 -1
- package/dist/commands/sys/setup-core.js +16 -2
- package/dist/commands/sys/setup-wizard.d.ts +11 -0
- package/dist/commands/sys/setup-wizard.js +111 -6
- package/dist/core/__tests__/tmux-surface.test.js +5 -2
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/labels.d.ts +1 -0
- package/dist/core/canvas/labels.js +3 -1
- package/dist/core/canvas/nav-render.d.ts +6 -0
- package/dist/core/canvas/nav-render.js +8 -0
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/config.js +3 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +1 -1
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -0
- package/dist/core/preview-registry.d.ts +5 -0
- package/dist/core/preview-registry.js +10 -0
- package/dist/core/runtime/canvas-extensions.d.ts +4 -0
- package/dist/core/runtime/canvas-extensions.js +4 -0
- package/dist/core/runtime/naming-persist.d.ts +4 -3
- package/dist/core/runtime/naming-persist.js +10 -9
- package/dist/core/runtime/naming.d.ts +28 -8
- package/dist/core/runtime/naming.js +168 -32
- package/dist/core/runtime/nerd-font.d.ts +28 -0
- package/dist/core/runtime/nerd-font.js +127 -0
- package/dist/core/runtime/tmux.js +16 -0
- package/dist/daemon/api/map.js +2 -0
- package/dist/pi-extensions/naming-tool.d.ts +24 -0
- package/dist/pi-extensions/naming-tool.js +67 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -86,6 +86,9 @@ export interface NodeWorktreeDTO {
|
|
|
86
86
|
* `GET /v1/nodes/{id}` and by the create/lifecycle actions that yield a node. */
|
|
87
87
|
export interface NodeDetailDTO extends NodeSummaryDTO {
|
|
88
88
|
description?: string;
|
|
89
|
+
/** The Nerd Font glyph the namer chose for this node's work, when it has one
|
|
90
|
+
* (`NodeMeta.icon`). Rendered ahead of the label by surfaces that want it. */
|
|
91
|
+
icon?: string;
|
|
89
92
|
cycles?: number;
|
|
90
93
|
/** Approximate context-window token load of the node's live/last session,
|
|
91
94
|
* used by the orchestrator yield-nudge (`childFollowUp`). Null when unknown
|
|
@@ -15,6 +15,7 @@ Open this dir whenever a task turns on understanding the runtime itself or chang
|
|
|
15
15
|
- **nodes-and-canvas** — the agent-runtime model: nodes on the canvas graph, spawn/delegate, the push/feed spine, lifecycle (mode + lifecycle axes), and revive (manual + daemon auto-revive).
|
|
16
16
|
- **storage-tiers** — where every kind of state lives: the two tiers (scope root and canvas home) and their durability/ownership contracts.
|
|
17
17
|
- **agent-shaping** — the when-to-use-which layer over the four dials that shape a node: kinds (the builtin roster, sub-kinds, and custom personas), modes (base vs orchestrator), profiles, and the memory tiers (node/profile/project/user/builtin).
|
|
18
|
+
- **workflow-codification** — the codification loop for repeatable tasks: do the work hands-on under a capture session, mine the HAR, then codify it as scripts (scope-root `scripts/`) + a slash-invokable memory doc, with the self-heal rule that a failed script is re-derived and rewritten.
|
|
18
19
|
- **plugins** — authoring a crtr plugin: the plugin.json manifest, directory layout, scopes, install mechanics, versioning, command plugins (contributing top-level CLI commands via commands.json + one executable), and configured CLIs (contributing commands as definition + HTTP from a remote manifest, no executable).
|
|
19
20
|
- **marketplaces** — authoring a crtr marketplace: the marketplace.json index, plugin entries, symlink-based install, auto-bump CI, dual-publishing.
|
|
20
21
|
- **examples/** — worked compositions of the primitives into complete systems (the analogue of pi's `examples/` dir), e.g. the iMessage assistant node.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
kind: knowledge
|
|
3
|
+
when-and-why-to-read: When the user names a task as repeatable — "make this repeatable", "save this workflow", "we do this every month" — or when you are about to redo a multi-step task an agent has done before, this playbook should be read because codifying the task into scripts plus a slash-invokable doc makes every future run faster and more reliable than re-deriving it, and an uncodified repeat wastes the entire prior derivation.
|
|
4
|
+
short-form: "The codification loop: do a repeatable task hands-on under a capture session, mine the HAR for direct API calls, then codify it as a slash-invokable memory doc (judgment + facts) plus scripts (the fast path). Scripts live in scope-root scripts/; a failed script means re-derive hands-on and rewrite."
|
|
5
|
+
system-prompt-visibility: preview
|
|
6
|
+
file-read-visibility: none
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Codifying repeatable work — workflows as a slash doc + scripts
|
|
10
|
+
|
|
11
|
+
A **codified workflow** is a convention assembled from two existing surfaces, each carrying what it is best at:
|
|
12
|
+
|
|
13
|
+
- **A workflow memory doc** with `slash: true` in its frontmatter, invocable as `/<name>`. It is the entry point and the single home for everything prose: the *judgment* (ordered steps, verification criteria, stop conditions — "if amounts mismatch, do NOT upload — flag it"), the *environment facts* that outlive any one run (which account bills when, which browser profile is logged in where, which API returns an app shell instead of a PDF), and pointers to the scripts.
|
|
14
|
+
- **Scripts** (`scripts/`, below) are the fast path. Each one deterministically performs a mechanical step the agent would otherwise re-derive: a direct API call mined from traffic, a DOM injection, a PDF fetch.
|
|
15
|
+
|
|
16
|
+
Judgment and facts stay in prose where the agent applies them fresh each run; mechanics compile into scripts that run in milliseconds. A workflow codified as *only* prose is permanently slow; codified as *only* scripts it breaks silently the first time the world drifts. Together they self-heal: **when a script fails, fall back to doing that step hands-on with `crtr capture`, then rewrite the script (and any stale facts in the doc) before finishing.** The workflow gets faster over time and never permanently breaks.
|
|
17
|
+
|
|
18
|
+
Codify only what the user has named repeatable, or what you are repeating yourself. A one-off task gets no scripts.
|
|
19
|
+
|
|
20
|
+
## The loop
|
|
21
|
+
|
|
22
|
+
1. **Run the task hands-on under a capture session.** `crtr capture session start --url <entry>` — the session auto-records HAR, so your clicks and navigations leave a complete traffic trace as a side effect of just doing the work. Sessions attach to the user's real logged-in browser (auto-detection prefers it; `--port` pins an endpoint; `--hold` with a later `--target` adopts an already-open tab).
|
|
23
|
+
2. **Finish the task.** Correctness first — codification captures a *successful* run.
|
|
24
|
+
3. **Mine the trace.** `crtr capture session har <id> --filter-url <domain> --full` inlines request headers, post data, and response bodies. You are looking for the direct path the UI was hiding: the JSON endpoint behind the dashboard, the presigned-URL hop behind the download button, the exact payload a form submit produced. What took ten clicks is usually one or two requests.
|
|
25
|
+
4. **Codify.** Write the workflow doc (`slash: true`) and the scripts (shapes below). The doc references scripts by absolute path; scripts open with a header naming their owning doc.
|
|
26
|
+
5. **Replay and repair.** Next time, `/<name>` runs the scripts in order. On any script failure: re-derive that step hands-on under a fresh session, rewrite the script in place, and update the doc if the *shape* of the task changed. Rewrite as if fresh — never append a changelog.
|
|
27
|
+
|
|
28
|
+
## The scripts tier
|
|
29
|
+
|
|
30
|
+
Scripts live in the scope root, beside `memory/`:
|
|
31
|
+
|
|
32
|
+
- `~/.crouter/scripts/` — user-scope: personal workflows (receipts, bookings, account chores).
|
|
33
|
+
- `<project>/.crouter/scripts/` — project-scope: workflows owned by a repo.
|
|
34
|
+
|
|
35
|
+
Layout: one directory per workflow (`scripts/mercury-receipts/`), plus `scripts/lib/` for shared helpers. Every script opens with a header comment:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// @workflow mercury-receipts — fetch one Anthropic invoice PDF via the invoicedata.stripe.com hop
|
|
39
|
+
// @owner memory/mercury-receipts.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The header is the discovery contract: it makes scripts greppable and lets tooling list them later without any new runtime machinery. A script with no owning workflow doc is an orphan — every script must be reachable from the doc that explains when to run it.
|
|
43
|
+
|
|
44
|
+
**Default runtime is Node ESM (`.mjs`), executable, no dependencies.** JS keeps scripts importable by each other and by the rest of the ecosystem, and Node's builtins cover the usual needs (`util.parseArgs` for flags, `fetch` for HTTP, `child_process` for CLIs). Reach for another runtime only when JS genuinely cannot do the job (e.g. a Python CDP-websocket edge case) — note why in the header.
|
|
45
|
+
|
|
46
|
+
## Calling crtr and capture from a script
|
|
47
|
+
|
|
48
|
+
The CLI is the SDK. Every capture command mirrors its result as JSON under `--json`; spawn it and parse:
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { execFileSync } from 'node:child_process';
|
|
52
|
+
const run = (...args) => JSON.parse(execFileSync('crtr', [...args, '--json'], { encoding: 'utf8' }));
|
|
53
|
+
const tabs = run('capture', 'tab', 'list');
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Do not import crouter's internals into a script. crtrd owns canvas state and the CLI is the sanctioned client seam; the exec boundary also keeps scripts version-proof across runtime generations, since they always talk to the installed CLI.
|
|
57
|
+
|
|
58
|
+
Grow `scripts/lib/kit.mjs` as helpers recur — `run()` above, a `pageExec(js, {port, target})` wrapper, a pinned-target guard. The kit is agent-owned and evolves with use; there is no shipped package to wait on.
|
|
59
|
+
|
|
60
|
+
## Script shape: linear script vs mini-CLI
|
|
61
|
+
|
|
62
|
+
A workflow step with one job is one linear script. But when a workflow has several verbs sharing state and safety rules, build the script *as a small CLI* — subcommands over `util.parseArgs`, noun-verb naming, invariants enforced inside:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
recgov pin [target] # persist the exact browser target; refuse ambiguity
|
|
66
|
+
recgov inspect # read login/cart/availability state, never mutate
|
|
67
|
+
recgov add <date> # refuses non-empty cart, wrong site class, unverified login
|
|
68
|
+
recgov fill # fills forms, stops before any payment action
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
This shape earns its keep exactly when the task has **irreversible edges** (payments, submissions, deletions): the CLI encodes the guard once — verify login before every mutation, refuse a stale port, never touch the purchase button — instead of trusting each future run to remember. The agent building its own harness for a workflow family is the intended endgame of codification, not scope creep.
|
|
72
|
+
|
|
73
|
+
## Mining guidance
|
|
74
|
+
|
|
75
|
+
- **Auth rides the browser.** If a mined endpoint needs cookies or CSRF tokens, call it *in-page* via `crtr capture page exec` (XHR/fetch runs with the session's credentials). Curl directly only when the request proved public — presigned URLs, tokenized links.
|
|
76
|
+
- **Capture the payload shape, not just the URL.** The HAR's post data is the schema documentation the vendor never published.
|
|
77
|
+
- **Pin targets for anything stateful.** Multiple Chromium apps expose CDP (Electron shells, Spotify, secondary browser profiles). Before mutating anything, verify you are in the right browser *and* the right identity — read a logged-in marker off the page, not just the tab title. `CDP_PORT`/`CDP_TARGET` pin the pair for every subsequent command.
|
|
78
|
+
- **Verify effects like the UI would.** After an upload or submission, re-read the page state that a human would check (the badge count dropped, the row disappeared). A 200 response is not confirmation.
|
|
79
|
+
|
|
80
|
+
## What good codification looks like
|
|
81
|
+
|
|
82
|
+
The test: a fresh agent, given only `/<name>`, completes the task in a fraction of the original time, refuses the same dangerous states the first derivation learned to refuse, and — when the vendor shipped a redesign overnight — degrades to hands-on capture work instead of failing, then leaves the codification better than it found it.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// REGRESSION: ctrl+o (expanded tool output) on a bash call whose command is
|
|
2
|
+
// wider than a narrow pane emitted the command line unclipped, so pi-tui's line
|
|
3
|
+
// accounting desynced from the terminal's own wrapping and the viewer crashed
|
|
4
|
+
// with "Rendered line N exceeds terminal width". Expanded bash now wraps into
|
|
5
|
+
// the pane instead of overhanging it.
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import { visibleWidth } from '@earendil-works/pi-tui';
|
|
9
|
+
import { createCrtrPlainBashToolDefinition } from '../render/tool-calls.js';
|
|
10
|
+
const theme = {
|
|
11
|
+
fg(_name, text) { return text; },
|
|
12
|
+
bg(_name, text) { return text; },
|
|
13
|
+
bold(text) { return text; },
|
|
14
|
+
};
|
|
15
|
+
function renderExpanded(command, width, timeout) {
|
|
16
|
+
const def = createCrtrPlainBashToolDefinition('/tmp');
|
|
17
|
+
const component = def.renderCall({ command, timeout }, theme, { expanded: true, state: {} });
|
|
18
|
+
return component.render(width);
|
|
19
|
+
}
|
|
20
|
+
const COMMANDS = [
|
|
21
|
+
'ls -la /Users/silasrhyneer/Code/cli/crouter',
|
|
22
|
+
'grep -rn "averyveryverylongtokenwithnospacesatallxxxxxxxxxxxxxxxxxxxxxx" src/',
|
|
23
|
+
`first\nsecond ${'x'.repeat(120)}`,
|
|
24
|
+
];
|
|
25
|
+
test('expanded bash call never renders a line wider than the pane', () => {
|
|
26
|
+
for (const width of [80, 42, 20, 3, 2, 1]) {
|
|
27
|
+
for (const command of COMMANDS) {
|
|
28
|
+
for (const line of renderExpanded(command, width, 120)) {
|
|
29
|
+
assert.ok(visibleWidth(line) <= width, `width ${width}: line of ${visibleWidth(line)} cols for ${JSON.stringify(command)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
test('expanded bash wraps rather than clipping — the whole command survives', () => {
|
|
35
|
+
const command = 'ls -la /Users/silasrhyneer/Code/cli/crouter';
|
|
36
|
+
const joined = renderExpanded(command, 42).map((line) => line.trim()).join(' ');
|
|
37
|
+
assert.ok(joined.includes('/Users/silasrhyneer/Code/cli/crouter'), joined);
|
|
38
|
+
});
|
|
@@ -62,6 +62,26 @@ function assistantSnapshot(count, tag) {
|
|
|
62
62
|
}
|
|
63
63
|
const countOf = (children, ctor) => children.filter((c) => c instanceof ctor).length;
|
|
64
64
|
initTheme(undefined, false);
|
|
65
|
+
test('folded tools are separated from preceding assistant prose and thinking', async () => {
|
|
66
|
+
const tui = new TUI(fakeTerminal());
|
|
67
|
+
const container = new Container();
|
|
68
|
+
const view = new ChatView(tui, container, { hideThinking: false });
|
|
69
|
+
const internals = view;
|
|
70
|
+
internals.foldSettledTools = true;
|
|
71
|
+
await view.applySnapshot({
|
|
72
|
+
messages: [
|
|
73
|
+
{ role: 'assistant', content: [{ type: 'text', text: 'Prose.' }, { type: 'toolCall', id: 'text-tool', name: 'bash', arguments: { command: 'true' } }] },
|
|
74
|
+
{ role: 'toolResult', toolCallId: 'text-tool', toolName: 'bash', content: [{ type: 'text', text: '' }], isError: false },
|
|
75
|
+
{ role: 'assistant', content: [{ type: 'thinking', thinking: 'Think.' }, { type: 'toolCall', id: 'thinking-tool', name: 'bash', arguments: { command: 'true' } }] },
|
|
76
|
+
{ role: 'toolResult', toolCallId: 'thinking-tool', toolName: 'bash', content: [{ type: 'text', text: '' }], isError: false },
|
|
77
|
+
],
|
|
78
|
+
stats: {},
|
|
79
|
+
state: { isStreaming: false },
|
|
80
|
+
});
|
|
81
|
+
internals.applyToolDisplay();
|
|
82
|
+
const separators = internals.historyContainer.children.filter((child) => child.render(80).length === 1 && child.render(80)[0] === '');
|
|
83
|
+
assert.equal(separators.length, 2, 'both visible assistant blocks receive a folded-tool gap');
|
|
84
|
+
});
|
|
65
85
|
test('live bash animation survives history caching and stops on a welcome snapshot', async () => {
|
|
66
86
|
let renderRequests = 0;
|
|
67
87
|
const tui = {
|
|
@@ -51,5 +51,7 @@ test('the attach viewer covers crtr bash tool calls and crtr-output messages nat
|
|
|
51
51
|
assert.match(chatView, /CRTR_OUTPUT_CUSTOM_TYPE/);
|
|
52
52
|
assert.match(chatView, /new CrtrOutputMessageComponent\(/);
|
|
53
53
|
assert.match(chatView, /createCrtrBashToolDefinition\(args\)/);
|
|
54
|
-
|
|
54
|
+
// MinimizableToolComponent is the viewer's ToolExecutionComponent subclass (it
|
|
55
|
+
// adds the Ctrl+O minimized state); the crtr tool definition still threads through it.
|
|
56
|
+
assert.match(chatView, /new MinimizableToolComponent\([\s\S]*toolDefinition as never/);
|
|
55
57
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type BashJobStatus } from '../../../core/bash-jobs.js';
|
|
2
|
-
/** Compact cockpit row:
|
|
3
|
-
* `
|
|
4
|
-
|
|
2
|
+
/** Compact cockpit row: state first (matching the cron row), then the running
|
|
3
|
+
* command. `cmdCap` is the columns the caller has left for that command, so the
|
|
4
|
+
* cell shrinks with the pane instead of being clipped after the row's own key
|
|
5
|
+
* hints. `undefined` for an empty list — the caller omits the row entirely. */
|
|
6
|
+
export declare function bashJobsWaitSummary(jobs: readonly BashJobStatus[], nowMs?: number, cmdCap?: number): string | undefined;
|
|
@@ -4,20 +4,27 @@
|
|
|
4
4
|
// activeBackgroundBashJobs' job, in core/bash-jobs.ts). Enter on that row opens
|
|
5
5
|
// the Inspector's `jobs` section, which owns the drill-in rendering.
|
|
6
6
|
import { formatBashElapsed } from '../../../core/bash-jobs.js';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/** First line of `command`, whitespace-collapsed and capped to `cap` columns. A
|
|
8
|
+
* shell one-liner is cut on a word boundary when there is one near the cap, so
|
|
9
|
+
* the cell ends on a readable token instead of mid-`$((`. */
|
|
10
|
+
function cmdCell(command, cap) {
|
|
10
11
|
const oneLine = command.split('\n')[0].replace(/\s+/g, ' ').trim();
|
|
11
|
-
|
|
12
|
+
if (oneLine.length <= cap)
|
|
13
|
+
return oneLine;
|
|
14
|
+
const cut = oneLine.slice(0, Math.max(1, cap - 1));
|
|
15
|
+
const space = cut.lastIndexOf(' ');
|
|
16
|
+
return `${(space >= cap - 12 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
12
17
|
}
|
|
13
|
-
/** Compact cockpit row:
|
|
14
|
-
* `
|
|
15
|
-
|
|
18
|
+
/** Compact cockpit row: state first (matching the cron row), then the running
|
|
19
|
+
* command. `cmdCap` is the columns the caller has left for that command, so the
|
|
20
|
+
* cell shrinks with the pane instead of being clipped after the row's own key
|
|
21
|
+
* hints. `undefined` for an empty list — the caller omits the row entirely. */
|
|
22
|
+
export function bashJobsWaitSummary(jobs, nowMs = Date.now(), cmdCap = 26) {
|
|
16
23
|
if (jobs.length === 0)
|
|
17
24
|
return undefined;
|
|
18
25
|
if (jobs.length === 1) {
|
|
19
26
|
const job = jobs[0];
|
|
20
|
-
return
|
|
27
|
+
return `bash job · ${formatBashElapsed(nowMs - job.startedAtMs)} · ${cmdCell(job.command, Math.max(12, cmdCap))}`;
|
|
21
28
|
}
|
|
22
29
|
const oldest = jobs[0]; // activeBackgroundBashJobs returns oldest-first
|
|
23
30
|
return `${jobs.length} bash jobs · oldest ${formatBashElapsed(nowMs - oldest.startedAtMs)}`;
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// caller owns the Containers and the refresh trigger. beginFrameTelem() clears the
|
|
13
13
|
// nav-model telemetry cache for this one build pass so token/activity cells are
|
|
14
14
|
// read fresh for every chrome frame.
|
|
15
|
-
import { beginFrameTelem, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, YELLOW, RESET, DIM, GREEN, } from '../../../core/canvas/nav-render.js';
|
|
15
|
+
import { beginFrameTelem, navLabel, nodeGlyph, truncate, tokensCell, cycleBadge, askBadge, activityCell, fillWidth, YELLOW, RESET, DIM, GREEN, } from '../../../core/canvas/nav-render.js';
|
|
16
|
+
/** Columns the bash row spends on everything except the command itself:
|
|
17
|
+
* ` ⚙ ` + `bash job · ` + elapsed + ` · ` + ` · ↵ details`. */
|
|
18
|
+
const BASH_ROW_CHROME = 34;
|
|
16
19
|
import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
|
|
17
20
|
import { cronWaitSummary } from './cron-wait.js';
|
|
18
21
|
import { bashJobsWaitSummary } from './bash-jobs.js';
|
|
@@ -158,10 +161,14 @@ export async function buildCanvasPanelLines(nodeId, asks, palette, source, crons
|
|
|
158
161
|
items.push({ key: 'human', kind: 'human', line: ` ${YELLOW}⚑ waiting on human · ${selfAsks} pending${RESET}` });
|
|
159
162
|
// Bash wait row: attention (human) -> active-now (running background work) ->
|
|
160
163
|
// scheduled-future (armed crons). The leading glyph distinguishes the row type.
|
|
161
|
-
|
|
164
|
+
// `x stop` is advertised on the row because it is the roster's only
|
|
165
|
+
// destructive verb and nothing else would teach it. The command cell gets
|
|
166
|
+
// whatever the row's fixed decoration leaves, so the hints always survive.
|
|
167
|
+
const stopHint = jobs.length === 1 ? ' · x stop' : '';
|
|
168
|
+
const bashSummary = bashJobsWaitSummary(jobs, Date.now(), fillWidth() - (BASH_ROW_CHROME + stopHint.length));
|
|
162
169
|
if (bashSummary !== undefined) {
|
|
163
170
|
const gear = palette ? palette.info('⚙') : '⚙';
|
|
164
|
-
items.push({ key: 'bash', kind: 'bash', line: ` ${gear} ${muted(`${bashSummary} · ↵ details`)}${RESET}` });
|
|
171
|
+
items.push({ key: 'bash', kind: 'bash', line: ` ${gear} ${muted(`${bashSummary} · ↵ details${stopHint}`)}${RESET}` });
|
|
165
172
|
}
|
|
166
173
|
const cronSummary = cronWaitSummary(crons);
|
|
167
174
|
if (cronSummary !== undefined) {
|
|
@@ -5,8 +5,13 @@ export interface RosterHooks {
|
|
|
5
5
|
/** Live per-node human-ticket counts — read fresh on every rebuild, since the
|
|
6
6
|
* viewer's attention poll reassigns the map behind us. */
|
|
7
7
|
asks: () => Record<string, number>;
|
|
8
|
-
/** Transient footer notice (errors, "already fired", swap failures).
|
|
9
|
-
|
|
8
|
+
/** Transient footer notice (errors, "already fired", swap failures). A
|
|
9
|
+
* `sticky` notice holds until it is replaced or cleared — what the `x` stop
|
|
10
|
+
* confirmation needs, since the ordinary notice fades in three seconds and a
|
|
11
|
+
* question that vanished must not still be armed. */
|
|
12
|
+
setNotice: (msg: string, opts?: {
|
|
13
|
+
sticky?: boolean;
|
|
14
|
+
}) => void;
|
|
10
15
|
/** Ask the viewer to re-run the full chrome refresh. Detail popups call this
|
|
11
16
|
* on close so a wait row that resolved while the popup was open disappears. */
|
|
12
17
|
requestChromeRefresh: () => void;
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
// graph, so `refresh()` rebuilds the cached rows asynchronously while arrow
|
|
26
26
|
// movement only reverses the selected cached row and renders immediately.
|
|
27
27
|
import { execFile, execFileSync } from 'node:child_process';
|
|
28
|
-
import { Text, matchesKey } from '@earendil-works/pi-tui';
|
|
28
|
+
import { Text, decodeKittyPrintable, matchesKey } from '@earendil-works/pi-tui';
|
|
29
29
|
import { cliClient } from '../../../commands/api-client.js';
|
|
30
30
|
import { activeBackgroundBashJobs } from '../../../core/bash-jobs.js';
|
|
31
31
|
import { contextDir } from '../../../core/canvas/paths.js';
|
|
32
|
-
import { fillBar, fillWidth, REVERSE } from '../../../core/canvas/nav-render.js';
|
|
32
|
+
import { fillBar, fillWidth, stripSgr, REVERSE } from '../../../core/canvas/nav-render.js';
|
|
33
33
|
import { buildCanvasPanelLines } from './canvas-panels.js';
|
|
34
34
|
/** Seed pane focus from the pane's REAL active state, not an unconditional
|
|
35
35
|
* `true`. tmux only emits DECSET 1004 focus events on a TRANSITION, so a viewer
|
|
@@ -50,6 +50,8 @@ function initialPaneActive() {
|
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
/** How long an armed `x` waits for its `y`. */
|
|
54
|
+
const ARM_TIMEOUT_MS = 15_000;
|
|
53
55
|
export function createRoster(s, hooks) {
|
|
54
56
|
const { editor, tui, pal, nodeId, remote, role, canvasSource } = s;
|
|
55
57
|
const reports = s.containers.reports;
|
|
@@ -61,6 +63,12 @@ export function createRoster(s, hooks) {
|
|
|
61
63
|
if (autoSelect !== undefined)
|
|
62
64
|
delete process.env['CRTR_ATTACH_ROSTER'];
|
|
63
65
|
let paneFocused = initialPaneActive();
|
|
66
|
+
// Armed job cancellation: `x` on the bash row names the job, `y` confirms.
|
|
67
|
+
// Held here (not in the item list) so any other key, a selection move, or a
|
|
68
|
+
// rebuild that retires the job drops it — a stale confirmation must never
|
|
69
|
+
// kill a job the user is no longer looking at.
|
|
70
|
+
let pendingKill;
|
|
71
|
+
let killTimer;
|
|
64
72
|
const syncCursorDim = () => {
|
|
65
73
|
editor.paneFocused = paneFocused && selKey === undefined;
|
|
66
74
|
};
|
|
@@ -69,7 +77,10 @@ export function createRoster(s, hooks) {
|
|
|
69
77
|
reports.clear();
|
|
70
78
|
for (const item of items) {
|
|
71
79
|
const selected = item.key === selKey;
|
|
72
|
-
|
|
80
|
+
// Selected rows highlight as ONE uniform bar: strip the row's own colours
|
|
81
|
+
// first, because reverse-video turns every tinted span into a coloured
|
|
82
|
+
// block and the bar reads as a patchwork otherwise.
|
|
83
|
+
reports.addChild(new Text(selected ? fillBar(stripSgr(item.line), fillWidth(), REVERSE) : item.line, 1, 0));
|
|
73
84
|
}
|
|
74
85
|
tui.requestRender();
|
|
75
86
|
};
|
|
@@ -96,14 +107,73 @@ export function createRoster(s, hooks) {
|
|
|
96
107
|
if (selKey !== undefined && !items.some((item) => item.key === selKey)) {
|
|
97
108
|
selKey = undefined;
|
|
98
109
|
}
|
|
110
|
+
if (pendingKill !== undefined && !bashJobs.some((job) => job.jobId === pendingKill?.jobId))
|
|
111
|
+
disarmKill(true);
|
|
99
112
|
paint();
|
|
100
113
|
};
|
|
114
|
+
/** Drop an armed stop, and the question with it. */
|
|
115
|
+
const disarmKill = (clearNotice) => {
|
|
116
|
+
if (killTimer !== undefined) {
|
|
117
|
+
clearTimeout(killTimer);
|
|
118
|
+
killTimer = undefined;
|
|
119
|
+
}
|
|
120
|
+
if (pendingKill === undefined)
|
|
121
|
+
return;
|
|
122
|
+
pendingKill = undefined;
|
|
123
|
+
if (clearNotice)
|
|
124
|
+
hooks.setNotice('');
|
|
125
|
+
};
|
|
101
126
|
const exit = () => {
|
|
127
|
+
disarmKill(true);
|
|
102
128
|
if (selKey !== undefined) {
|
|
103
129
|
selKey = undefined;
|
|
104
130
|
paint();
|
|
105
131
|
}
|
|
106
132
|
};
|
|
133
|
+
/** The printable character a keystroke carries, under both plain and Kitty
|
|
134
|
+
* encodings — the roster's own `x`/`y` verbs are letters, not chords. */
|
|
135
|
+
const typed = (data) => decodeKittyPrintable(data) ?? (data.length === 1 ? data : undefined);
|
|
136
|
+
/** First line of a command, for the confirmation prompt. */
|
|
137
|
+
const commandHead = (command, cap = 40) => {
|
|
138
|
+
const line = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
|
|
139
|
+
return line.length > cap ? `${line.slice(0, cap - 1)}…` : line;
|
|
140
|
+
};
|
|
141
|
+
/** `x` on the bash row: arm cancellation for the one live job. With several
|
|
142
|
+
* jobs there is no single target on a summary row, so send the user into the
|
|
143
|
+
* Inspector, where each job is separately selectable. */
|
|
144
|
+
const armKill = () => {
|
|
145
|
+
if (role !== 'controller') {
|
|
146
|
+
hooks.setNotice('Read-only observer attach — cannot stop background jobs');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const jobs = activeBackgroundBashJobs(contextDir(nodeId));
|
|
150
|
+
if (jobs.length === 0) {
|
|
151
|
+
hooks.setNotice('Background job already finished');
|
|
152
|
+
hooks.requestChromeRefresh();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (jobs.length > 1) {
|
|
156
|
+
hooks.setNotice(`${jobs.length} background jobs — press ↵ to pick one in the Inspector`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const job = jobs[0];
|
|
160
|
+
pendingKill = { jobId: job.jobId, label: commandHead(job.command) };
|
|
161
|
+
hooks.setNotice(`Stop “${pendingKill.label}”? Press y to confirm.`, { sticky: true });
|
|
162
|
+
// An unanswered question expires rather than sitting armed forever — a `y`
|
|
163
|
+
// typed minutes later belongs to whatever the user is doing then.
|
|
164
|
+
killTimer = setTimeout(() => disarmKill(true), ARM_TIMEOUT_MS);
|
|
165
|
+
};
|
|
166
|
+
const confirmKill = () => {
|
|
167
|
+
const target = pendingKill;
|
|
168
|
+
if (target === undefined)
|
|
169
|
+
return;
|
|
170
|
+
disarmKill(false);
|
|
171
|
+
hooks.setNotice(`Stopping “${target.label}”…`);
|
|
172
|
+
execFile('crtr', ['node', 'bash', 'kill', target.jobId, '--node', nodeId], (err) => {
|
|
173
|
+
hooks.setNotice(err === null ? `Stopped “${target.label}”.` : `Could not stop the job: ${err.message.split('\n')[0]}`);
|
|
174
|
+
hooks.requestChromeRefresh();
|
|
175
|
+
});
|
|
176
|
+
};
|
|
107
177
|
editor.onCursorDownAtBottom = remote ? undefined : () => {
|
|
108
178
|
if (selKey !== undefined || items.length === 0)
|
|
109
179
|
return;
|
|
@@ -172,6 +242,20 @@ export function createRoster(s, hooks) {
|
|
|
172
242
|
// Roster active. Explicit boundaries on the single list: ↑ off the TOP row
|
|
173
243
|
// returns to the editor; ↓ CLAMPS at the bottom row.
|
|
174
244
|
const i = items.findIndex((item) => item.key === selKey);
|
|
245
|
+
const selected = items[i];
|
|
246
|
+
// Confirmation first: while a kill is armed, `y` fires it and every other
|
|
247
|
+
// key disarms it before taking its normal meaning.
|
|
248
|
+
if (pendingKill !== undefined) {
|
|
249
|
+
if (typed(data) === 'y') {
|
|
250
|
+
confirmKill();
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
disarmKill(true);
|
|
254
|
+
}
|
|
255
|
+
if (selected?.kind === 'bash' && typed(data) === 'x') {
|
|
256
|
+
armKill();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
175
259
|
if (up) {
|
|
176
260
|
if (i <= 0)
|
|
177
261
|
exit();
|
|
@@ -48,6 +48,9 @@ export interface InputControllerHooks {
|
|
|
48
48
|
/** OPTIONAL: Ctrl+O / app.tools.expand — flip global tool-output expansion in
|
|
49
49
|
* the render layer (ChatView owns the components). Absent → the key no-ops. */
|
|
50
50
|
onToggleToolsExpand?: () => void;
|
|
51
|
+
/** OPTIONAL: `/fold-tools` — flip the "fold finished tool calls" view setting.
|
|
52
|
+
* Absent → the command notifies it is unwired. */
|
|
53
|
+
onFoldTools?: () => void;
|
|
51
54
|
/** OPTIONAL: Ctrl+T / app.thinking.toggle — flip thinking-block visibility in
|
|
52
55
|
* the render layer. Absent → the key no-ops. */
|
|
53
56
|
onToggleThinking?: () => void;
|
|
@@ -244,6 +244,7 @@ export class InputController {
|
|
|
244
244
|
onCopy: this.hooks.onCopy,
|
|
245
245
|
onColor: this.hooks.onColor,
|
|
246
246
|
onNodeMetadata: this.hooks.onNodeMetadata,
|
|
247
|
+
onFoldTools: this.hooks.onFoldTools,
|
|
247
248
|
openModelPicker: () => void this.openModelPicker(),
|
|
248
249
|
openSessionPicker: () => void this.openSessionPicker(),
|
|
249
250
|
openForkPicker: () => void this.openForkPicker(),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { AssistantMessageComponent } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
type AssistantMessageLike = Parameters<AssistantMessageComponent['updateContent']>[0];
|
|
3
|
+
export declare class ViewerAssistantMessageComponent extends AssistantMessageComponent {
|
|
4
|
+
/** The message as it arrived, thinking parts intact. */
|
|
5
|
+
private original;
|
|
6
|
+
/** The stripped copy last handed to the base class, for identity comparison. */
|
|
7
|
+
private stripped;
|
|
8
|
+
private hidden;
|
|
9
|
+
constructor(message: AssistantMessageLike | undefined, hideThinkingBlock?: boolean, markdownTheme?: ConstructorParameters<typeof AssistantMessageComponent>[2], hiddenThinkingLabel?: string);
|
|
10
|
+
updateContent(message: AssistantMessageLike): void;
|
|
11
|
+
setHideThinkingBlock(hide: boolean): void;
|
|
12
|
+
}
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// assistant-message.ts — the viewer's assistant message component.
|
|
2
|
+
//
|
|
3
|
+
// pi's AssistantMessageComponent renders a hidden thinking run as a one-line
|
|
4
|
+
// `Thinking...` placeholder. In the viewer, thinking is hidden by DEFAULT
|
|
5
|
+
// (viewer.ts), so that placeholder is the steady state: a transcript of real work
|
|
6
|
+
// ends up striped with italic `Thinking...` lines nobody asked to see. Hidden here
|
|
7
|
+
// means gone — the run renders nothing at all.
|
|
8
|
+
//
|
|
9
|
+
// The content is not lost: the original message is retained, so Ctrl+T
|
|
10
|
+
// (`setHideThinkingBlock(false)`) re-renders the real thinking text in place.
|
|
11
|
+
import { AssistantMessageComponent } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
/** Same message with its `thinking` parts removed, so the base component sees a
|
|
13
|
+
* message that simply has no thinking run to label. */
|
|
14
|
+
function withoutThinking(message) {
|
|
15
|
+
const content = message.content;
|
|
16
|
+
if (!Array.isArray(content) || !content.some((part) => part?.type === 'thinking'))
|
|
17
|
+
return message;
|
|
18
|
+
return { ...message, content: content.filter((part) => part?.type !== 'thinking') };
|
|
19
|
+
}
|
|
20
|
+
export class ViewerAssistantMessageComponent extends AssistantMessageComponent {
|
|
21
|
+
/** The message as it arrived, thinking parts intact. */
|
|
22
|
+
original;
|
|
23
|
+
/** The stripped copy last handed to the base class, for identity comparison. */
|
|
24
|
+
stripped;
|
|
25
|
+
hidden;
|
|
26
|
+
constructor(message, hideThinkingBlock = false, markdownTheme, hiddenThinkingLabel) {
|
|
27
|
+
// Defer content to our own updateContent so stripping applies to it too.
|
|
28
|
+
super(undefined, hideThinkingBlock, markdownTheme, hiddenThinkingLabel);
|
|
29
|
+
this.hidden = hideThinkingBlock;
|
|
30
|
+
if (message)
|
|
31
|
+
this.updateContent(message);
|
|
32
|
+
}
|
|
33
|
+
updateContent(message) {
|
|
34
|
+
// The base class re-renders by calling this with ITS last message (invalidate,
|
|
35
|
+
// setHideThinkingBlock) — that copy is the stripped one, so only a genuinely
|
|
36
|
+
// new message replaces the original we keep for un-hiding.
|
|
37
|
+
if (message !== this.stripped)
|
|
38
|
+
this.original = message;
|
|
39
|
+
const source = this.original ?? message;
|
|
40
|
+
if (this.hidden) {
|
|
41
|
+
this.stripped = withoutThinking(source);
|
|
42
|
+
super.updateContent(this.stripped);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
this.stripped = undefined;
|
|
46
|
+
super.updateContent(source);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
setHideThinkingBlock(hide) {
|
|
50
|
+
this.hidden = hide;
|
|
51
|
+
// Base implementation flips its own flag and re-renders through
|
|
52
|
+
// `this.updateContent`, which lands on the override above.
|
|
53
|
+
super.setHideThinkingBlock(hide);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -59,7 +59,12 @@ export declare class ChatView {
|
|
|
59
59
|
private readonly imageWidthCells;
|
|
60
60
|
private hideThinking;
|
|
61
61
|
private readonly hiddenThinkingLabel;
|
|
62
|
+
/** Ctrl+O: pi's two-state tool-output expansion. */
|
|
62
63
|
private toolOutputExpanded;
|
|
64
|
+
/** The view setting (Alt+C → w → f): fold every SETTLED tool call to its single
|
|
65
|
+
* call line. Independent of `toolOutputExpanded` — it survives an expand/collapse
|
|
66
|
+
* and only ever applies to tool calls that are done. */
|
|
67
|
+
private foldSettledTools;
|
|
63
68
|
private readonly onFooterEvent;
|
|
64
69
|
/** Theme-derived colorizers for the activity/status area. */
|
|
65
70
|
private readonly spinnerStyle;
|
|
@@ -70,6 +75,12 @@ export declare class ChatView {
|
|
|
70
75
|
/** The assistant message component currently being streamed (between
|
|
71
76
|
* message_start and message_end for an assistant turn). */
|
|
72
77
|
private streamingComponent;
|
|
78
|
+
/** The separator immediately after the live assistant component. It becomes
|
|
79
|
+
* visible when that response has visible prose or thinking and folded tools. */
|
|
80
|
+
private streamingToolSeparator;
|
|
81
|
+
/** Content classes for each separator, so Ctrl+T can add/remove thinking gaps
|
|
82
|
+
* without rebuilding the transcript. */
|
|
83
|
+
private readonly foldedToolSeparatorContents;
|
|
73
84
|
/** Tool-call id → its execution component, for the duration of the call. */
|
|
74
85
|
private readonly pendingTools;
|
|
75
86
|
/** The `!` bash component currently streaming (between bash_start and bash_end). */
|
|
@@ -133,6 +144,13 @@ export declare class ChatView {
|
|
|
133
144
|
* the caller can surface a notice. Condensed cycles hold no tool components and
|
|
134
145
|
* are frozen as text, so they are unaffected by design. */
|
|
135
146
|
toggleToolsExpanded(): boolean;
|
|
147
|
+
/** The `fold finished tool calls` view setting (`/fold-tools`, Alt+C → w → f).
|
|
148
|
+
* Returns the new state so the caller can surface a notice. */
|
|
149
|
+
toggleFoldSettledTools(): boolean;
|
|
150
|
+
/** Push both display settings onto every rendered component. A tool call still in
|
|
151
|
+
* `pendingTools` is in flight, so it is never folded — you keep watching live
|
|
152
|
+
* output, and it folds on its own once `tool_execution_end` settles it. */
|
|
153
|
+
private applyToolDisplay;
|
|
136
154
|
/** Ctrl+T / app.thinking.toggle — flip thinking-block visibility and apply it to
|
|
137
155
|
* every assistant message (the in-flight streaming bubble is itself a child of
|
|
138
156
|
* the container, so the loop covers it too). Returns the new HIDDEN state. */
|
|
@@ -147,6 +165,17 @@ export declare class ChatView {
|
|
|
147
165
|
fullOutputPath?: string;
|
|
148
166
|
}): void;
|
|
149
167
|
private addMessageToChat;
|
|
168
|
+
/** Render an assistant reply plus its initially-hidden boundary separator. */
|
|
169
|
+
private appendAssistantMessage;
|
|
170
|
+
/** Add a separator in transcript order now; it renders only after a following
|
|
171
|
+
* tool settles in the folded view. */
|
|
172
|
+
private appendFoldedToolSeparator;
|
|
173
|
+
/** Make each separator visible only if its immediately-following tool call is
|
|
174
|
+
* actually folded; in-flight calls remain flush with their live output. */
|
|
175
|
+
private updateFoldedToolSeparators;
|
|
176
|
+
/** Track whether an assistant response contains prose or thinking, then make
|
|
177
|
+
* its separator eligible only for content the current Ctrl+T setting shows. */
|
|
178
|
+
private setAssistantSeparatorContent;
|
|
150
179
|
/** Tool component with native viewer renderers for crouter-owned tool output. */
|
|
151
180
|
private makeToolComponent;
|
|
152
181
|
/** Append conversation content above the independent live status area — or into
|