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.
Files changed (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. 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 { getStoredKey } = await import('./cli/auth.js');
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 ?? getStoredKey(cfgProvider ?? 'openai') ?? getStoredKey('openai') ?? getStoredKey('anthropic') ?? '';
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 = getStoredKey('anthropic');
118
- const openaiKey = getStoredKey('openai');
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 (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
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 (k.includes('SECRET') || k.includes('TOKEN') || k === 'ANTHROPIC_API_KEY' || k === 'OPENAI_API_KEY')
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 = 'blue';
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
- const atToken = /^@(\S*)$/.exec(input);
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
- setInput(`${top.name} `);
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
  }
@@ -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+ unread/);
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
@@ -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
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "yellow", paddingX: 1, marginY: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["\u26A0 approval needed \u2014 ", pending.toolName] }), _jsxs(Text, { color: "gray", 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: "yellow", 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: "red", children: [" ", editError] }) : null] })) : null, explain ? _jsxs(Text, { color: "cyan", 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: "green", children: "[y] once" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[a] session" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "green", children: "[A] always" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "red", children: "[n] deny" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "yellow", children: "[e] edit" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[f] full" }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "cyan", children: "[?] explain" }), _jsx(Text, { color: "gray", children: " (Enter = deny)" })] })] }));
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, the only new lines
68
- * are the ones already counted — the affordance flips to "jump to end". */
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;
@@ -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, the only new lines
92
- * are the ones already counted — the affordance flips to "jump to end". */
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
- void idle; // Ink can't capture the idle click; count is shown either way (§7.2)
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`;
@@ -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: "#9A9A9A";
11
+ readonly dim: "#6B6B6B";
12
12
  readonly guide: "#3A3A3A";
13
13
  readonly accent: "#FF6B1A";
14
14
  readonly accentSoft: "#FFF1E6";
15
- readonly ok: "#2FA36B";
16
- readonly err: "#E06C6C";
17
- readonly warn: "#D9A021";
18
- readonly info: "#6FA8DC";
19
- readonly diffAddBg: "#12250F";
20
- readonly diffDelBg: "#2A1212";
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: {};
@@ -8,19 +8,22 @@ export const tokens = {
8
8
  bgElevated: '#FAF7F2',
9
9
  fg: '#1A1A1A',
10
10
  soft: '#F5F5F5',
11
- dim: '#9A9A9A',
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 are deliberately distinct from the orange brand: ok reads
16
- // green, warn reads amber, err reads red. (Previously ok/warn reused the
17
- // accent hex and were indistinguishable in the transcript.)
18
- ok: '#2FA36B',
19
- err: '#E06C6C',
20
- warn: '#D9A021',
21
- info: '#6FA8DC',
22
- diffAddBg: '#12250F',
23
- diffDelBg: '#2A1212',
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
- export const spacing = { sidebar: 28, inspector: 36 };
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 = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",