jaitus 0.3.6 → 0.3.8
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/package.json +2 -2
- package/src/agent/loop.js +82 -6
- package/src/agent/permissions.js +3 -1
- package/src/agent/rules.js +27 -3
- package/src/agent/tools.js +39 -2
- package/src/api.js +3 -1
- package/src/errors.js +2 -1
- package/src/index.js +187 -44
- package/src/sessions.js +22 -1
- package/src/skills/builtin/documents/SKILL.md +91 -0
- package/src/skills/builtin/frontend-design/SKILL.md +102 -0
- package/src/skills.js +6 -1
- package/src/tui/entries.js +36 -5
- package/src/tui/widgets.js +32 -0
- package/src/tui-ink.js +99 -17
package/src/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
endpoints, version, HOME, HISTORY,
|
|
18
18
|
} from './config.js';
|
|
19
19
|
import { runAgent, repairToolPairs } from './agent/loop.js';
|
|
20
|
-
import { createPermissions } from './agent/permissions.js';
|
|
20
|
+
import { createPermissions, MODES as PERMISSION_MODES } from './agent/permissions.js';
|
|
21
21
|
import { DEFAULT_DENY } from './agent/rules.js';
|
|
22
22
|
import { forgetIgnoreCache, imageBlock, IMAGE_MIME } from './agent/tools.js';
|
|
23
23
|
import { shrinkImageFile } from './tui/clipboard.js';
|
|
@@ -311,6 +311,17 @@ function requireAuth({ quiet = false } = {}) {
|
|
|
311
311
|
*/
|
|
312
312
|
let activeTui = null;
|
|
313
313
|
|
|
314
|
+
// Failures caused by the message itself: sending it again would fail again, so a turn
|
|
315
|
+
// that failed this way is taken out of the conversation instead of kept with its error.
|
|
316
|
+
const MESSAGE_FAULTS = new Set(['content_blocked', 'request_too_large', 'free_request_too_large', 'context_too_long', 'bad_request']);
|
|
317
|
+
|
|
318
|
+
/** The typed text of a message, whatever its shape. */
|
|
319
|
+
function textOfMessage(m) {
|
|
320
|
+
if (!m) return '';
|
|
321
|
+
if (typeof m.content === 'string') return m.content;
|
|
322
|
+
return (Array.isArray(m.content) ? m.content : []).filter((b) => b?.type === 'text').map((b) => b.text).join('\n');
|
|
323
|
+
}
|
|
324
|
+
|
|
314
325
|
function reportAgentError(e, context = {}) {
|
|
315
326
|
const error = e instanceof Error ? e : new Error(String(e?.message ?? e ?? 'Unknown error'));
|
|
316
327
|
if (activeTui?.error) {
|
|
@@ -361,6 +372,11 @@ async function loadContextBudget(client) {
|
|
|
361
372
|
// Compact at this fraction of the budget. Low enough that the summarisation request
|
|
362
373
|
// itself still fits comfortably, high enough that a normal session never sees it.
|
|
363
374
|
const AUTO_COMPACT_AT = 0.78;
|
|
375
|
+
// The budget is what the gateway advertises — the providers' limit, or the admin's
|
|
376
|
+
// Context cap when it is lower (Admin → Settings → Plans & credits). That cap is the ONE
|
|
377
|
+
// control for how big a session gets before it is summarised: a second ceiling here
|
|
378
|
+
// would silently override whatever the admin set.
|
|
379
|
+
const compactThreshold = () => CONTEXT_BUDGET * AUTO_COMPACT_AT;
|
|
364
380
|
|
|
365
381
|
// An expanded @mention is capped: pulling a 4 MB lockfile into the prompt because
|
|
366
382
|
// someone typed `@package-lock.json` would blow the context it is meant to save.
|
|
@@ -410,8 +426,20 @@ async function summarise(client, model, messages, focus) {
|
|
|
410
426
|
messages: [
|
|
411
427
|
{
|
|
412
428
|
role: 'user',
|
|
413
|
-
|
|
414
|
-
|
|
429
|
+
// Sections a successor needs to carry on without re-asking or re-reading — the
|
|
430
|
+
// same structure agent tools use for their own compaction. Losing the user's
|
|
431
|
+
// exact instructions or where the work stopped is what makes a compacted session
|
|
432
|
+
// feel worse; everything else can be re-read from disk.
|
|
433
|
+
content: 'Summarize this conversation for a successor agent that will continue the work with no other memory of it. '
|
|
434
|
+
+ 'Use these headings, and be specific (paths, names, numbers) rather than general:\n'
|
|
435
|
+
+ '1. Request — what the user asked for, with their exact words for any instruction, constraint or preference.\n'
|
|
436
|
+
+ '2. Corrections — anything the user rejected, corrected or asked to do differently, verbatim where short.\n'
|
|
437
|
+
+ '3. Files — every file read or changed: path, what changed or what matters in it.\n'
|
|
438
|
+
+ '4. Commands and results — what was run and what it showed (test/build results, errors and how each was fixed).\n'
|
|
439
|
+
+ '5. Decisions — what was chosen and why.\n'
|
|
440
|
+
+ '6. Current state — exactly what was being done when this summary was made, and what is unfinished.\n'
|
|
441
|
+
+ '7. Next step — the single next action, if the work was mid-way.\n'
|
|
442
|
+
+ 'Omit headings that have nothing. No preamble.'
|
|
415
443
|
+ (focus ? ` Focus on: ${focus}.` : '')
|
|
416
444
|
+ `\n\n<conversation>\n${transcriptForSummary(messages)}\n</conversation>`,
|
|
417
445
|
},
|
|
@@ -491,6 +519,37 @@ function projectRules(cwd) {
|
|
|
491
519
|
return { allow: Array.isArray(saved.allow) ? saved.allow : [], deny: Array.isArray(saved.deny) ? saved.deny : [] };
|
|
492
520
|
}
|
|
493
521
|
|
|
522
|
+
/** The bypass confirmation, wherever bypass is switched on from. */
|
|
523
|
+
const BYPASS_CHOICES = [
|
|
524
|
+
{ label: 'Yes, for this session', value: 'y' },
|
|
525
|
+
{ label: 'Yes, and start every new session in bypass', value: 'always' },
|
|
526
|
+
{ label: 'No, stay put', value: 'n' },
|
|
527
|
+
];
|
|
528
|
+
const BYPASS_WARNING = [
|
|
529
|
+
'Writes and shell commands will run with NO permission prompts.',
|
|
530
|
+
'Use only in a trusted workspace you can afford to rebuild. Deny rules still apply.',
|
|
531
|
+
];
|
|
532
|
+
|
|
533
|
+
/** The mode every new session starts in; null goes back to "the last one used". */
|
|
534
|
+
function saveDefaultMode(mode) {
|
|
535
|
+
const settings = loadSettings();
|
|
536
|
+
if (mode) settings.defaultMode = mode;
|
|
537
|
+
else delete settings.defaultMode;
|
|
538
|
+
saveSettings(settings);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* What to say beside a mode when a saved rule already approves a whole tool here —
|
|
543
|
+
* otherwise "Ask" is on screen while every command runs unasked, and the modes look
|
|
544
|
+
* like they do nothing.
|
|
545
|
+
*/
|
|
546
|
+
function wideRuleNote(permissions, mode) {
|
|
547
|
+
if (mode === 'plan' || mode === 'bypassPermissions') return '';
|
|
548
|
+
const wide = permissions?.wide || [];
|
|
549
|
+
if (!wide.length) return '';
|
|
550
|
+
return `Saved rule ${wide.join(', ')} skips the permission question in every mode here — /permissions forget ${wide[0]} to let the mode decide.`;
|
|
551
|
+
}
|
|
552
|
+
|
|
494
553
|
/** The key rules are saved under: the existing spelling when there is one. */
|
|
495
554
|
function rulesKey(cwd) {
|
|
496
555
|
return Object.keys(loadSettings().permissionRules || {}).find((k) => sameDir(k, cwd)) || path.resolve(cwd);
|
|
@@ -536,8 +595,11 @@ async function oneShot(task, { flags }) {
|
|
|
536
595
|
messages.push({ role: 'user', content: task });
|
|
537
596
|
|
|
538
597
|
const explicitPlan = flags.permissionModeExplicit && flags.permissionMode === 'plan';
|
|
598
|
+
// The saved default mode applies here too — "every session" includes `jaitus -p`.
|
|
599
|
+
const savedDefault = loadSettings().defaultMode;
|
|
600
|
+
const oneShotMode = !flags.permissionModeExplicit && PERMISSION_MODES.includes(savedDefault) ? savedDefault : flags.permissionMode;
|
|
539
601
|
const permissions = createPermissions({
|
|
540
|
-
autoApprove: flags.yes && !explicitPlan, mode:
|
|
602
|
+
autoApprove: flags.yes && !explicitPlan, mode: oneShotMode,
|
|
541
603
|
interactive: false, ...projectRules(flags.cwd),
|
|
542
604
|
});
|
|
543
605
|
|
|
@@ -547,7 +609,7 @@ async function oneShot(task, { flags }) {
|
|
|
547
609
|
...(flags.maxTurns ? { maxIterations: flags.maxTurns } : {}),
|
|
548
610
|
silent: true,
|
|
549
611
|
systemOverride: flags.systemPrompt || null,
|
|
550
|
-
extraSystem: flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''
|
|
612
|
+
extraSystem: `${skillCatalog(discoverSkills(flags.cwd))}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}`,
|
|
551
613
|
onText: (text) => {
|
|
552
614
|
answer += text;
|
|
553
615
|
if (format === 'stream-json') out(JSON.stringify({ type: 'content_block_delta', delta: { text } }));
|
|
@@ -602,7 +664,7 @@ const SLASH = {
|
|
|
602
664
|
'/tasks': { summary: 'review tool activity from this session' },
|
|
603
665
|
'/image <path>': { summary: 'attach an image (png/jpg/gif/webp) the model will see with your next message' },
|
|
604
666
|
'/memory [text]': { summary: 'show or append to JAITUS.md (# does this inline)' },
|
|
605
|
-
'/permissions [mode|rules]': { summary: 'permission mode, and the allow/deny rules for this project' },
|
|
667
|
+
'/permissions [mode|rules|default]': { summary: 'permission mode, the mode new sessions start in, and the allow/deny rules for this project' },
|
|
606
668
|
'/skills': { summary: 'list project workflows' },
|
|
607
669
|
'/sessions': { summary: 'list saved sessions in this directory' },
|
|
608
670
|
'/session': { summary: 'show this session id', hidden: true }, // the id is already in /status
|
|
@@ -767,11 +829,21 @@ async function session({ flags }) {
|
|
|
767
829
|
// flag used to be overridden by it, so after one Shift+Tab `--permission-mode plan`
|
|
768
830
|
// started a session that edited files without asking. Plan (often set by /ask or
|
|
769
831
|
// /plan, not chosen) and bypass never carry into a new session on their own.
|
|
770
|
-
|
|
771
|
-
|
|
832
|
+
// A saved default mode (`/permissions default <mode>`, or "Yes, every session" on the
|
|
833
|
+
// bypass prompt) is a choice made once, on purpose, and it wins over the last mode:
|
|
834
|
+
// turning bypass on in every new chat was the thing people had to repeat.
|
|
835
|
+
const startSettings = loadSettings();
|
|
836
|
+
const defaultMode = PERMISSION_MODES.includes(startSettings.defaultMode) ? startSettings.defaultMode : null;
|
|
837
|
+
const lastMode = startSettings.lastMode;
|
|
838
|
+
if (!flags.yes && !flags.permissionModeExplicit) {
|
|
839
|
+
if (defaultMode) permissions.setMode(defaultMode);
|
|
840
|
+
else if (['default', 'acceptEdits'].includes(lastMode)) permissions.setMode(lastMode);
|
|
841
|
+
}
|
|
842
|
+
const bypassIsDefault = defaultMode === 'bypassPermissions' && !flags.permissionModeExplicit;
|
|
772
843
|
// Starting straight into bypass gets one explicit confirmation (§11) when a
|
|
773
|
-
// human is present; headless runs just get the warning on stderr.
|
|
774
|
-
|
|
844
|
+
// human is present; headless runs just get the warning on stderr. A saved bypass
|
|
845
|
+
// default was confirmed when it was saved and is not asked again.
|
|
846
|
+
if (permissions.mode === 'bypassPermissions' && !flags.yes && !bypassIsDefault) {
|
|
775
847
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
776
848
|
out(`\n ${c.red(c.bold('● BYPASS'))} ${c.red('requested — writes and shell commands would run with no permission checks.')}`);
|
|
777
849
|
const answer = await askKey(` ${c.dim(`Enable bypass mode for this session? [y/N] ${glyph('›', '>')}`)} `, ['y', 'n', '\r', ' ']);
|
|
@@ -789,7 +861,7 @@ async function session({ flags }) {
|
|
|
789
861
|
// continues its count instead of starting again from zero.
|
|
790
862
|
const saved = sessionState.spent || {};
|
|
791
863
|
const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
792
|
-
const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns) };
|
|
864
|
+
const spent = { input: num(saved.input), cached: num(saved.cached), output: num(saved.output), tools: num(saved.tools), turns: num(saved.turns), credits: num(saved.credits) };
|
|
793
865
|
sessionState.spent = spent;
|
|
794
866
|
// The real size of the last prompt, as the provider counted it; 0 until one is known.
|
|
795
867
|
let lastContext = num(sessionState.lastContext);
|
|
@@ -862,17 +934,15 @@ async function session({ flags }) {
|
|
|
862
934
|
const order = ['default', 'acceptEdits', 'plan', 'bypassPermissions'];
|
|
863
935
|
const next = order[(order.indexOf(permissions.mode) + 1) % order.length];
|
|
864
936
|
if (next === 'bypassPermissions') {
|
|
865
|
-
const ans = await tui.askDialog('Enable bypass mode?',
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
'Writes and shell commands will run with NO permission prompts.',
|
|
870
|
-
'Use only in a trusted workspace you can afford to rebuild.',
|
|
871
|
-
]);
|
|
872
|
-
if (dialogValue(ans) !== 'y') return permissions.mode;
|
|
937
|
+
const ans = await tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING);
|
|
938
|
+
const picked = dialogValue(ans);
|
|
939
|
+
if (picked !== 'y' && picked !== 'always') return permissions.mode;
|
|
940
|
+
if (picked === 'always') saveDefaultMode('bypassPermissions');
|
|
873
941
|
}
|
|
874
942
|
const m = permissions.cycleMode();
|
|
875
943
|
refreshStatusLine('mode');
|
|
944
|
+
const note = wideRuleNote(permissions, m);
|
|
945
|
+
if (note) tui.flash?.(note, 'warn', 4500);
|
|
876
946
|
return m;
|
|
877
947
|
},
|
|
878
948
|
};
|
|
@@ -964,6 +1034,7 @@ async function session({ flags }) {
|
|
|
964
1034
|
spent.input += result.input || 0;
|
|
965
1035
|
spent.cached += result.cached || 0;
|
|
966
1036
|
spent.output += result.output || 0;
|
|
1037
|
+
spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
|
|
967
1038
|
spent.tools += result.tools || 0;
|
|
968
1039
|
try { tui?.setSpent?.(spent); } catch { /* header keeps last value */ }
|
|
969
1040
|
}
|
|
@@ -989,7 +1060,12 @@ async function session({ flags }) {
|
|
|
989
1060
|
const result = await runAgent({
|
|
990
1061
|
client, messages, model, cwd: flags.cwd, permissions, signal: ctrl.signal, tui, journal,
|
|
991
1062
|
onContext: (n) => { lastContext = n; sessionState.lastContext = n; showContext(); },
|
|
992
|
-
|
|
1063
|
+
// `jaitus config trimToolResults off` keeps every old tool output in context.
|
|
1064
|
+
trimContext: loadSettings().trimToolResults !== false,
|
|
1065
|
+
// What happened since the model's last turn (a failed request, an interrupt, a
|
|
1066
|
+
// compaction, a blocked hook), told once. This was recorded all along and never
|
|
1067
|
+
// read — the model never heard about any of it.
|
|
1068
|
+
extraSystem: `${skillCatalog(skills)}\n\nCurrent intent: ${intentMode}. ${intentGuidance[intentMode]}${flags.appendSystemPrompt ? `\n\nAdditional instructions:\n${flags.appendSystemPrompt}` : ''}${ledger.drain()}`,
|
|
993
1069
|
systemOverride: flags.systemPrompt || null,
|
|
994
1070
|
});
|
|
995
1071
|
// An interrupted turn still spent what it spent before Esc — count it.
|
|
@@ -997,6 +1073,7 @@ async function session({ flags }) {
|
|
|
997
1073
|
spent.input += result.input || 0;
|
|
998
1074
|
spent.cached += result.cached || 0;
|
|
999
1075
|
spent.output += result.output || 0;
|
|
1076
|
+
spent.credits = Math.round(((spent.credits || 0) + (result.credits || 0)) * 100) / 100;
|
|
1000
1077
|
spent.tools += result.tools || 0;
|
|
1001
1078
|
if (result.context) { lastContext = result.context; sessionState.lastContext = lastContext; }
|
|
1002
1079
|
}
|
|
@@ -1026,9 +1103,21 @@ async function session({ flags }) {
|
|
|
1026
1103
|
// that, its next turn begins as though nothing went wrong and it re-attempts the
|
|
1027
1104
|
// thing that just failed.
|
|
1028
1105
|
if (ctrl.signal.aborted) lastRunInterrupted = true;
|
|
1106
|
+
const what = isJaitusError(e) ? `${e.title} — ${e.message}` : 'an unexpected error inside the CLI';
|
|
1029
1107
|
if (messages.length === startLength && messages[messages.length - 1]?.role === 'user') {
|
|
1030
|
-
|
|
1031
|
-
|
|
1108
|
+
if (MESSAGE_FAULTS.has(e?.code)) {
|
|
1109
|
+
// The message ITSELF was the problem (refused content, too large): sending it
|
|
1110
|
+
// again with every later request would make every one of them fail too. It
|
|
1111
|
+
// stays out, and the model is told what was asked and why it was rejected.
|
|
1112
|
+
const failed = messages.pop();
|
|
1113
|
+
const preview = e.code === 'content_blocked' ? '' : ` It began: "${textOfMessage(failed).replace(/\s+/g, ' ').slice(0, 240)}"`;
|
|
1114
|
+
ledger.note('turn-failed', `The user's previous message got no answer: it was rejected (${what}) and removed from the conversation, because sending it again would fail again.${preview}`);
|
|
1115
|
+
} else {
|
|
1116
|
+
// Anything else — the network, a busy provider, a plan limit — says nothing
|
|
1117
|
+
// about the message. It stays in the conversation, followed by what happened to
|
|
1118
|
+
// it, so the model can see what was asked and pick it up.
|
|
1119
|
+
messages.push({ role: 'assistant', content: `[No reply — this request failed: ${what}]` });
|
|
1120
|
+
}
|
|
1032
1121
|
} else {
|
|
1033
1122
|
ledger.note('turn-failed', `A request to the gateway failed${e.status ? ` with HTTP ${e.status}` : ''} partway through the task: ${e.message}. The steps before it are kept; it did not finish.`);
|
|
1034
1123
|
}
|
|
@@ -1079,7 +1168,7 @@ async function session({ flags }) {
|
|
|
1079
1168
|
const autoCompactIfNeeded = async () => {
|
|
1080
1169
|
if (loadSettings().autoCompact === false) return;
|
|
1081
1170
|
const used = estimateTokens(messages);
|
|
1082
|
-
if (used <
|
|
1171
|
+
if (used < compactThreshold()) return;
|
|
1083
1172
|
if (messages.length < 4) return;
|
|
1084
1173
|
|
|
1085
1174
|
tui.setStatus?.(`Compacting — ${format(used)} of ${format(CONTEXT_BUDGET)} context used`);
|
|
@@ -1396,6 +1485,15 @@ async function session({ flags }) {
|
|
|
1396
1485
|
showContext();
|
|
1397
1486
|
tui.setSpent?.(spent);
|
|
1398
1487
|
refreshStatusLine('start');
|
|
1488
|
+
// Why the session is in the mode it is in, when that is not obvious from the badge.
|
|
1489
|
+
const startNotes = [
|
|
1490
|
+
bypassIsDefault && permissions.mode === 'bypassPermissions' ? 'Bypass is your default for new sessions — /permissions default ask turns that off.' : '',
|
|
1491
|
+
wideRuleNote(permissions, permissions.mode),
|
|
1492
|
+
].filter(Boolean);
|
|
1493
|
+
// The welcome screen hides transcript notices until the first message, so the status
|
|
1494
|
+
// line carries them at start; the notice keeps the record in the transcript.
|
|
1495
|
+
for (const note of startNotes) { if (tui.notice) tui.notice(note, 'warn'); else tui.print(c.yellow(` ${note}`)); }
|
|
1496
|
+
if (startNotes.length) tui.flash?.(startNotes.join(' · '), 'warn', 9000);
|
|
1399
1497
|
|
|
1400
1498
|
/** Esc+Esc: pick a checkpoint, restore the conversation and the files. */
|
|
1401
1499
|
const doRewind = async () => {
|
|
@@ -1698,7 +1796,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1698
1796
|
[` ${c.dim('account')}`, creds?.user?.email || 'not signed in'],
|
|
1699
1797
|
[` ${c.dim('directory')}`, ctx.flags.cwd],
|
|
1700
1798
|
[` ${c.dim('context')}`, `${ctx.messages.length} messages · ~${estimateTokens(ctx.messages).toLocaleString()} tokens`],
|
|
1701
|
-
[` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls`],
|
|
1799
|
+
[` ${c.dim('session')}`, `${ctx.spent.turns} turns · ${ctx.spent.tools} tool calls${ctx.spent.credits ? ` · ${Math.round(ctx.spent.credits * 10) / 10} credits` : ''}`],
|
|
1702
1800
|
]);
|
|
1703
1801
|
return null;
|
|
1704
1802
|
}
|
|
@@ -1861,17 +1959,15 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1861
1959
|
const applyMode = async (next) => {
|
|
1862
1960
|
const real = next === 'auto' ? 'bypassPermissions' : next;
|
|
1863
1961
|
if ((real === 'bypassPermissions') && ctx.tui && ctx.permissions.mode !== 'bypassPermissions') {
|
|
1864
|
-
const ans = await ctx.tui.askDialog('Enable bypass mode?',
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
'Writes and shell commands will run with NO permission prompts.',
|
|
1869
|
-
'Use only in a trusted workspace you can afford to rebuild.',
|
|
1870
|
-
], { title: 'Bypass mode', hideComment: true });
|
|
1871
|
-
if (dialogValue(ans) !== 'y') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
|
|
1962
|
+
const ans = await ctx.tui.askDialog('Enable bypass mode?', BYPASS_CHOICES, BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
|
|
1963
|
+
const picked = dialogValue(ans);
|
|
1964
|
+
if (picked !== 'y' && picked !== 'always') { info(`Staying in ${c.bold(ctx.permissions.mode)}.`); return; }
|
|
1965
|
+
if (picked === 'always') { saveDefaultMode('bypassPermissions'); ok('Bypass is now the default for every new session — /permissions default ask undoes it.'); }
|
|
1872
1966
|
}
|
|
1873
1967
|
ctx.permissions.setMode(real);
|
|
1874
1968
|
ok(`Permission mode: ${c.bold(next)}`);
|
|
1969
|
+
const note = wideRuleNote(ctx.permissions, real);
|
|
1970
|
+
if (note) warn(note);
|
|
1875
1971
|
if (real === 'bypassPermissions') warn('The agent will not ask before writing files or running commands.');
|
|
1876
1972
|
if (next === 'plan') info('The agent can read and think, but nothing runs until you switch out of plan mode.');
|
|
1877
1973
|
};
|
|
@@ -1880,6 +1976,27 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1880
1976
|
// shows what has accumulated, which matters because "always allow" writes to
|
|
1881
1977
|
// disk and a list nobody can read is a list nobody can revoke.
|
|
1882
1978
|
const next = args[0];
|
|
1979
|
+
// ── the mode every new session starts in ──
|
|
1980
|
+
if (next === 'default' && args.length > 1 || next === 'startup') {
|
|
1981
|
+
const wanted = normaliseMode(args[1]) || (/^(off|none|last|unset)$/i.test(args[1] || '') ? 'off' : null);
|
|
1982
|
+
if (!wanted) {
|
|
1983
|
+
const current = loadSettings().defaultMode;
|
|
1984
|
+
info(`New sessions start in ${c.bold(current || 'the last mode used (ask or auto-edit)')}.`);
|
|
1985
|
+
info(`${c.dim('Change it with')} ${c.bold('/permissions default bypass')}${c.dim(', ask, acceptEdits, plan — or off.')}`);
|
|
1986
|
+
return null;
|
|
1987
|
+
}
|
|
1988
|
+
if (wanted === 'bypassPermissions' && ctx.tui && loadSettings().defaultMode !== 'bypassPermissions') {
|
|
1989
|
+
const ans = await ctx.tui.askDialog('Start every new session in bypass?', [
|
|
1990
|
+
{ label: 'Yes, every session', value: 'y' },
|
|
1991
|
+
{ label: 'No', value: 'n' },
|
|
1992
|
+
], BYPASS_WARNING, { title: 'Bypass mode', hideComment: true });
|
|
1993
|
+
if (dialogValue(ans) !== 'y') { info('Unchanged.'); return null; }
|
|
1994
|
+
}
|
|
1995
|
+
saveDefaultMode(wanted === 'off' ? null : wanted);
|
|
1996
|
+
ok(wanted === 'off' ? 'New sessions start in the last mode used (ask or auto-edit).' : `New sessions start in ${c.bold(wanted)}.`);
|
|
1997
|
+
if (wanted !== 'off' && ctx.permissions.mode !== wanted) info(`This session stays in ${c.bold(ctx.permissions.mode)} — Shift+Tab or /permissions ${wanted} to switch now.`);
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
1883
2000
|
if (next === 'rules' || next === 'allow' || next === 'deny' || next === 'forget') {
|
|
1884
2001
|
const { parseRule } = await import('./agent/rules.js');
|
|
1885
2002
|
if (next === 'rules') {
|
|
@@ -1912,8 +2029,10 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1912
2029
|
}
|
|
1913
2030
|
|
|
1914
2031
|
if (next) {
|
|
1915
|
-
|
|
1916
|
-
|
|
2032
|
+
// `bypass`, `ask`, `auto-edit` are what the badge says; they are modes too.
|
|
2033
|
+
const named = normaliseMode(next);
|
|
2034
|
+
if (!named) { warn(`Unknown mode: ${next}. Try ask, acceptEdits, plan, bypass — or ${c.bold('/permissions rules')}.`); return null; }
|
|
2035
|
+
await applyMode(named);
|
|
1917
2036
|
return null;
|
|
1918
2037
|
}
|
|
1919
2038
|
if (!ctx.tui) {
|
|
@@ -1982,7 +2101,7 @@ async function slashCommand(verb, args, ctx) {
|
|
|
1982
2101
|
// sessionState.spent and every reader hold this same object.
|
|
1983
2102
|
const t = target.spent || {};
|
|
1984
2103
|
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
|
|
1985
|
-
Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns) });
|
|
2104
|
+
Object.assign(ctx.spent, { input: n(t.input), cached: n(t.cached), output: n(t.output), tools: n(t.tools), turns: n(t.turns), credits: n(t.credits) });
|
|
1986
2105
|
ctx.resetContext?.();
|
|
1987
2106
|
ctx.tui?.setSpent?.(ctx.spent);
|
|
1988
2107
|
if (ctx.tui) replaySession(ctx.tui, target.messages);
|
|
@@ -2528,11 +2647,12 @@ async function slashCommand(verb, args, ctx) {
|
|
|
2528
2647
|
table([
|
|
2529
2648
|
[' Turns', String(spent.turns)],
|
|
2530
2649
|
[' Tool calls', String(spent.tools)],
|
|
2650
|
+
[' Credits', spent.credits ? String(Math.round(spent.credits * 100) / 100) : '—'],
|
|
2531
2651
|
[' Input tokens', spent.input.toLocaleString()],
|
|
2532
2652
|
[' Cached input tokens', (spent.cached || 0).toLocaleString()],
|
|
2533
2653
|
[' Output tokens', spent.output.toLocaleString()],
|
|
2534
2654
|
]);
|
|
2535
|
-
info(`${c.dim('
|
|
2655
|
+
info(`${c.dim('Credits are what your plan counts: real cost, cached context included at its lower price. See')} ${c.bold('/usage')} ${c.dim('for your plan.')}`);
|
|
2536
2656
|
return null;
|
|
2537
2657
|
|
|
2538
2658
|
case '/cwd':
|
|
@@ -2565,9 +2685,18 @@ function planRows(u) {
|
|
|
2565
2685
|
const st = u.planStatus || null;
|
|
2566
2686
|
if (!u.isPaid) return [['Plan', 'Free'], ['Free prompts', `${u.freeRequestsUsed ?? 0} of ${u.freeRequestLimit ?? '?'} used`]];
|
|
2567
2687
|
const rows = [['Plan', st?.name || 'Paid']];
|
|
2568
|
-
|
|
2688
|
+
// Credits: what the plan counts (real cost), the same number the website shows.
|
|
2689
|
+
const cr = (n) => ((Number(n) || 0) >= 1000 ? Math.round(n).toLocaleString('en-IN') : String(Math.round((Number(n) || 0) * 10) / 10));
|
|
2690
|
+
if (st?.monthlyCredits) {
|
|
2569
2691
|
const resets = st.periodResetsAt ? ` · resets ${new Date(st.periodResetsAt).toLocaleDateString()}` : '';
|
|
2570
|
-
rows.push(['This period', `${
|
|
2692
|
+
rows.push(['This period', `${cr(st.periodCredits)} of ${cr(st.monthlyCredits)} credits${resets}`]);
|
|
2693
|
+
if (st.windowCredits) {
|
|
2694
|
+
const refill = st.windowResetsAt
|
|
2695
|
+
? `refills at ${new Date(st.windowResetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
2696
|
+
: 'opens with your next request';
|
|
2697
|
+
rows.push([`This ${st.windowHours || 5}h window`, `${cr(st.windowUsed)} of ${cr(st.windowCredits)} credits · ${refill}`]);
|
|
2698
|
+
}
|
|
2699
|
+
if (st.periodTokens) rows.push(['Behind them', `${fmtTokens(st.periodTokens)} tokens of input and replies`]);
|
|
2571
2700
|
}
|
|
2572
2701
|
if (u.planExpiresAt) rows.push(['Renews by', new Date(u.planExpiresAt).toLocaleDateString()]);
|
|
2573
2702
|
return rows;
|
|
@@ -2720,9 +2849,10 @@ const commands = {
|
|
|
2720
2849
|
'jaitus config theme <name> dark, light, ocean, daltonized, auto',
|
|
2721
2850
|
'jaitus config <key> --unset back to the default',
|
|
2722
2851
|
'jaitus config copyOnSelect <on|off> auto-copy mouse selection',
|
|
2852
|
+
'jaitus config defaultMode bypass start every session in bypass (ask, acceptEdits, plan; --unset = last mode used)',
|
|
2723
2853
|
],
|
|
2724
2854
|
detail: [
|
|
2725
|
-
'Settable: model, baseUrl, siteUrl, theme, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, mouse, statusLine,',
|
|
2855
|
+
'Settable: model, baseUrl, siteUrl, theme, defaultMode, copyOnSelect, scrollSpeed, wheelAccel, autoScroll, animations, autoCompact, trimToolResults, mouse, statusLine,',
|
|
2726
2856
|
'braveSearchKey, tavilyKey, serperKey. Stored in ~/.jaitus/settings.json.',
|
|
2727
2857
|
'',
|
|
2728
2858
|
'A search API key is the single biggest improvement available to web_search: the free',
|
|
@@ -2737,7 +2867,7 @@ const commands = {
|
|
|
2737
2867
|
const unset = rest.length === 1 && /^(--unset|none|unset)$/i.test(rest[0]);
|
|
2738
2868
|
const value = unset ? '' : rest.join(' ').trim();
|
|
2739
2869
|
const settings = loadSettings();
|
|
2740
|
-
const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'mouse', 'autoUpdate'];
|
|
2870
|
+
const ONOFF = ['copyOnSelect', 'wheelAccel', 'autoScroll', 'animations', 'autoCompact', 'trimToolResults', 'mouse', 'autoUpdate'];
|
|
2741
2871
|
const SECRET = ['braveSearchKey', 'tavilyKey', 'serperKey'];
|
|
2742
2872
|
if (!key) {
|
|
2743
2873
|
const e = endpoints();
|
|
@@ -2746,6 +2876,7 @@ const commands = {
|
|
|
2746
2876
|
website: e.siteUrl,
|
|
2747
2877
|
model: settings.model || null,
|
|
2748
2878
|
theme: settings.theme || 'auto',
|
|
2879
|
+
defaultMode: settings.defaultMode || null,
|
|
2749
2880
|
statusLine: settings.statusLine || null,
|
|
2750
2881
|
copyOnSelect: settings.copyOnSelect !== false,
|
|
2751
2882
|
scrollSpeed: settings.scrollSpeed ?? 1,
|
|
@@ -2753,6 +2884,7 @@ const commands = {
|
|
|
2753
2884
|
autoScroll: settings.autoScroll !== false,
|
|
2754
2885
|
animations: settings.animations !== false,
|
|
2755
2886
|
autoCompact: settings.autoCompact !== false,
|
|
2887
|
+
trimToolResults: settings.trimToolResults !== false,
|
|
2756
2888
|
mouse: settings.mouse !== false,
|
|
2757
2889
|
// Presence and length only. A key echoed into a transcript is a key that has
|
|
2758
2890
|
// been published, and the transcript is on disk.
|
|
@@ -2764,12 +2896,14 @@ const commands = {
|
|
|
2764
2896
|
['Website', data.website],
|
|
2765
2897
|
['Model', data.model || 'whatever the gateway lists first'],
|
|
2766
2898
|
['Theme', `${data.theme} (${[...THEME_NAMES, 'auto'].join(', ')})`],
|
|
2899
|
+
['Start mode', data.defaultMode || 'the last mode used (ask or auto-edit)'],
|
|
2767
2900
|
['Copy on select', data.copyOnSelect ? 'on (drag selects, auto-copies)' : 'off (Ctrl+Shift+C copies)'],
|
|
2768
2901
|
['Scroll speed', String(data.scrollSpeed)],
|
|
2769
2902
|
['Wheel accel', data.wheelAccel ? 'on' : 'off'],
|
|
2770
2903
|
['Auto-scroll', data.autoScroll ? 'on' : 'off'],
|
|
2771
2904
|
['Animations', data.animations ? 'on' : 'off'],
|
|
2772
|
-
['Auto-compact', data.autoCompact ? 'on (summarises
|
|
2905
|
+
['Auto-compact', data.autoCompact ? 'on (summarises at 78% of the context size the service allows)' : 'off'],
|
|
2906
|
+
['Trim old tool output', data.trimToolResults ? 'on (clears old outputs past ~40k tokens, keeps the last 6)' : 'off'],
|
|
2773
2907
|
['Mouse', data.mouse ? 'on (wheel scrolls, clicks act, drag or double-click copies)' : 'off (the terminal selects text itself)'],
|
|
2774
2908
|
['Search API', data.searchApi ? `${data.searchApi} (set)` : 'none — using free engines + npm/GitHub/MDN/Stack Overflow'],
|
|
2775
2909
|
['Status line', data.statusLine || '(unset — set a command to render one)'],
|
|
@@ -2777,13 +2911,13 @@ const commands = {
|
|
|
2777
2911
|
]));
|
|
2778
2912
|
return 0;
|
|
2779
2913
|
}
|
|
2780
|
-
if (!['model', 'baseUrl', 'siteUrl', 'theme', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
|
|
2781
|
-
fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
|
|
2914
|
+
if (!['model', 'baseUrl', 'siteUrl', 'theme', 'defaultMode', 'statusLine', ...ONOFF, ...SECRET, 'scrollSpeed'].includes(key)) {
|
|
2915
|
+
fail(`Unknown setting: ${key}. Try model, baseUrl, siteUrl, theme, defaultMode, statusLine, ${ONOFF.join(', ')}, ${SECRET.join(', ')} or scrollSpeed.`);
|
|
2782
2916
|
return 1;
|
|
2783
2917
|
}
|
|
2784
2918
|
// Clearing a value (there was no way: an empty value just printed the current one).
|
|
2785
2919
|
if (unset) {
|
|
2786
|
-
if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
|
|
2920
|
+
if (!['model', 'baseUrl', 'siteUrl', 'statusLine', 'theme', 'defaultMode', 'scrollSpeed', ...SECRET].includes(key)) { fail(`${key} is on/off — set it with on or off.`); return 1; }
|
|
2787
2921
|
delete settings[key];
|
|
2788
2922
|
saveSettings(settings);
|
|
2789
2923
|
ok(`${key} cleared — back to the default.`);
|
|
@@ -2799,6 +2933,15 @@ const commands = {
|
|
|
2799
2933
|
if (key === 'scrollSpeed') { out(String(settings.scrollSpeed ?? 1)); return 0; }
|
|
2800
2934
|
out(String(settings[key] ?? '')); return 0;
|
|
2801
2935
|
}
|
|
2936
|
+
if (key === 'defaultMode') {
|
|
2937
|
+
const mode = normaliseMode(value);
|
|
2938
|
+
if (!mode) { fail('Use ask, acceptEdits, plan or bypass (or --unset for the last mode used).'); return 1; }
|
|
2939
|
+
settings.defaultMode = mode;
|
|
2940
|
+
saveSettings(settings);
|
|
2941
|
+
ok(`defaultMode = ${mode} — every new session starts in it`);
|
|
2942
|
+
if (mode === 'bypassPermissions') warn('Writes and shell commands will run with no permission prompts in every session. Deny rules still apply.');
|
|
2943
|
+
return 0;
|
|
2944
|
+
}
|
|
2802
2945
|
if (key === 'theme') {
|
|
2803
2946
|
// The themes that exist (ocean was refused; plain was accepted and did nothing).
|
|
2804
2947
|
const names = [...THEME_NAMES, 'auto'];
|
package/src/sessions.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import crypto from 'node:crypto';
|
|
9
9
|
import { SESSIONS } from './config.js';
|
|
10
10
|
import { c } from './ui.js';
|
|
11
|
+
import { unifiedDiff } from './diff.js';
|
|
11
12
|
|
|
12
13
|
function safeId(id) {
|
|
13
14
|
return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 80);
|
|
@@ -145,7 +146,9 @@ export function replaySession(tui, messages) {
|
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
if (m.role !== 'assistant') continue;
|
|
148
|
-
|
|
149
|
+
// A plain-string assistant turn (a failed request's "[No reply — …]" note) was
|
|
150
|
+
// skipped entirely, as though the turn never happened.
|
|
151
|
+
const blocks = typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : Array.isArray(m.content) ? m.content : [];
|
|
149
152
|
const text = blocks.filter((b) => b?.type === 'text' && b.text).map((b) => b.text).join('\n\n');
|
|
150
153
|
if (text.trim()) {
|
|
151
154
|
if (structured) tui.assistant(text); else tui.print(`${c.brand('◆')} ${text}`, 'assistant');
|
|
@@ -161,7 +164,25 @@ export function replaySession(tui, messages) {
|
|
|
161
164
|
status: outcome ? (outcome.failed ? 'failed' : 'ok') : 'ok',
|
|
162
165
|
file: call.input?.path,
|
|
163
166
|
error: outcome?.failed ? outcome.text.split('\n')[0].slice(0, 200) : '',
|
|
167
|
+
// The whole result, so the row can be opened again after a resume.
|
|
168
|
+
full: outcome ? outcome.text.slice(0, 200_000) : '',
|
|
164
169
|
});
|
|
170
|
+
// An edit reads back as the change it made. The file's full before/after is not
|
|
171
|
+
// in the conversation, but the replaced text and its replacement are.
|
|
172
|
+
if (!outcome?.failed && typeof tui.diff === 'function') {
|
|
173
|
+
const input = call.input || {};
|
|
174
|
+
try {
|
|
175
|
+
if (call.name === 'edit_file') {
|
|
176
|
+
const pairs = (Array.isArray(input.edits) ? input.edits : [input])
|
|
177
|
+
.filter((e) => typeof e?.old === 'string' && typeof e?.new === 'string');
|
|
178
|
+
const patch = pairs.map((e) => unifiedDiff(e.old, e.new, { file: input.path })).filter(Boolean).join('\n');
|
|
179
|
+
if (patch) tui.diff(String(input.path || 'edit'), patch);
|
|
180
|
+
} else if (call.name === 'write_file' && typeof input.content === 'string') {
|
|
181
|
+
const patch = unifiedDiff('', input.content, { file: input.path });
|
|
182
|
+
if (patch) tui.diff(String(input.path || 'file'), patch);
|
|
183
|
+
}
|
|
184
|
+
} catch { /* a missing diff never blocks the replay */ }
|
|
185
|
+
}
|
|
165
186
|
} else {
|
|
166
187
|
tui.print(` ${c.accent('✓')} ${c.dim(`${call.name} ${label}`.slice(0, 100))}`, 'tool');
|
|
167
188
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Load BEFORE making a PDF or any printable/shareable document — report, proposal, invoice, resume/CV, letter, certificate, brochure, one-pager, handout, slide deck. Produces typeset, professional documents instead of plain text dumps.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Professional documents and PDFs
|
|
6
|
+
|
|
7
|
+
The reliable route is: **write semantic HTML with print CSS, then print it to PDF with a
|
|
8
|
+
headless browser.** It gives real typography, exact page control and is easy to revise.
|
|
9
|
+
Use a PDF library (reportlab, pdfkit, jsPDF) only if the project already uses one.
|
|
10
|
+
|
|
11
|
+
## 1. Plan the document
|
|
12
|
+
|
|
13
|
+
- What is it, who reads it, and what must they do after reading? Put that first.
|
|
14
|
+
- Pick a document direction that fits: corporate report (clean sans, blue-black ink, one
|
|
15
|
+
accent, data tables) · editorial/proposal (serif body, generous margins, pull quotes) ·
|
|
16
|
+
modern minimal (one sans, lots of white, strong headings) · formal letter/certificate
|
|
17
|
+
(centred serif, restrained ornament) · resume (dense but calm, two-column, no graphics
|
|
18
|
+
that ATS software cannot read).
|
|
19
|
+
- Outline the sections before writing; every page should have one clear purpose.
|
|
20
|
+
|
|
21
|
+
## 2. Page setup (print CSS)
|
|
22
|
+
|
|
23
|
+
```css
|
|
24
|
+
@page { size: A4; margin: 20mm 18mm 22mm; } /* or: size: Letter; */
|
|
25
|
+
@page :first { margin-top: 0; } /* full-bleed cover if wanted */
|
|
26
|
+
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
27
|
+
body { font: 10.5pt/1.55 "Source Serif 4", Georgia, serif; color: #1b1d22; }
|
|
28
|
+
h1, h2, h3 { font-family: "Inter Tight", "Helvetica Neue", Arial, sans-serif; line-height: 1.15; break-after: avoid; }
|
|
29
|
+
h2 { break-before: auto; margin: 18pt 0 6pt; }
|
|
30
|
+
table, figure, blockquote, .card { break-inside: avoid; }
|
|
31
|
+
p { orphans: 3; widows: 3; }
|
|
32
|
+
.page-break { break-before: page; }
|
|
33
|
+
a { color: inherit; text-decoration: none; border-bottom: 0.5pt solid currentColor; }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Use pt/mm for print, never viewport units. Body 10–11.5pt, headings on a scale
|
|
37
|
+
(e.g. 10.5 · 13 · 16 · 22 · 32pt). Line length ~65–80 characters; wide pages use two
|
|
38
|
+
columns (`columns: 2; column-gap: 8mm;`) or a wide margin column for notes.
|
|
39
|
+
- Colour: dark ink on white, ONE accent colour for headings rules, key numbers and table
|
|
40
|
+
headers, tinted greys for secondary text. Keep it printable in greyscale.
|
|
41
|
+
- Page numbers / running footer: put a footer element on each page section, or when the
|
|
42
|
+
browser supports it use `@page { @bottom-right { content: counter(page) " / " counter(pages); } }`.
|
|
43
|
+
|
|
44
|
+
## 3. Typesetting details that make it look professional
|
|
45
|
+
|
|
46
|
+
- A proper cover or title block: title, subtitle, author/organisation, date — aligned to a
|
|
47
|
+
grid, not just a big centred heading.
|
|
48
|
+
- Consistent vertical rhythm: spacing in multiples of the body line height.
|
|
49
|
+
- Tables: left-align text, right-align numbers with tabular figures
|
|
50
|
+
(`font-variant-numeric: tabular-nums`), light horizontal rules only, a bold or tinted
|
|
51
|
+
header row, units in the header, totals row emphasised. No full grid of borders.
|
|
52
|
+
- Numbers: thousands separators, consistent decimals, currency symbols (₹ 1,49,900 for
|
|
53
|
+
Indian format when the audience is Indian).
|
|
54
|
+
- Callouts/key figures as a small highlighted block, not colourful boxes everywhere.
|
|
55
|
+
- Charts: inline SVG (or Chart.js rendered before printing) with direct labels, no 3D, no
|
|
56
|
+
heavy gridlines, the accent colour for the series that matters and grey for the rest.
|
|
57
|
+
- Real content — no lorem ipsum, no "[Your Name]" left in unless it is a template.
|
|
58
|
+
- Invoices: seller/buyer blocks, invoice number, dates, line items table, tax lines, total,
|
|
59
|
+
payment details and terms — every legally expected field for the country.
|
|
60
|
+
- Resumes: name large, contact on one line, sections in a clear order, consistent date
|
|
61
|
+
alignment on the right, verbs and numbers in bullets, one page unless senior.
|
|
62
|
+
|
|
63
|
+
## 4. Render to PDF
|
|
64
|
+
|
|
65
|
+
Save the HTML (e.g. `report.html`) and print it with whichever browser is installed:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
# Windows (Edge ships with Windows)
|
|
69
|
+
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --headless --disable-gpu --no-pdf-header-footer --print-to-pdf="report.pdf" "file:///C:/path/to/report.html"
|
|
70
|
+
# Chrome (any OS)
|
|
71
|
+
chrome --headless --disable-gpu --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
|
|
72
|
+
# macOS Chrome
|
|
73
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --no-pdf-header-footer --print-to-pdf=report.pdf file:///abs/path/report.html
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If the project has Node and Playwright or Puppeteer, `page.pdf({ format: 'A4',
|
|
77
|
+
printBackground: true, preferCSSPageSize: true })` works the same. Web fonts must finish
|
|
78
|
+
loading before printing — with a headless browser add `--virtual-time-budget=5000`, or
|
|
79
|
+
embed the fonts locally.
|
|
80
|
+
|
|
81
|
+
## 5. Check before handing over
|
|
82
|
+
|
|
83
|
+
1. The PDF exists and opens; report its path and page count.
|
|
84
|
+
2. No heading stranded at the bottom of a page, no table row split, no blank last page.
|
|
85
|
+
3. Margins are even, nothing clipped at the edges, backgrounds printed.
|
|
86
|
+
4. Hierarchy is obvious at a glance; one accent colour; consistent spacing.
|
|
87
|
+
5. All placeholder text replaced; numbers, dates and names are correct.
|
|
88
|
+
|
|
89
|
+
For slide decks: one idea per slide, 16:9 (`@page { size: 297mm 167mm; margin: 0 }` with
|
|
90
|
+
one `section` per slide), a big headline that states the point, at most ~30 words of body,
|
|
91
|
+
visual consistency across slides, and slide numbers.
|