klyro 1.0.6 → 1.0.7
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 +13 -0
- package/dist/agent/orchestrator.d.ts +7 -1
- package/dist/agent/orchestrator.js +18 -7
- package/dist/agent/runtime.d.ts +10 -0
- package/dist/agent/runtime.js +70 -13
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +2 -0
- package/dist/checkpoints/store.js +12 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.js +22 -16
- package/dist/cli/hooks.d.ts +21 -1
- package/dist/cli/hooks.js +34 -2
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +54 -17
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/parser.d.ts +1 -1
- package/dist/cli/slash/parser.js +6 -3
- package/dist/cli/update.d.ts +3 -1
- package/dist/cli/update.js +16 -1
- package/dist/context/accounting.d.ts +6 -0
- package/dist/context/accounting.js +8 -2
- package/dist/context/compaction.d.ts +2 -1
- package/dist/context/compaction.js +39 -12
- package/dist/context/memory.d.ts +11 -0
- package/dist/context/memory.js +47 -9
- package/dist/eval/harness.d.ts +19 -2
- package/dist/eval/harness.js +72 -7
- package/dist/index.js +90 -4
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/config.d.ts +28 -0
- package/dist/mcp/config.js +65 -0
- package/dist/mcp/registry.d.ts +13 -7
- package/dist/mcp/registry.js +90 -14
- package/dist/mcp/remote.d.ts +7 -0
- package/dist/mcp/remote.js +56 -2
- package/dist/mcp/sse.d.ts +42 -0
- package/dist/mcp/sse.js +310 -0
- package/dist/persistence/audit.d.ts +15 -3
- package/dist/persistence/audit.js +84 -13
- package/dist/persistence/store.d.ts +9 -0
- package/dist/persistence/store.js +17 -0
- package/dist/policy/engine.js +9 -0
- package/dist/providers.js +4 -4
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +35 -5
- package/dist/tui/app.test.js +3 -2
- package/dist/tui/approval.js +3 -1
- package/dist/tui/scroll-model.d.ts +2 -2
- package/dist/tui/scroll-model.js +9 -3
- package/dist/tui/tokens.d.ts +8 -11
- package/dist/tui/tokens.js +18 -11
- package/package.json +1 -1
package/dist/providers.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function resolveProvider() {
|
|
|
85
85
|
}
|
|
86
86
|
// Persisted config — the "set once, works in every terminal" layer.
|
|
87
87
|
{
|
|
88
|
-
const {
|
|
88
|
+
const { getStoredKeyAsync } = await import('./cli/auth.js');
|
|
89
89
|
if (cfgBase) {
|
|
90
90
|
try {
|
|
91
91
|
assertSafeBaseURL(cfgBase, { allowInsecure });
|
|
@@ -96,7 +96,7 @@ export async function resolveProvider() {
|
|
|
96
96
|
return null;
|
|
97
97
|
}
|
|
98
98
|
{
|
|
99
|
-
const key = envKey ?? cfgKey ??
|
|
99
|
+
const key = envKey ?? cfgKey ?? (await getStoredKeyAsync(cfgProvider ?? 'openai')) ?? (await getStoredKeyAsync('openai')) ?? (await getStoredKeyAsync('anthropic')) ?? '';
|
|
100
100
|
return {
|
|
101
101
|
baseURL: normalizeBaseURL(cfgBase),
|
|
102
102
|
apiKey: key,
|
|
@@ -114,8 +114,8 @@ export async function resolveProvider() {
|
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
// Stored keys alone (e.g. `klyro login` with defaults, or key-only setup).
|
|
117
|
-
const anthropicKey =
|
|
118
|
-
const openaiKey =
|
|
117
|
+
const anthropicKey = await getStoredKeyAsync('anthropic');
|
|
118
|
+
const openaiKey = await getStoredKeyAsync('openai');
|
|
119
119
|
if (cfgProvider === 'anthropic' && anthropicKey) {
|
|
120
120
|
return {
|
|
121
121
|
baseURL: normalizeBaseURL('https://api.anthropic.com/v1'),
|
|
@@ -17,6 +17,19 @@ declare const InputSchema: z.ZodObject<{
|
|
|
17
17
|
}, z.core.$strip>;
|
|
18
18
|
/** Reset the sticky cwd (e.g. after /cd changes the session directory). */
|
|
19
19
|
export declare function resetPersistentCwd(): void;
|
|
20
|
+
/** Bounds for the tool-output dir (G5d) — oldest-first LRU prune. */
|
|
21
|
+
export declare const TOOL_OUTPUT_MAX_FILES = 50;
|
|
22
|
+
export declare const TOOL_OUTPUT_MAX_BYTES: number;
|
|
23
|
+
/**
|
|
24
|
+
* Prune a directory: delete oldest files until both the file-count and
|
|
25
|
+
* total-bytes bounds hold. Pure helper (exported for unit tests).
|
|
26
|
+
*/
|
|
27
|
+
export declare function pruneDir(dir: string, maxFiles: number, maxBytes: number): Promise<string[]>;
|
|
28
|
+
/**
|
|
29
|
+
* Prune `~/.klyro/tool-output/` (≤50 files, ≤50MB). Best-effort — never
|
|
30
|
+
* throws, failures just leave the dir unpruned until the next write.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pruneToolOutput(): Promise<void>;
|
|
20
33
|
export declare function filteredEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
|
|
21
34
|
/**
|
|
22
35
|
* Protected-branch push helpers (mirror policy engine shellDenyRule).
|
|
@@ -41,14 +41,68 @@ let persistentCwd = null;
|
|
|
41
41
|
export function resetPersistentCwd() {
|
|
42
42
|
persistentCwd = null;
|
|
43
43
|
}
|
|
44
|
+
/** Bounds for the tool-output dir (G5d) — oldest-first LRU prune. */
|
|
45
|
+
export const TOOL_OUTPUT_MAX_FILES = 50;
|
|
46
|
+
export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024 * 1024;
|
|
47
|
+
/**
|
|
48
|
+
* Prune a directory: delete oldest files until both the file-count and
|
|
49
|
+
* total-bytes bounds hold. Pure helper (exported for unit tests).
|
|
50
|
+
*/
|
|
51
|
+
export async function pruneDir(dir, maxFiles, maxBytes) {
|
|
52
|
+
const fs = await import('node:fs/promises');
|
|
53
|
+
const path = await import('node:path');
|
|
54
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
55
|
+
const files = [];
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
if (!e.isFile())
|
|
58
|
+
continue;
|
|
59
|
+
try {
|
|
60
|
+
const st = await fs.stat(path.join(dir, e.name));
|
|
61
|
+
files.push({ file: path.join(dir, e.name), mtimeMs: st.mtimeMs, size: st.size });
|
|
62
|
+
}
|
|
63
|
+
catch { /* raced deletion */ }
|
|
64
|
+
}
|
|
65
|
+
// Oldest first.
|
|
66
|
+
files.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
67
|
+
const deleted = [];
|
|
68
|
+
while (files.length > maxFiles)
|
|
69
|
+
deleted.push(files.shift().file);
|
|
70
|
+
let total = files.reduce((s, f) => s + f.size, 0);
|
|
71
|
+
while (total > maxBytes && files.length > 0) {
|
|
72
|
+
const f = files.shift();
|
|
73
|
+
total -= f.size;
|
|
74
|
+
deleted.push(f.file);
|
|
75
|
+
}
|
|
76
|
+
await Promise.all(deleted.map((f) => fs.rm(f, { force: true }).catch(() => undefined)));
|
|
77
|
+
return deleted;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Prune `~/.klyro/tool-output/` (≤50 files, ≤50MB). Best-effort — never
|
|
81
|
+
* throws, failures just leave the dir unpruned until the next write.
|
|
82
|
+
*/
|
|
83
|
+
export async function pruneToolOutput() {
|
|
84
|
+
try {
|
|
85
|
+
await pruneDir(FULL_OUTPUT_DIR, TOOL_OUTPUT_MAX_FILES, TOOL_OUTPUT_MAX_BYTES);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* dir missing or unreadable — nothing to prune */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
44
91
|
// Filtered env — only safe vars, secrets stripped
|
|
45
92
|
const ALLOWED_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
|
|
93
|
+
/**
|
|
94
|
+
* Secret-name test: SECRET/TOKEN/KEY anywhere in the name. KEY covers
|
|
95
|
+
* allowed-prefix vars such as NODE_*_API_KEY / NPM_*_KEY that the prefix
|
|
96
|
+
* allowlist would otherwise admit. Case-insensitive to catch mixed-case
|
|
97
|
+
* exporters (e.g. npm_config_key). Mirrors verification/registry.ts.
|
|
98
|
+
*/
|
|
99
|
+
const SECRET_NAME_RE = /SECRET|TOKEN|KEY/i;
|
|
46
100
|
export function filteredEnv(extra) {
|
|
47
101
|
const out = {};
|
|
48
102
|
for (const [k, v] of Object.entries(process.env)) {
|
|
49
103
|
if (k.startsWith('KLYRO_') && k.includes('API_KEY'))
|
|
50
104
|
continue;
|
|
51
|
-
if (
|
|
105
|
+
if (SECRET_NAME_RE.test(k) || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
|
|
52
106
|
continue;
|
|
53
107
|
if (ALLOWED_ENV_PREFIXES.some((p) => k.startsWith(p)) || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP') {
|
|
54
108
|
out[k] = v;
|
|
@@ -59,7 +113,7 @@ export function filteredEnv(extra) {
|
|
|
59
113
|
for (const [k, v] of Object.entries(extra)) {
|
|
60
114
|
if (k === 'NODE_OPTIONS' || k === 'LD_PRELOAD' || k === 'LD_LIBRARY_PATH')
|
|
61
115
|
continue;
|
|
62
|
-
if (
|
|
116
|
+
if (SECRET_NAME_RE.test(k) || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
|
|
63
117
|
continue;
|
|
64
118
|
if (ALLOWED_ENV_PREFIXES.some((p) => k.startsWith(p)) || k === 'PATH' || k === 'PWD' || k === 'TMPDIR' || k === 'TEMP') {
|
|
65
119
|
out[k] = v;
|
|
@@ -131,6 +185,12 @@ const DANGEROUS_PATTERNS = [
|
|
|
131
185
|
{ pattern: /\bwget\b.*(?:\s--method=POST\b|\s--body-data\b)/i, reason: 'exfiltration: wget --method=POST/--body-data denied' },
|
|
132
186
|
{ pattern: /\bInvoke-RestMethod\b/i, reason: 'exfiltration: Invoke-RestMethod denied' },
|
|
133
187
|
{ pattern: /\bStart-BitsTransfer\b/i, reason: 'exfiltration: Start-BitsTransfer denied' },
|
|
188
|
+
// Windows has no native sandbox backend, so PowerShell network primitives
|
|
189
|
+
// are the primary upload/exfil vectors there — denied outright (partial
|
|
190
|
+
// mitigation; genuinely honest limits are documented in the audit, the real
|
|
191
|
+
// boundary is the bwrap/landlock sandbox on POSIX).
|
|
192
|
+
{ pattern: /\b(Invoke-WebRequest|iwr)\b/i, reason: 'exfiltration: Invoke-WebRequest/iwr denied (no win32 sandbox backend)' },
|
|
193
|
+
{ pattern: /\bSystem\.Net\.WebClient\b/i, reason: 'exfiltration: System.Net.WebClient denied (no win32 sandbox backend)' },
|
|
134
194
|
// Protected-branch push deny (mirrors policy engine shellDenyRule).
|
|
135
195
|
// Explicit `git push ... main|master|production`. Honored only when
|
|
136
196
|
// KLYRO_ALLOW_MAIN_PUSH=1 is NOT set (see findBlockedReason). Bare
|
|
@@ -426,6 +486,8 @@ export const shellExecTool = defineTool({
|
|
|
426
486
|
const fullPath = path.join(FULL_OUTPUT_DIR, `${outId}.txt`);
|
|
427
487
|
const fullContent = `STDOUT:\n${fullOutBuf.toString('utf-8')}\n\nSTDERR:\n${fullErrBuf.toString('utf-8')}\n`;
|
|
428
488
|
await fs.writeFile(fullPath, fullContent, 'utf-8');
|
|
489
|
+
// G5d: bound the tool-output dir (~50 files / 50MB LRU). Best-effort.
|
|
490
|
+
await pruneToolOutput();
|
|
429
491
|
}
|
|
430
492
|
catch { /* ignore */ }
|
|
431
493
|
// Head+tail truncation: keep first 15k and last 15k chars
|
package/dist/tui/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
10
10
|
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
11
11
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
12
12
|
import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
|
|
13
|
+
import { loadMcpServers } from '../mcp/config.js';
|
|
13
14
|
import { tokens, g } from './tokens.js';
|
|
14
15
|
import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, badgeLabel, } from './scroll-model.js';
|
|
15
16
|
import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
|
|
@@ -129,7 +130,7 @@ function MarkdownText({ text, dim, width }) {
|
|
|
129
130
|
else if (p.color)
|
|
130
131
|
color = p.color;
|
|
131
132
|
else if (p.href)
|
|
132
|
-
color =
|
|
133
|
+
color = tokens.colors.info;
|
|
133
134
|
else if (p.dim)
|
|
134
135
|
color = dimColor;
|
|
135
136
|
else if (p.code)
|
|
@@ -395,12 +396,16 @@ export function App(props) {
|
|
|
395
396
|
}
|
|
396
397
|
}, []);
|
|
397
398
|
// Flush pending tail before unmount so no trailing delta is lost.
|
|
399
|
+
// (React may drop post-unmount setStates; the turn-end effect above is
|
|
400
|
+
// the primary guarantee — this covers paths that unmount mid-stream.)
|
|
398
401
|
useEffect(() => () => {
|
|
402
|
+
flushStream();
|
|
403
|
+
flushThinking();
|
|
399
404
|
if (streamTimer.current)
|
|
400
405
|
clearTimeout(streamTimer.current);
|
|
401
406
|
if (thinkingTimer.current)
|
|
402
407
|
clearTimeout(thinkingTimer.current);
|
|
403
|
-
}, []);
|
|
408
|
+
}, [flushStream, flushThinking]);
|
|
404
409
|
const width = stdout?.columns ?? 100;
|
|
405
410
|
const height = stdout?.rows ?? 30;
|
|
406
411
|
const isFullscreen = props.isFullscreen ?? false;
|
|
@@ -696,8 +701,21 @@ export function App(props) {
|
|
|
696
701
|
return [];
|
|
697
702
|
}
|
|
698
703
|
}, [props.cwd]);
|
|
704
|
+
// MCP prompt stems: `/mcp__<server>__` completes server names so prompt
|
|
705
|
+
// commands are discoverable; full prompt names need a live connection
|
|
706
|
+
// (see `klyro mcp prompts`), so the stem carries that hint.
|
|
707
|
+
const mcpStems = useMemo(() => {
|
|
708
|
+
if (!input.startsWith('/mcp__'))
|
|
709
|
+
return [];
|
|
710
|
+
try {
|
|
711
|
+
return Object.keys(loadMcpServers(props.cwd).servers).map((s) => ({ name: `mcp__${s}__`, hint: 'mcp server — run `mcp prompts <server>` for names' }));
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return [];
|
|
715
|
+
}
|
|
716
|
+
}, [props.cwd, input]);
|
|
699
717
|
const slashSuggest = input.startsWith('/') && !input.slice(1).includes(' ') && !awaitingApproval
|
|
700
|
-
? suggestCommands(input, 6, customCmds)
|
|
718
|
+
? suggestCommands(input, 6, [...customCmds, ...mcpStems])
|
|
701
719
|
: [];
|
|
702
720
|
// `@` file completion — fuzzy over the workspace file index while typing
|
|
703
721
|
// a mention token (no space yet). Directories carry trailing `/`.
|
|
@@ -709,7 +727,11 @@ export function App(props) {
|
|
|
709
727
|
return [];
|
|
710
728
|
}
|
|
711
729
|
}, [props.cwd]);
|
|
712
|
-
|
|
730
|
+
// @-completion now matches a mention at ANY position (mid-line included),
|
|
731
|
+
// not just a whole-input `^@token`. Complete-at-cursor semantics: the token
|
|
732
|
+
// ends at the end of input (Tab-completion surface). `foo bar @ut` completes
|
|
733
|
+
// just like a bare `@ut` did.
|
|
734
|
+
const atToken = /(?:^|\s)@(\S*)$/.exec(input);
|
|
713
735
|
const pathSuggest = atToken && !awaitingApproval
|
|
714
736
|
? suggestCommands(`/${atToken[1] ?? ''}`, 6, fileIndex.map((f) => ({ name: f, hint: 'file' }))).map((d) => ({ ...d, name: `@${d.name}` }))
|
|
715
737
|
: [];
|
|
@@ -728,7 +750,15 @@ export function App(props) {
|
|
|
728
750
|
}
|
|
729
751
|
if ((key.tab || inputStr === '\t') && pathSuggest.length > 0) {
|
|
730
752
|
const top = pathSuggest[0];
|
|
731
|
-
|
|
753
|
+
// Mid-line @: splice the completed filename into the mention token,
|
|
754
|
+
// preserving the message prefix (`look at @ut` → `look at @util.ts`).
|
|
755
|
+
const m = /(?:^|\s)@(\S*)$/.exec(input);
|
|
756
|
+
if (m && m[1] !== undefined) {
|
|
757
|
+
setInput(input.slice(0, input.length - m[1].length) + top.name.slice(1) + ' ');
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
setInput(`${top.name} `);
|
|
761
|
+
}
|
|
732
762
|
setVimCursor(null);
|
|
733
763
|
return;
|
|
734
764
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -603,8 +603,9 @@ describe('App', () => {
|
|
|
603
603
|
stdin.write(KEY_HOME);
|
|
604
604
|
await waitForMatch(lastFrame, /⇅ 0\/\d+/);
|
|
605
605
|
hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
|
|
606
|
-
// Badge counts lines grown while pinned.
|
|
607
|
-
await waitForMatch(lastFrame, /↓ \d
|
|
606
|
+
// Badge counts lines grown while pinned (idle shows "jump to end" affordance).
|
|
607
|
+
await waitForMatch(lastFrame, /↓ \d+/);
|
|
608
|
+
expect(lastFrame() ?? '').toContain('· jump to end');
|
|
608
609
|
hooks.clearTranscript();
|
|
609
610
|
// Wait for transcript content to actually clear (no stale MSG-* items) —
|
|
610
611
|
// this is the "no stale badge" invariant: after a clear, the view must
|
package/dist/tui/approval.js
CHANGED
|
@@ -22,6 +22,7 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
22
22
|
import { useEffect, useState } from 'react';
|
|
23
23
|
import { Box, Text, useInput } from 'ink';
|
|
24
24
|
import { approvalChoiceForKey, sanitizeForPrompt } from '../policy/approval.js';
|
|
25
|
+
import { tokens } from './tokens.js';
|
|
25
26
|
class TuiApprovalBridge {
|
|
26
27
|
pending = null;
|
|
27
28
|
// Fan-out set: BOTH the App (awaitingApproval gate) and the ApprovalModal
|
|
@@ -159,5 +160,6 @@ export function ApprovalModal({ bridge }) {
|
|
|
159
160
|
const clean = sanitizeForPrompt(s);
|
|
160
161
|
return !full && clean.length > n ? clean.slice(0, n) + '…' : clean;
|
|
161
162
|
};
|
|
162
|
-
|
|
163
|
+
const c = tokens.colors;
|
|
164
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: c.warn, paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: c.warn, bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: c.dim, children: [" reason: \"", trunc(pending.reason), "\""] }), pending.summary ? _jsxs(Text, { children: [" \"", trunc(pending.summary), "\""] }) : null, editing !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: c.warn, bold: true, children: " editing input JSON (Enter = submit, Esc = back):" }), _jsxs(Text, { wrap: "wrap", children: [" ", (editing.length > 1500 ? editing.slice(0, 1500) + '…' : editing) + '▌'] }), editError ? _jsxs(Text, { color: c.err, children: [" ", editError] }) : null] })) : null, explain ? _jsxs(Text, { color: c.info, children: [" Explain: This tool will ", pending.toolName, " with the shown args. [y] once, [a] session, [A] always\u2192settings, [n] deny, [e] edit, [f] full text, [?] toggle help."] }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: c.ok, children: "[y] once" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.ok, children: "[a] session" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.ok, children: "[A] always" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.err, children: "[n] deny" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.warn, children: "[e] edit" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.info, children: "[f] full" }), _jsx(Text, { color: c.dim, children: " " }), _jsx(Text, { color: c.info, children: "[?] explain" }), _jsx(Text, { color: c.dim, children: " (Enter = deny)" })] })] }));
|
|
163
165
|
}
|
|
@@ -64,6 +64,6 @@ export interface Resolved {
|
|
|
64
64
|
/** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
|
|
65
65
|
export declare function resolveTopRow(s: ScrollState, ctx: ScrollCtx): Resolved;
|
|
66
66
|
/** Badge label per §7.2 (Claude Code style: "N unread" / "jump to end").
|
|
67
|
-
* Empty string = hidden. `idle` = agent done streaming
|
|
68
|
-
*
|
|
67
|
+
* Empty string = hidden. `idle` = agent done streaming; when idle the badge
|
|
68
|
+
* flips to the actionable "jump to end" affordance instead of a raw count. */
|
|
69
69
|
export declare function badgeLabel(atBottom: boolean, newSinceUnstick: number, idle: boolean): string;
|
package/dist/tui/scroll-model.js
CHANGED
|
@@ -88,12 +88,18 @@ export function resolveTopRow(s, ctx) {
|
|
|
88
88
|
return { topRow, atBottom: topRow >= maxTop - FOLLOW_EPSILON };
|
|
89
89
|
}
|
|
90
90
|
/** Badge label per §7.2 (Claude Code style: "N unread" / "jump to end").
|
|
91
|
-
* Empty string = hidden. `idle` = agent done streaming
|
|
92
|
-
*
|
|
91
|
+
* Empty string = hidden. `idle` = agent done streaming; when idle the badge
|
|
92
|
+
* flips to the actionable "jump to end" affordance instead of a raw count. */
|
|
93
93
|
export function badgeLabel(atBottom, newSinceUnstick, idle) {
|
|
94
94
|
if (atBottom || newSinceUnstick <= 0)
|
|
95
95
|
return '';
|
|
96
|
-
|
|
96
|
+
// When the agent is idle (no more lines arriving), show the actionable
|
|
97
|
+
// affordance; otherwise keep the live unread count.
|
|
98
|
+
if (idle) {
|
|
99
|
+
if (newSinceUnstick >= 1000)
|
|
100
|
+
return '↓ 999+ · jump to end';
|
|
101
|
+
return `↓ ${newSinceUnstick} · jump to end`;
|
|
102
|
+
}
|
|
97
103
|
if (newSinceUnstick >= 1000)
|
|
98
104
|
return '↓ 999+ unread';
|
|
99
105
|
return `↓ ${newSinceUnstick} unread`;
|
package/dist/tui/tokens.d.ts
CHANGED
|
@@ -8,16 +8,16 @@ export declare const tokens: {
|
|
|
8
8
|
readonly bgElevated: "#FAF7F2";
|
|
9
9
|
readonly fg: "#1A1A1A";
|
|
10
10
|
readonly soft: "#F5F5F5";
|
|
11
|
-
readonly dim: "#
|
|
11
|
+
readonly dim: "#6B6B6B";
|
|
12
12
|
readonly guide: "#3A3A3A";
|
|
13
13
|
readonly accent: "#FF6B1A";
|
|
14
14
|
readonly accentSoft: "#FFF1E6";
|
|
15
|
-
readonly ok: "#
|
|
16
|
-
readonly err: "#
|
|
17
|
-
readonly warn: "#
|
|
18
|
-
readonly info: "#
|
|
19
|
-
readonly diffAddBg: "#
|
|
20
|
-
readonly diffDelBg: "#
|
|
15
|
+
readonly ok: "#1B7A4D";
|
|
16
|
+
readonly err: "#B3261E";
|
|
17
|
+
readonly warn: "#8A5A00";
|
|
18
|
+
readonly info: "#1F6FB2";
|
|
19
|
+
readonly diffAddBg: "#DFF5DF";
|
|
20
|
+
readonly diffDelBg: "#F9DEDC";
|
|
21
21
|
};
|
|
22
22
|
readonly ansi: {
|
|
23
23
|
readonly accent: "yellowBright";
|
|
@@ -89,7 +89,4 @@ export declare const glyphAscii: {
|
|
|
89
89
|
};
|
|
90
90
|
export declare function isAsciiMode(): boolean;
|
|
91
91
|
export declare function g(name: keyof typeof glyphs): string;
|
|
92
|
-
export declare const spacing: {
|
|
93
|
-
readonly sidebar: 28;
|
|
94
|
-
readonly inspector: 36;
|
|
95
|
-
};
|
|
92
|
+
export declare const spacing: {};
|
package/dist/tui/tokens.js
CHANGED
|
@@ -8,19 +8,22 @@ export const tokens = {
|
|
|
8
8
|
bgElevated: '#FAF7F2',
|
|
9
9
|
fg: '#1A1A1A',
|
|
10
10
|
soft: '#F5F5F5',
|
|
11
|
-
dim:
|
|
11
|
+
// dim/guide raised for WCAG AA on white: #6B6B6B ≈ 4.86:1 (was #9A9A9A ≈
|
|
12
|
+
// 2.9:1). guide stays the muted row glyph and is kept readable.
|
|
13
|
+
dim: '#6B6B6B',
|
|
12
14
|
guide: '#3A3A3A',
|
|
13
15
|
accent: '#FF6B1A',
|
|
14
16
|
accentSoft: '#FFF1E6',
|
|
15
|
-
// Status hues
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
// Status hues deliberately distinct from the orange brand. These are
|
|
18
|
+
// darkened for AA 4.5:1 on white (they were ~2.4-3:1).
|
|
19
|
+
ok: '#1B7A4D',
|
|
20
|
+
err: '#B3261E',
|
|
21
|
+
warn: '#8A5A00',
|
|
22
|
+
info: '#1F6FB2',
|
|
23
|
+
// Diff rollover backgrounds are LIGHT-mode hexes (the TUI bg is white);
|
|
24
|
+
// the dark hexes here were legacy dark-theme leftovers.
|
|
25
|
+
diffAddBg: '#DFF5DF',
|
|
26
|
+
diffDelBg: '#F9DEDC',
|
|
24
27
|
},
|
|
25
28
|
ansi: {
|
|
26
29
|
accent: 'yellowBright',
|
|
@@ -98,4 +101,8 @@ export function g(name) {
|
|
|
98
101
|
return glyphAscii[name] ?? glyphs[name];
|
|
99
102
|
return glyphs[name];
|
|
100
103
|
}
|
|
101
|
-
|
|
104
|
+
// Sidebar/inspector pixel widths live in the TUI_DESIGN doc, not here —
|
|
105
|
+
// the old `spacing = {sidebar: 28, inspector: 36}` held dead numbers (the
|
|
106
|
+
// sidebar layout is unused in the render). Keep a placeholder so old imports
|
|
107
|
+
// don't break, but route new code to the doc.
|
|
108
|
+
export const spacing = {};
|