dsh-generative-ui 0.0.2 → 0.0.4
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 +9 -2
- package/lib/client.js +791 -398
- package/lib/client.js.map +19 -17
- package/lib/index.js +534 -44
- package/lib/types/card-failure.d.ts +88 -0
- package/lib/types/client/canvas/CanvasPanel.d.ts +1 -1
- package/lib/types/client/canvas/index.d.ts +1 -1
- package/lib/types/client/runtime/GenUISurface.d.ts +65 -35
- package/lib/types/client/runtime/inline-fence.d.ts +33 -2
- package/lib/types/client/runtime/report-error.d.ts +59 -12
- package/lib/types/contract-assets.d.ts +10 -0
- package/lib/types/index.d.ts +51 -8
- package/package.json +44 -25
- package/src/card-failure.ts +106 -0
- package/src/client/canvas/CanvasPanel.tsx +10 -4
- package/src/client/canvas/index.ts +1 -1
- package/src/client/index.ts +26 -10
- package/src/client/runtime/GenUISurface.tsx +172 -48
- package/src/client/runtime/bindings.ts +3 -2
- package/src/client/runtime/compiler.ts +24 -2
- package/src/client/runtime/inline-fence.ts +195 -10
- package/src/client/runtime/register.ts +16 -4
- package/src/client/runtime/report-error.ts +89 -34
- package/src/client/runtime/state.ts +13 -1
- package/src/client/runtime/uno-config.ts +13 -0
- package/src/client/session.ts +10 -3
- package/src/contract-assets.ts +11 -0
- package/src/index.ts +123 -21
- package/src/prompt.ts +154 -10
- package/src/skill.ts +296 -22
- package/types/fs.d.ts +15 -1
package/src/index.ts
CHANGED
|
@@ -17,39 +17,67 @@ import { createRequire } from "node:module";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import type { Context } from "@deepseek-ai/cordis";
|
|
19
19
|
import z from "@deepseek-ai/schemastery";
|
|
20
|
-
import {
|
|
20
|
+
import type {} from "@deepseek-ai/dsh-settings";
|
|
21
21
|
import type {} from "@deepseek-ai/dsh-host-webserver";
|
|
22
22
|
import type {} from "@deepseek-ai/dsh-system-prompt";
|
|
23
23
|
import type {} from "@deepseek-ai/dsh-skill";
|
|
24
24
|
// A value import, unlike the others: `llm.stream` rejects a plain `{role, content}` object,
|
|
25
25
|
// and this is the constructor that stamps the identity and source tags it requires.
|
|
26
26
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
27
|
-
import { AI_STREAM_PATH, ASSET_PREFIX, CANVAS_READ_PATH, EXEC_PATH, FS_PATH, WASM_PATH, WEB_SEARCH_PATH } from "./contract-assets.ts";
|
|
27
|
+
import { AI_STREAM_PATH, ASSET_PREFIX, CANVAS_READ_PATH, CARD_ERROR_PATH, EXEC_PATH, FS_PATH, WASM_PATH, WEB_SEARCH_PATH } from "./contract-assets.ts";
|
|
28
28
|
import { CANVAS_DIR, canvasChildPath, canvasIdOf, canvasPath, isCanvasId } from "./contract.ts";
|
|
29
|
+
import { CardFailures, CARD_FAILURE_CONTEXT, CARD_FAILURE_CONTEXT_ORDER, WAKE_SUMMARY, WAKE_TEXT } from "./card-failure.ts";
|
|
29
30
|
import { inlinePrompt, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER } from "./prompt.ts";
|
|
30
31
|
import { skillBody, SKILL_DESCRIPTION, SKILL_NAME } from "./skill.ts";
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The one field of the assembling agent this plugin reads: its id, which IS the session id.
|
|
35
|
+
*
|
|
36
|
+
* Declared here rather than by importing `@deepseek-ai/dsh-agent`, which owns the real
|
|
37
|
+
* augmentation. That package also augments cordis `Context` with the HOST's `sessions` service,
|
|
38
|
+
* and the augmentation is global — pulling it in retyped `ctx.sessions` inside `src/client/`,
|
|
39
|
+
* where the session store is the browser runtime's and has `getSnapshot`/`binding` instead.
|
|
40
|
+
* Six type errors in files this change does not touch. One optional field is the whole
|
|
41
|
+
* dependency, so it is cheaper to state it than to import the package that carries it.
|
|
42
|
+
*/
|
|
43
|
+
declare module "@deepseek-ai/dsh-system-prompt" {
|
|
44
|
+
interface AssembleContext {
|
|
45
|
+
agent?: { readonly id: string };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
export const name = "dsh-generative-ui";
|
|
33
50
|
export const inject = ["systemPrompt"];
|
|
34
51
|
|
|
35
52
|
/** The settings section this plugin owns; the key under `dsh-generative-ui:` in settings.yaml. */
|
|
36
|
-
export const SETTINGS_NAMESPACE =
|
|
53
|
+
export const SETTINGS_NAMESPACE = name;
|
|
37
54
|
|
|
38
55
|
/**
|
|
39
56
|
* Plugin settings. A schemastery schema, not a TypeScript type: the host validates the
|
|
40
57
|
* `settings.yaml` section against it and builds the settings UI from it, so a plain interface
|
|
41
58
|
* would be a switch nobody can find and nobody can check.
|
|
42
59
|
*
|
|
43
|
-
* `allowExec` is
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
60
|
+
* `allowExec` is ON by default, and the trade it makes is worth stating rather than assuming.
|
|
61
|
+
*
|
|
62
|
+
* What it is NOT: an escape from the fence. The route resolves `ctx.sandboxPolicy` for the session
|
|
63
|
+
* and hands it to `ctx.shell`, so a card's command opens nothing the agent's own bash has not
|
|
64
|
+
* already opened, in a workdir pinned to a live session's workspace, killed after 15s and on the
|
|
65
|
+
* reader closing the page.
|
|
66
|
+
*
|
|
67
|
+
* What it IS: the approval layer does not reach here. `approval.request()` needs an open turn and
|
|
68
|
+
* an agent, and a card fires on a reader's keystroke long after its turn ended — so the per-command
|
|
69
|
+
* fence is the sandbox policy alone. Under `workspace-write` a card can therefore delete inside the
|
|
70
|
+
* workspace with nobody agreeing to it command by command. The prompt answers that where it can
|
|
71
|
+
* ("observe, never change"; anything destructive belongs in a `sendMessage` the user agrees to),
|
|
72
|
+
* which is guidance, not a fence.
|
|
73
|
+
*
|
|
74
|
+
* Turned on because the capability it gates is the ordinary case, not the exotic one: search
|
|
75
|
+
* (`fd`, `rg`) that no `$dsh/fs` call expresses, `lint` / `check` / test runs whose output IS the
|
|
76
|
+
* card, `git log`, and the twenty-`readdir` walks a single `ls -R` replaces. Off, a model reasoning
|
|
77
|
+
* from a five-capability set writes those as file-by-file loops or does not write the card at all.
|
|
50
78
|
*/
|
|
51
79
|
export const Config = z.object({
|
|
52
|
-
allowExec: z.boolean().default(
|
|
80
|
+
allowExec: z.boolean().default(true).description("Let generated cards run shell commands through `$dsh/exec`, under this session's own sandbox mode. Cards use it to search (`rg`, `fd`), run `lint`/`check`, and read `git`. The sandbox still applies; what does not is the per-command approval prompt, so turn this off for a session where that matters."),
|
|
53
81
|
});
|
|
54
82
|
|
|
55
83
|
export type Config = ReturnType<typeof Config>;
|
|
@@ -513,16 +541,15 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
|
|
513
541
|
void configured?.fiber.dispose();
|
|
514
542
|
configured = { allowExec, fiber: ctx.plugin({ name: "dsh-generative-ui:configured", apply: (scoped: Context) => applyWith(scoped, allowExec) }) };
|
|
515
543
|
};
|
|
516
|
-
//
|
|
517
|
-
// `installSettingsSection` sits inside `ctx.inject(["settings"])`, so on a host with no settings
|
|
518
|
-
// service — `dsh --profile headless` is one — `onChange` never fires at all and nothing would
|
|
519
|
-
// ever mount. The `mounted` check above is what keeps this from double-mounting where it does.
|
|
544
|
+
// 无 settings 服务时下面的 inject 不执行,必须先用 entry 配置挂载;rebuild 内的值比较避免重复挂载。
|
|
520
545
|
rebuild();
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
546
|
+
ctx.inject(["settings"], (sctx) => {
|
|
547
|
+
sctx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
548
|
+
setSource: (source) => {
|
|
549
|
+
current = source;
|
|
550
|
+
},
|
|
551
|
+
onChange: rebuild,
|
|
552
|
+
});
|
|
526
553
|
});
|
|
527
554
|
ctx.effect(() => () => void configured?.fiber.dispose(), "dsh-generative-ui: settings scope");
|
|
528
555
|
}
|
|
@@ -533,6 +560,27 @@ function applyWith(ctx: Context, allowExec: boolean): void {
|
|
|
533
560
|
// teaches the model to write cards that cannot work — and the failure surfaces to the user as a
|
|
534
561
|
// dead card, not as a disabled feature.
|
|
535
562
|
ctx.effect(() => ctx.systemPrompt.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text: inlinePrompt(allowExec) }), "dsh-generative-ui: inline prompt");
|
|
563
|
+
// A card that will not render, as CONTEXT rather than as a message — see `card-failure.ts`.
|
|
564
|
+
// The provider runs per assembly, so an empty string is how a fixed card stops being mentioned;
|
|
565
|
+
// `agent.id` is the session id, which is what the browser half keys its reports on.
|
|
566
|
+
const failures = new CardFailures();
|
|
567
|
+
ctx.effect(() => ctx.systemPrompt.context({ name: CARD_FAILURE_CONTEXT, order: CARD_FAILURE_CONTEXT_ORDER, text: (assembly) => failures.text(assembly.agent?.id) }), "dsh-generative-ui: card failure context");
|
|
568
|
+
// The nudge needs the agent registry, which a diagnostic or headless composition may not have.
|
|
569
|
+
// Scoped, like every other capability here: without it the failure still reaches the model, just
|
|
570
|
+
// on the next turn the user starts rather than on one of its own.
|
|
571
|
+
let wake: ((session: string) => void) | null = null;
|
|
572
|
+
ctx.inject(["agents"], (withAgents) => {
|
|
573
|
+
wake = (session) => {
|
|
574
|
+
const agent = (withAgents as unknown as { agents: { get: (id: string) => { followup: (message: ReturnType<typeof createUserMessage>) => void } | undefined } }).agents.get(session);
|
|
575
|
+
// `source.kind` is what the transcript renders on: anything other than `"user"` is
|
|
576
|
+
// classified as injected context rather than a chat bubble, which is why this can wake the
|
|
577
|
+
// model without putting words in the reader's mouth. `form: "notice"` is the presentation.
|
|
578
|
+
agent?.followup(createUserMessage({ content: [{ type: "text", text: WAKE_TEXT }], source: { kind: "plugin", plugin: "dsh-generative-ui", form: "notice", summary: WAKE_SUMMARY } }));
|
|
579
|
+
};
|
|
580
|
+
return () => {
|
|
581
|
+
wake = null;
|
|
582
|
+
};
|
|
583
|
+
});
|
|
536
584
|
// Both routes only matter to a browser half that exists to consume them. Scoped rather than
|
|
537
585
|
// required so the plugin still teaches the model on a profile with no web server at all —
|
|
538
586
|
// `dsh --profile headless` has no `webServer`, and a required injection there means the
|
|
@@ -551,6 +599,10 @@ function applyWith(ctx: Context, allowExec: boolean): void {
|
|
|
551
599
|
};
|
|
552
600
|
scoped.effect(() => scoped.webServer.register({ kind: "prefix", path: ASSET_PREFIX, handler: (req, res) => serveAsset(req, res, file) }), "dsh-generative-ui: tsx wasm");
|
|
553
601
|
scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CANVAS_READ_PATH, handler: (req, res) => serveCanvas(liveWorkspaces, req, res) }), "dsh-generative-ui: canvas reads");
|
|
602
|
+
// Beside the canvas route rather than under `fs`/`shell`/`llm`: reporting a broken card needs
|
|
603
|
+
// nothing but a web server, and it is worth the least on the compositions that have the most
|
|
604
|
+
// missing — a card whose capability module is absent is exactly the card that fails.
|
|
605
|
+
scoped.effect(() => scoped.webServer.register({ kind: "exact", path: CARD_ERROR_PATH, handler: (req, res) => serveCardError(failures, () => wake, req, res) }), "dsh-generative-ui: card failures");
|
|
554
606
|
// One level deeper again: a deployment can mount a web server without an LLM runtime, and
|
|
555
607
|
// losing `$dsh/ai` there should not take the wasm and canvas routes down with it.
|
|
556
608
|
// Same shape again: a deployment can serve the web without a sandboxed filesystem, and
|
|
@@ -578,6 +630,56 @@ function applyWith(ctx: Context, allowExec: boolean): void {
|
|
|
578
630
|
// subsystem is disabled. Nested, only the skill goes missing.
|
|
579
631
|
// Model-only: `/generative-ui` as a user command would just print the guidance at the user.
|
|
580
632
|
ctx.inject(["skills"], (scoped) => {
|
|
581
|
-
scoped.effect(() => scoped.skills.register({ name: SKILL_NAME, description: SKILL_DESCRIPTION,
|
|
633
|
+
scoped.effect(() => scoped.skills.register({ name: SKILL_NAME, description: SKILL_DESCRIPTION, // `allowExec` is the third argument and was omitted, so the skill dropped its whole
|
|
634
|
+
// "Running a command" section even where the route IS registered: the prompt said six
|
|
635
|
+
// capabilities and the skill described five. Same rule as the prompt — the docs have to
|
|
636
|
+
// name the set that exists, in both directions.
|
|
637
|
+
content: skillBody(typesImportMap(import.meta.url), standaloneImportMap(import.meta.url), allowExec), source: "runtime", invocation: { modelInvocable: true, userInvocable: false } }), "dsh-generative-ui: skill");
|
|
582
638
|
});
|
|
583
639
|
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Record or clear one session's failing card, and wake the model when the failure is news.
|
|
643
|
+
*
|
|
644
|
+
* `POST ?session=<id>` with `{message, phase}` to set it and `{}` to clear it. The detail never
|
|
645
|
+
* comes back as a chat message — it becomes the `ui4a:card-failure` runtime context, which the
|
|
646
|
+
* assembly re-reads each step. See `card-failure.ts` for why the two halves are split.
|
|
647
|
+
*
|
|
648
|
+
* The wake is looked up per request rather than captured: the agent registry is scoped, so the
|
|
649
|
+
* function it hands out can go away while this route stays up, and a stale capture would call
|
|
650
|
+
* into a disposed fiber.
|
|
651
|
+
*
|
|
652
|
+
* Exported for `test/card-error-route.test.ts`.
|
|
653
|
+
*/
|
|
654
|
+
export async function serveCardError(failures: CardFailures, wake: () => ((session: string) => void) | null, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
655
|
+
if (req.method !== "POST") return void res.writeHead(405).end();
|
|
656
|
+
const url = new URL(req.url ?? "", "http://localhost");
|
|
657
|
+
const session = url.searchParams.get("session");
|
|
658
|
+
if (session === null || session === "") return void res.writeHead(400).end();
|
|
659
|
+
|
|
660
|
+
let body = "";
|
|
661
|
+
for await (const chunk of req) {
|
|
662
|
+
body += chunk as string;
|
|
663
|
+
if (body.length > MAX_BODY) return void res.writeHead(413).end();
|
|
664
|
+
}
|
|
665
|
+
let report: { message?: string; phase?: string };
|
|
666
|
+
try {
|
|
667
|
+
report = JSON.parse(body) as typeof report;
|
|
668
|
+
} catch {
|
|
669
|
+
return void res.writeHead(400).end();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// No message means the card recovered. Clearing is the whole point of routing this through
|
|
673
|
+
// state instead of the transcript, so it is not an afterthought: without it the model keeps
|
|
674
|
+
// reading about a card that has been fine for ten turns.
|
|
675
|
+
if (report.message === undefined || report.message === "") {
|
|
676
|
+
failures.clear(session);
|
|
677
|
+
} else if (failures.set(session, { message: report.message, phase: report.phase ?? "compile" })) {
|
|
678
|
+
// Only when the session went from healthy to failing. A settled card that fails re-renders on
|
|
679
|
+
// every later frame of the transcript, and a turn per render is a loop the reader has to kill
|
|
680
|
+
// — and a SECOND failure while the first is still open needs no nudge of its own, because the
|
|
681
|
+
// notice says nothing but "read the context" and the context already holds the newer message.
|
|
682
|
+
wake()?.(session);
|
|
683
|
+
}
|
|
684
|
+
res.writeHead(204).end();
|
|
685
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -57,7 +57,9 @@ export default function Answer() {
|
|
|
57
57
|
- **The info string is \`${FENCE_LANG}\`, never \`tsx\`.** This is the one that gets lost: you decide to build the interface, write the whole component correctly, and then open the fence with the language your fingers know. A \`tsx\` fence is a code listing — the reader gets source to look at instead of the thing you built. Check the opening line before you write the body.
|
|
58
58
|
- The module must \`export default\` a component taking no props.
|
|
59
59
|
- **Never name it after something you imported.** \`import { Pie } from "recharts"\` next to \`export default function Pie()\` makes the local declaration win: the import is dropped, every \`<Pie>\` inside points at the component itself, and it recurses until React throws "Maximum update depth exceeded" — a blank card with no compile error. Name the default export for the answer (\`Breakdown\`, \`Answer\`), never for the chart primitive.
|
|
60
|
-
- \`import\` React and anything else you need; bare specifiers resolve from npm automatically.
|
|
60
|
+
- \`import\` React and anything else you need; bare specifiers resolve from npm automatically. **Any package, not a short list** — \`@headlessui/react\` or \`@radix-ui/react-*\` for switches, tabs, modals and disclosures that already handle focus and the keyboard, \`shiki\` to syntax-highlight code or a diff (**never \`@monaco-editor/react\` for code the reader only READS** — measured, a card that did spent 22s of worker time analysing code nobody edited and took 132ms per tab switch against 4ms for its other buttons), \`recharts\` for charts, \`lucide-react\` for icons. There is no install step and no allowlist, so hand-rolling a component to avoid an import is a worse component you now own.
|
|
61
|
+
|
|
62
|
+
**Four widgets are never hand-written here, because hand-writing them silently drops the keyboard.** A row of tabs, a collapsible section, a dropdown or select, a modal. Written by hand they look finished and are not: measured, a real reader clicked through a hand-rolled tab strip and asked *"我以为用了的话就能有左右方向键来切换 tab 的功能呢"* — arrow-key roving, Home/End, focus returning where it came from, \`aria-selected\` following the panel. That is a day of work in a library and a \`useState\` away from wrong by hand. **\`@radix-ui/react-tabs\` is the one with arrow keys**; \`@headlessui/react\`'s \`Tab\` does not rove. Reach for the import the moment you type the state that switches between them, not after someone notices. **The hand-rolled ones do not feel like a decision** — measured across 125 cards, \`@headlessui/react\` was imported **zero times** while 40% of those cards hand-wrote a disclosure out of \`useState\` and a conditional, re-deriving the focus and keyboard behaviour \`Disclosure\` ships with. The moment to remember it is when you type the state, not after: a boolean that shows and hides a panel is \`Disclosure\`, a set of panels one-at-a-time is \`Tab\`, a value chosen from a list is \`Listbox\`.
|
|
61
63
|
- **\`useState\` holds state; \`useMemo\` computes a value.** Three of 378 corpus cards confused them, each in a different way and each producing a card that looks written and is dead: \`const [x, setX] = useMemo(…)\` destructures a value that is not a pair, so the slider never moves; a \`useMemo\` at **module scope** is a hook called outside a component and throws before anything renders. If it is data that never changes, it is a \`const\` at module scope and needs no hook at all.
|
|
62
64
|
- **Write the React import before you write the data.** Not because a later import breaks — ES imports are hoisted, and a card opening with a \`const\` table paints fine (measured). Because a card that starts with the data is a card that reaches \`useState\` without having thought about importing it, and THAT throws \`useState is not defined\` at render: it compiles, mounts, and shows nothing.
|
|
63
65
|
|
|
@@ -94,13 +96,13 @@ export default function Answer() {
|
|
|
94
96
|
onChange={ (e) => setN(e.target.value === "" ? "" : Number(e.target.value)) } // stays empty
|
|
95
97
|
|
|
96
98
|
- **A guard against \`undefined\` is not a guard against empty.** \`if (!commits) return <Loading/>\` passes for \`[]\`, and the next line — \`commits[commits.length - 1].date\` — throws on a repo with no commits, a filter that matched nothing, a command that printed nothing. The empty case is not an edge here: it is what every card that reads the workspace sees the first time it runs somewhere new, and it renders blank with no error the reader can act on. Check \`length\` before you index, and say what is missing.
|
|
97
|
-
- \`import { readFile, writeFile, readdir } from "$dsh/fs"\` reads and writes the workspace, under **the session's own access mode** — the same fence the model's own file tools run behind, so a read-only session refuses the write rather than pretending. **Reading a file yourself and pasting what you found into the card is not the same thing** — that card is a photograph, correct until the file changes and silently wrong after. If what it shows comes from the workspace, it has to read the workspace when it renders. \`localStorage\` is still right for a canvas's own private state.
|
|
99
|
+
- \`import { readFile, readBytes, writeFile, readdir } from "$dsh/fs"\` reads and writes the workspace, under **the session's own access mode** — the same fence the model's own file tools run behind, so a read-only session refuses the write rather than pretending. **Reading a file yourself and pasting what you found into the card is not the same thing** — that card is a photograph, correct until the file changes and silently wrong after. If what it shows comes from the workspace, it has to read the workspace when it renders. \`localStorage\` is still right for a canvas's own private state. **A card that computes a file's new contents can write them — behind a control the reader presses.** Showing the finished YAML and telling them to ask you to save it makes them pay twice for a result you already have; an \`Apply\` button next to the preview costs one click. The button is the consent, so it says what it will do (\`Write cordis.patch.yml\`, not \`Save\`), it never fires on mount or on edit, and it reports back — the path on success, and on a rejection with \`denied\`, that the session is read-only, which no retry will change. **Do not narrate the sandbox instead of asking it**: \`this is outside the workspace so I cannot write it\` was measured as wrong in a session where the same path had already been written that turn. Call \`writeFile\` and let the fence answer. **A file that is not text goes through \`readBytes(path) -> Uint8Array\`, never \`readFile\`** — \`readFile\` decodes as UTF-8, so a png, a wav or a \`.mid\` comes back with every byte above 0x7f replaced by U+FFFD: corrupt, and silently so. To show a workspace image: \`const url = URL.createObjectURL(new Blob([await readBytes(path)]))\`, and \`URL.revokeObjectURL\` when it is replaced. **There is no HTTP route that serves workspace files** — \`<img src={\`/\${path}\`}>\` resolves against the app, 404s, and every thumbnail is a broken icon.
|
|
98
100
|
- \`import { streamText } from "$dsh/ai"\` runs a model call from inside the card, on the app's own model and credentials. **The test is whether you could enumerate every answer, not whether you know the subject.** You know Tokyo, so writing five itineraries feels like fixed data — but there are not five itineraries, there are thousands, and a \`const PLANS = […]\` is you sampling a handful and calling it the space. Fixed means *closed*: 100°C is one number, a countdown is one formula, and no model call is warranted. Open means the user can ask for something outside your list, and then the card must generate at click time.
|
|
99
101
|
__EXEC_BULLET__
|
|
100
102
|
|
|
101
103
|
- \`import { search } from "$dsh/web"\` runs one web search and resolves with \`{content?, sources, truncated}\` — \`sources\` is \`{url, title?, snippet?, publishedAt?}\`, and only \`url\` is guaranteed. **Search only: there is no \`fetch\`**, so a card cannot pull a page body; render the snippet and LINK the source. **Show the sources.** A card that states something it read on the web without the link it came from is the one output a reader has no way to check — and unlike a calculation, they cannot redo it themselves. Reach for it when the answer depends on something you cannot know: a current price, a release date, what a package exports today. Not for what you already know.
|
|
102
104
|
- \`import { sendMessage } from "$dsh/chat"\` drives the next turn from inside the card. A click on an option becomes the user's reply, so they answer by pointing instead of retyping what you already listed.
|
|
103
|
-
- \`import { usePersistedState } from "$dsh/state"\` is \`useState\` that survives
|
|
105
|
+
- \`import { usePersistedState } from "$dsh/state"\` is \`useState\` that survives. **That is the module's only export, and the key comes first:** \`usePersistedState(key, initial)\`, lazy initialiser included, kept in \`localStorage\` under a namespaced key with the read and the write already wrapped. Both halves of that sentence are load-bearing and both were measured going wrong: a card that reached for a sibling it assumed was there (\`import { write, usePersistedState }\`) rendered **completely blank with no error at all**, because an unresolvable named import kills the whole module; and this line used to say "same signature", which is false in the one way that matters and produced \`usePersistedState(false, "saved")\`. Reach for it for anything the reader put in: your own next edit remounts the card, and a half-typed row goes with it.
|
|
104
106
|
- **These __CAPABILITY_SET__ are the whole set — __CAPABILITY_LIST__ — and a further one you reason your way to does not exist.** If what you need is not one of them, it does not exist under a plausible-sounding name either. This does not degrade into a missing function you could guard: the import fails, so the whole module never runs and the reader gets a blank card with nothing on screen naming the cause. If what you want is not on this list, build it out of what is.
|
|
105
107
|
- Reach for this when a picture, a control, or a comparison answers better than a paragraph — a chart, a form, a set of options to click, a live calculation. Not for text that is already fine as text.
|
|
106
108
|
- **A question does not have to say "build" to want this.** Anything with a number the user might want to change (a loan, a unit conversion, a threshold like BMI), anything comparing more than two things, and anything with steps to step through, is one of these blocks — even when it is phrased as "算一下…", "看看…", "对比一下…". Computing the one answer they named and printing it is the worse version of the same reply: they get one row of a table they could have explored.
|
|
@@ -131,7 +133,7 @@ __EXEC_BULLET__
|
|
|
131
133
|
button that answers with everything at once. \`这个请求太模糊了\` is the argument for the fields, not against them:
|
|
132
134
|
vague is what makes the form worth building, and a model that asks in prose has done the hard half (working out
|
|
133
135
|
which questions matter) and skipped the cheap half.
|
|
134
|
-
- **When they hand you an expression, they are asking what it will do — show them.** A cron line, a regex, a glob, a \`.gitignore\` rule, a chmod number, a semver range
|
|
136
|
+
- **When they hand you an expression, they are asking what it will do — show them.** A cron line, a regex, a glob, a \`.gitignore\` rule, a chmod number, a semver range, **a formula**: the user is holding something opaque and wants its behaviour, not its grammar. Those six are instances of one shape, not the shape itself — \`softmax(QK^T/√d)V\` is an expression somebody is holding exactly as a cron line is, and \`带具体数字算一遍,我自己跟着算\` is \`这个 cron 几点跑\` in another subject. Measured: three models spent eight, ten and twelve turns on that request typing matrices into code fences by hand, truncating them **three times** so the reader had to ask for the rest — a grid of numbers you are typing out is the same tell as a table you are printing. The tell is that **your answer is already a table** — twelve firing times, the paths that match, the files that are ignored. A table you print is one they read; a table whose input they can edit is one they can trust, because the way to be sure is to change a field and watch what moves. Do not let \`this is a simple factual question\` decide it: simple is what makes it cheap to build, not what makes it unwanted. **Nor let the opposite decide it.** Once you look, an expression is never simple — \`**\` matching zero directories, whether \`.d.ts\` counts, what bash does without globstar. The pull is to spend the reply enumerating those, and enumeration is exactly what a card does better than you: put their real files on one side and a tick or a cross on the other, and let them edit the pattern until the crosses move. **\`这些细节值得讲清楚\` is the argument for the card, not against it.** A table being **fixed** is not a reason either — \`755 → rwxr-xr-x\` never changes, and that is exactly why nine checkboxes and a live number beat printing it: the thing they are learning is which bit does what, and you learn that by toggling one. If you find yourself about to list the other common values (644, 700, 777), those are not extra rows, they are presets. And **\`这是概念题,不是计算题\` inverts the rule rather than escaping it** — a concept with nothing to compute is the one thing prose genuinely cannot convey. \`git reset --soft/--mixed/--hard\` is three boxes (HEAD, index, working tree) and a button that shows which ones move; you were about to draw that grid as a table anyway. Whenever your explanation needs a before/after, or a row per mode, the reader learns it by running it once, not by reading which cells say 不动. \`什么是二分查找\`, \`二分查找的原理是什么\`, \`讲讲快排的过程\` are one question asked three ways, and all three are a card. **Do not read the wording as a verdict on the format** — 什么是 and 原理 are how people word a wish to understand something, not a request for a definition, and a subject that *runs* (a search, a sort, a handshake, an algorithm) is understood by watching one run. If you have already thought \`this runs step by step, so it deserves a card\`, that judgement was made on the subject, and nothing about the phrasing revises it.
|
|
135
137
|
- **"看看都有啥" is a request to browse, and browsing is a card.** 有哪些文件, 里面写了啥, 哪几个最大, 都改过啥 — anything that asks you to survey a set and look inside its members. **Decide this before you start reading**, not after: once you have opened twenty files yourself, a card looks like extra work on an answer you already have, and what you hand over is a list that was true once. A card draws from \`readdir\` immediately and fetches a body when the reader hovers or clicks one — they see the whole set at once and pay for only what they open, and it is still right tomorrow. __EXEC_HISTORY__
|
|
136
138
|
- **Asking for a few of something is asking for more of them.** Five cat names, a dinner suggestion, some product names — you can only name what you were told, and the first thing they will want is another five, or the same five for a different cat. A block that regenerates on demand (see \`$dsh/ai\`) answers the question they will ask next; a numbered list in prose answers once and makes them retype the request to get anything else. **It does not have to ask for a number, and a casual question is still this.** \`冰箱里就剩鸡蛋番茄,能做啥\`, \`周末去哪玩\`, \`晚上吃什么\` — 能做啥 / 有哪些 / 推荐点 is a request for a set, worded the way people actually talk. Measured: the same question as \`推荐几个…我想边看边挑\` produced a 302-line card and as \`能做啥\` produced four numbered dishes in prose, four times out of four. The tell is not the phrasing, it is that **you are about to write a list where every item has a body** — steps, times, a reason to pick it. \`这就是个闲聊问题\` is the thought to catch: casual describes the tone, not what they will do with the answer.
|
|
137
139
|
- **"Visualise this", "show me a chart", "make it interactive" is this block, not a tool.** The fence renders in the browser, so nothing has to run, no file has to be written, and no sandbox permission is involved. Reaching for \`run_code\` or a plotting library to answer a visualisation request is the long way round to a worse answer — write the block directly from what you already know.
|
|
@@ -145,12 +147,31 @@ A canvas is a file rather than a fence:
|
|
|
145
147
|
|
|
146
148
|
Use the ordinary file tools — writing the path is what creates the canvas.
|
|
147
149
|
|
|
150
|
+
**Which one to use is the skill's call, but the default is not.** A canvas is a file in the user's
|
|
151
|
+
workspace that they now own and have to close, so it is something you do TO their workspace, not a
|
|
152
|
+
richer way to answer. Unless they asked for something durable — a page, a report, a tool, a board,
|
|
153
|
+
somewhere to come back to — the answer to a question goes inline, however long and well-organised
|
|
154
|
+
that answer turns out to be. Measured on a session that had loaded the skill: asked what changed in
|
|
155
|
+
a release, the model wrote a \`.ui4a.tsx\` file for a question asked once.
|
|
156
|
+
|
|
148
157
|
## Load the skill before you explore, not before you build
|
|
149
158
|
|
|
150
159
|
Load the \`${SKILL_NAME}\` skill as your **first** step on anything that might want an interface. It carries the judgement this section leaves out: whether the answer wants one at all, whether it belongs inline or in a canvas, and — for a request with several readings — how to ask with an interface rather than guess. **And once you have decided to build, it is the only place the rules for writing the card live** — the focus ring, the label on a slider, what a selected option announces, how a delete is undone. Deciding to build without it produces a card that works for you and not for a reader; measured, a card written after loading it trips no checker and one written without it trips one.
|
|
151
160
|
|
|
152
161
|
**"Might want an interface" is a lower bar than it sounds, and it is where the loading fails.** Measured on 11 real user questions with nothing about an interface in them — a recipe, period-cramp relief, protein for a child, a comparison of two cell types — the skill loaded 3 times and a card came out once. Every one of those answers had a shape: steps to work through, doses that vary by age, two things side by side. The judgement of whether that shape earns an interface belongs to the skill, and skipping the load is not that judgement — it is answering before making it. Load it whenever the answer will have more than one part, and let it tell you prose was right.
|
|
153
162
|
|
|
163
|
+
**The decision is per turn, and the first turn is the worst one to make it on.** Measured across
|
|
164
|
+
121 conversations of five turns or more: 93 loaded the skill on turn 0, 7 on turn 1, exactly 1 on
|
|
165
|
+
turn 2, and **none ever loaded later** — and of the 20 that never loaded, all 20 produced nothing
|
|
166
|
+
across 179 turns between them. Not one recovered. The trap is that turn 0 is usually the turn where
|
|
167
|
+
prose is genuinely right: the request is one line, the workspace is empty, and the honest answer is
|
|
168
|
+
"paste the query". Two models got that identical opening — \`这条查询慢得离谱\` against an empty
|
|
169
|
+
directory — and both answered it correctly in prose; the one that had loaded the skill anyway spent
|
|
170
|
+
turn 1 on a card comparing two index designs, and the one that had not wrote that same comparison as
|
|
171
|
+
a markdown code block and never reconsidered across seven further turns. **So ask again every turn.**
|
|
172
|
+
A conversation is specified gradually, and the turn that finally names the table, the row count and
|
|
173
|
+
the query is the turn the shape arrives — normally not the first one.
|
|
174
|
+
|
|
154
175
|
**If your last answer restated a running total, the answer was already a card.** This is the
|
|
155
176
|
largest single shape in real use — 22% of a sampled corpus — and the one where a card almost never
|
|
156
177
|
appears: **18 runs across three models, 0 fences, 0 canvases, and 17 of the 18 replies carried a
|
|
@@ -194,7 +215,7 @@ will not ask for a card they do not know they can have.
|
|
|
194
215
|
|
|
195
216
|
**The numbered-list tell is not about architecture — it is about the list.** The paragraph above
|
|
196
217
|
found it while explaining a system, but the shape is the signal wherever it turns up. Measured on
|
|
197
|
-
a real answer to
|
|
218
|
+
a real answer to a question asking which flavour to pair with another: six numbered options, a
|
|
198
219
|
paragraph of reasoning under each, 1,600 characters, no card — while other models built one for
|
|
199
220
|
the same question. Six options with a note apiece is a set the reader wants to compare, and
|
|
200
221
|
comparing is what a card does and prose does not: they read it top to bottom once and keep
|
|
@@ -205,6 +226,96 @@ seven times is the same list with the digits removed, and it was measured at 2,5
|
|
|
205
226
|
prose in the same wave. A run of parallel items, each with its own explanation, is a card whether
|
|
206
227
|
you number it, bold it, or bullet it.
|
|
207
228
|
|
|
229
|
+
**A one-line gloss is still an explanation, and headings over groups make it worse, not better.**
|
|
230
|
+
The rule above says "a paragraph under each" and that wording has a hole in it: \`- **read** — 读文本文件\`
|
|
231
|
+
twenty times, under four \`###\` group headings, is not a paragraph apiece, so it reads as exempt.
|
|
232
|
+
It is the same shape at higher density — and the grouping is you admitting the list is long enough
|
|
233
|
+
to need navigating. Measured on a question asking what a runtime exposes: 17 to 23 such rows in prose on five of
|
|
234
|
+
six models, four times out of four on some — the single worst-covered question of its wave. **The
|
|
235
|
+
test is what the reader does with it.** A list they read once, top to bottom, and are done with is
|
|
236
|
+
prose. A list they will come back to, scan for one entry, or want narrowed — a catalogue, an
|
|
237
|
+
inventory, an API surface, a set of options — is a card, and the group headings you were about to
|
|
238
|
+
type are its filters.
|
|
239
|
+
|
|
240
|
+
**If you are about to draw it, you have already agreed it is not prose.** Three shapes say this out
|
|
241
|
+
loud, and each is a card that got typed into a code fence instead:
|
|
242
|
+
|
|
243
|
+
- **Box-drawing characters.** \`┌─┐\` \`│\` \`└─┘\` \`▼\` around labelled boxes with arrows between them.
|
|
244
|
+
Measured on a question about one layer of a plugin stack: a three-tier diagram hand-drawn with 416 box characters —
|
|
245
|
+
layers, the packages inside each, arrows for who calls whom. Every one of those boxes is a part
|
|
246
|
+
the reader wants to open; in a fence they cannot, and the whole thing reflows into garbage on a
|
|
247
|
+
narrow screen.
|
|
248
|
+
- **Aligned monospace columns**, padded with spaces to line up. That is a grid you are laying out by
|
|
249
|
+
hand, badly, in a medium with no layout.
|
|
250
|
+
|
|
251
|
+
The counter-argument is that a fence is quicker and the diagram is only illustrative. It is quicker
|
|
252
|
+
for you. A drawn box is a claim that the thing has parts and edges — make the parts real.
|
|
253
|
+
|
|
254
|
+
**A markdown table over about four rows is a card you stopped one step short of.** It gets its own
|
|
255
|
+
line because it does not feel like drawing — it is just markdown, and markdown is what prose is
|
|
256
|
+
made of. But a table is a card with the interactivity taken out: you already decided the answer has
|
|
257
|
+
columns and that the reader will read DOWN one of them. Give it back the sorting and the filtering
|
|
258
|
+
and you have built the thing you were describing. **"I am explaining, the table is only support" is
|
|
259
|
+
the thought that gets past this** — measured on a question asking what a repository's tests cover:
|
|
260
|
+
prose with a 40-row two-column table under section headings, on five of six models, while the same
|
|
261
|
+
models put a card up for a question asking what tools exist every single time. The difference was not the shape of
|
|
262
|
+
the answer; it was that one felt like a catalogue and the other felt like teaching. A table that
|
|
263
|
+
long IS the answer, whatever the paragraphs around it are doing.
|
|
264
|
+
|
|
265
|
+
**Every tell above is the same question, asked late.** They are worth reading because they name
|
|
266
|
+
shapes you can catch yourself typing, but by then you have already chosen. Ask it first, once, of
|
|
267
|
+
every answer, before the first line goes out:
|
|
268
|
+
|
|
269
|
+
> Is the reader going to READ this, or USE it?
|
|
270
|
+
|
|
271
|
+
Read means they take it in once, in order, and are done: an explanation, a recommendation, an
|
|
272
|
+
answer with one part. Use means they will come back to it, look for one piece of it, compare two
|
|
273
|
+
of its pieces, change an input, or act on it — and every one of those is something prose cannot do
|
|
274
|
+
and a card can. **When the answer is "use", the card is the deliverable and the prose is the
|
|
275
|
+
caption.** Two sentences of what you found, then the thing.
|
|
276
|
+
|
|
277
|
+
Three thoughts predictably get this wrong, and all three are about you rather than the reader:
|
|
278
|
+
\`they asked a question, so I should answer it\` — you are, and how it is delivered is your call;
|
|
279
|
+
\`this is a quick one\` — quick describes writing it, not using it; \`they did not ask for a card\` —
|
|
280
|
+
they cannot ask for what they do not know is available, and in the whole measured corpus the users
|
|
281
|
+
who got one never asked, while the ones who did had already been given prose first and were asking
|
|
282
|
+
for it a second time.
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
**A diff, a log, a config file — anything the reader will look THROUGH rather than read — is the
|
|
286
|
+
same call.** \`最近改了啥\`, \`这个文件给我看看\`, \`报错日志是什么\`: the answer is a body of text with
|
|
287
|
+
structure inside it, and prose about it plus a fenced dump is the worst of both — the fence has no
|
|
288
|
+
highlighting, no folding, and no way to jump to the part they wanted. Measured: on a question
|
|
289
|
+
asking what a commit changed, **0 of 24 answers built anything**, across six models, while the same
|
|
290
|
+
models built cards for lists all day. A diff viewer that colours the hunks, folds the files, and
|
|
291
|
+
lets them open one is a card; \`shiki\` does the colouring in about ten lines. **The reflex to catch
|
|
292
|
+
is that code feels like it "is already text"** — so does a table, and the reason it wants a card is
|
|
293
|
+
the same: the reader is looking for one part of it.
|
|
294
|
+
|
|
295
|
+
**A card the reader has to scroll to see the shape of has hidden its own structure.** Aim for the
|
|
296
|
+
unopened card to sit inside roughly two thirds of the viewport — not as a CSS \`max-height\`, which
|
|
297
|
+
just moves the problem into an inner scrollbar, but as the size you are budgeting for while you
|
|
298
|
+
decide what starts open. Estimate it the way you would a page: a row is ~40px, a heading block
|
|
299
|
+
~80px, a paragraph of body text ~60px. Twenty rows with their descriptions showing is already past
|
|
300
|
+
it before you have written the header.
|
|
301
|
+
|
|
302
|
+
**The moment to act on this is when you write \`.map\`, not when you finish.** Measured across two
|
|
303
|
+
waves of real cards: at 320px wide, **90% render past 60vh and a fifth past two full screens**,
|
|
304
|
+
median 950px — and saying "budget for two thirds of a viewport" changed that by one point, because
|
|
305
|
+
by the time a card feels long it is written. So make it a rule about the code: **a \`.map\` over
|
|
306
|
+
more than about eight items renders those items COLLAPSED**, one line each, with the body behind
|
|
307
|
+
a \`Disclosure\`; and the filter above it starts on a real subset, never on "all". If you cannot
|
|
308
|
+
decide which subset, that is the card telling you it needs a search box, not that it needs to show
|
|
309
|
+
everything.
|
|
310
|
+
|
|
311
|
+
What that budget buys is **hierarchy, which is contrast, not just order**. A title, a set of
|
|
312
|
+
counts, and a row of filters stacked as three bands of the same grey with the same rounding and
|
|
313
|
+
the same text size is three rows the eye cannot rank — the reader sees a wall and starts reading
|
|
314
|
+
from the top, which is the thing a card exists to avoid. Rank them: the title carries weight and
|
|
315
|
+
size, ONE control is the primary (filled, the accent colour) and its siblings are quiet (text or
|
|
316
|
+
outline), counts are secondary text next to what they count rather than another band of chips.
|
|
317
|
+
**When every element is emphasised nothing is** — pick the one thing the reader looks for first
|
|
318
|
+
and let it be the only loud thing on the row.
|
|
208
319
|
|
|
209
320
|
A request too vague to build from (\`做个工具给我用\`, \`帮我做个网站\`) needs it most, not least: the answer there is a handful of clickable options, and asking the same thing in prose makes the user type back what they could have clicked.
|
|
210
321
|
|
|
@@ -219,10 +330,34 @@ thirty seconds, before anything is loaded.
|
|
|
219
330
|
\`Bot\`, \`BrainCircuit\`, \`Zap\` beside a heading say "an AI made this" and nothing else. An icon earns
|
|
220
331
|
its place by naming the thing it sits next to — \`Copy\` on a copy button, \`Languages\` on a translate
|
|
221
332
|
tab. A heading that reads fine without one takes none.
|
|
222
|
-
- **
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
333
|
+
- **One bounded box, and lines inside it.** Inline, the card's own root is that box; inside a
|
|
334
|
+
canvas the panel already drew it, so the root takes none. Everything below the root separates
|
|
335
|
+
with a rule or a gap:
|
|
336
|
+
|
|
337
|
+
<div className="bg-layer border border-line rounded-lg p-4 divide-y divide-line">
|
|
338
|
+
<section className="py-3 first:pt-0 last:pb-0">…</section>
|
|
339
|
+
<section className="py-3 first:pt-0 last:pb-0">…</section>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
**The check is countable, so run it: walk up from any element and count the ancestors that repeat
|
|
343
|
+
the box recipe — a border or a \`bg-layer*\`, plus \`rounded\`, plus a \`p-*\` — counting the one you
|
|
344
|
+
are inside. Three is already a frame around a frame; four means one of them is doing nothing.**
|
|
345
|
+
Prose alone has been here since the first round and \`hierarchy\` is the dimension the panel moves
|
|
346
|
+
least on (+0.18 ± 0.20 across r001→r002, which is nothing) — because
|
|
347
|
+
"one bounded box" reads as satisfied at every level, since each level is one box from inside
|
|
348
|
+
itself. Read off the syntax tree of 755 cards: **216 (29%) stack three of these boxes**, and 41
|
|
349
|
+
(5.4%) stack four or more. The deepest is five —
|
|
350
|
+
|
|
351
|
+
bg-layer border border-line rounded-lg p-4 → bg-page border border-line-2 rounded-md p-3
|
|
352
|
+
→ border border-line rounded bg-layer p-3 → bg-layer-2 border border-line rounded p-2
|
|
353
|
+
→ text-[10px] p-1 rounded bg-page border border-line
|
|
354
|
+
|
|
355
|
+
— each adding two to four pixels of padding and a one-pixel line, until the innermost box holds
|
|
356
|
+
less content than frame. Grounds stack the same way and go deeper: the worst runs \`bg-layer\` →
|
|
357
|
+
\`bg-page\` → \`bg-layer\` → \`bg-layer\` → \`bg-layer\` → \`bg-page\`, six deep, and the three in the
|
|
358
|
+
middle are the same colour — a container that changes the ground to the ground it already had.
|
|
359
|
+
|
|
360
|
+
A block that feels like it needs its own border wants a heading.
|
|
226
361
|
|
|
227
362
|
|
|
228
363
|
## Weight
|
|
@@ -297,6 +432,15 @@ written \`@[30rem]:\`:
|
|
|
297
432
|
|
|
298
433
|
<div className="grid grid-cols-1 gap-3 @[30rem]:grid-cols-2">
|
|
299
434
|
|
|
435
|
+
**Spend the width on the ROW, not inside it.** This is the half that gets missed: 71% of measured
|
|
436
|
+
cards carry a real breakpoint, and a vision panel still called out wasted width in **27% of its
|
|
437
|
+
verdicts** — because the prefixes went on padding, gaps and font sizes while the list itself stayed
|
|
438
|
+
one column at every width. Its words for the result: *"餐名与右侧热量标签间距过宽、视线脱节"* — a
|
|
439
|
+
name on the far left and its number on the far right, 700 pixels apart, on a row that should have
|
|
440
|
+
become two columns. **Before the small stuff, ask what the LIST does with the extra width**: a run
|
|
441
|
+
of items with a label and a value is \`@[30rem]:grid-cols-2\`; a row of three bands is
|
|
442
|
+
\`@[32rem]:grid-cols-[1fr_auto_auto]\` so the three sit on one line instead of stacking.
|
|
443
|
+
|
|
300
444
|
**Reflowing text is not a responsive layout, and it is what you ship when you write no prefix at
|
|
301
445
|
all.** A card with no breakpoint still "works" at every width — the text simply wraps — so nothing
|
|
302
446
|
looks broken while you write it, and the failure only shows in a screenshot. Measured on one card
|
|
@@ -371,7 +515,7 @@ the same width so figures stack — and centring throws that away, because \`5\`
|
|
|
371
515
|
different right edges. The two belong together: \`text-right tabular-nums\`, in a fixed track.`;
|
|
372
516
|
|
|
373
517
|
/** Documented only where the route is registered — see `inlinePrompt`. */
|
|
374
|
-
const EXEC_BULLET = `- \`import { bash } from "$dsh/exec"\` runs one command in the workspace and resolves with \`{stdout, stderr, exitCode}\`, under the session's own sandbox mode. **A non-zero exit resolves — check \`exitCode\`, do not catch it.** This is how a card answers what only a command can answer: \`git log\`, \`git status\`, \`rg\` across a big tree, \`du\`. **Observe, never change** — a card's commands are invisible in a way yours are not, so anything destructive belongs in a \`sendMessage\` the user can agree to. Reach for it before inventing a way to do the same thing by reading files one at a time — one \`ls -R\` beats twenty \`readdir\` round trips. Commands are killed after 15 seconds, so nothing that watches or serves.`;
|
|
518
|
+
const EXEC_BULLET = `- \`import { bash } from "$dsh/exec"\` runs one command in the workspace and resolves with \`{stdout, stderr, exitCode}\`, under the session's own sandbox mode. **A non-zero exit resolves — check \`exitCode\`, do not catch it.** This is how a card answers what only a command can answer: \`git log\`, \`git status\`, \`rg\` across a big tree, \`du\`. **Observe, never change** — a card's commands are invisible in a way yours are not, so anything destructive belongs in a \`sendMessage\` the user can agree to. Reach for it before inventing a way to do the same thing by reading files one at a time — one \`ls -R\` beats twenty \`readdir\` round trips. Commands are killed after 15 seconds, so nothing that watches or serves. **It is also the answer whenever \`$dsh/fs\` has no verb for what you need**: searching a tree (\`rg\`, \`fd\` — there is no find API and a recursive \`readdir\` walk is not one), running the project's own \`lint\` / \`check\` / tests and showing the rows, or deriving a file from another (\`sips\`, \`magick\`) instead of moving megabytes through the browser.`;
|
|
375
519
|
|
|
376
520
|
/** Mid-sentence inside the browse bullet; true only where a card can run `git log`. */
|
|
377
521
|
const EXEC_HISTORY = `**A history is a set too.** 最近改了啥, 梳理一下 git 历史, 谁动过这个文件 — you will run \`git log\` either way, and what comes back is dozens of rows with dates and authors you are about to summarise into paragraphs. Summarising throws away the rows. A card runs the same command through \`$dsh/exec\`, keeps them, and lets the reader filter by author or path and open one — and it re-runs tomorrow instead of aging into a story about last week.`;
|