klyro 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/orchestrator.d.ts +27 -4
- package/dist/agent/orchestrator.js +51 -4
- package/dist/agent/runtime.js +5 -1
- package/dist/cli/markdown.js +29 -1
- package/dist/context/tokenizer.d.ts +18 -0
- package/dist/context/tokenizer.js +50 -1
- package/dist/events/bus.d.ts +5 -0
- package/dist/events/bus.js +10 -0
- package/dist/tools/shell/sandbox.d.ts +4 -3
- package/dist/tools/shell/sandbox.js +23 -3
- package/dist/tui/app.js +20 -1
- package/dist/tui/app.test.js +18 -12
- package/dist/tui/approval.test.js +20 -3
- package/dist/tui/markdown.d.ts +13 -0
- package/dist/tui/markdown.js +169 -2
- package/dist/tui/scroll-flow.test.js +3 -1
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* The compact result is a `ChildSummary` — a `ToolResult` the parent model
|
|
13
13
|
* can act on — never the full child transcript.
|
|
14
14
|
*/
|
|
15
|
-
import type { RuntimeDeps, RuntimeEvent } from './runtime.js';
|
|
15
|
+
import type { RuntimeDeps, RunOptions, RuntimeEvent } from './runtime.js';
|
|
16
16
|
import type { ToolResult } from '../tools/types.js';
|
|
17
17
|
import { TaskManager, type TaskRecord, type TaskStatus, type TaskSummary } from './task-manager.js';
|
|
18
18
|
import { WorkerSpawner } from './worker-spawner.js';
|
|
@@ -151,9 +151,11 @@ export interface OrchestratorOpts {
|
|
|
151
151
|
taskManager?: TaskManager;
|
|
152
152
|
workerSpawner?: WorkerSpawner;
|
|
153
153
|
/**
|
|
154
|
-
* True when the parent is the interactive TUI. TUI children
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* True when the parent is the interactive TUI. TUI children run
|
|
155
|
+
* process-isolated *unless* they may need to surface an approval prompt to
|
|
156
|
+
* the operator (see childCanIsolate) — the Ink bridge is tied to the parent
|
|
157
|
+
* terminal, so a prompting child must stay in-process. Headless/CLI children
|
|
158
|
+
* always isolate. Defaults to false.
|
|
157
159
|
*/
|
|
158
160
|
isTui?: boolean;
|
|
159
161
|
}
|
|
@@ -189,6 +191,27 @@ export declare class AgentOrchestrator {
|
|
|
189
191
|
getAgent(id: string): AgentDefinition | undefined;
|
|
190
192
|
/** Build the bridge the parent's runtime hands to tools. */
|
|
191
193
|
bridgeFor(parent: ParentContextRef): AgentSpawnBridge;
|
|
194
|
+
/**
|
|
195
|
+
* R2 — decide whether a child may run process-isolated.
|
|
196
|
+
*
|
|
197
|
+
* The only thing that forces a child to stay in-process is the possibility
|
|
198
|
+
* of an interactive approval prompt: the Ink bridge lives in the parent
|
|
199
|
+
* terminal, so a subprocess could never ask. A child that cannot prompt is
|
|
200
|
+
* therefore free to isolate.
|
|
201
|
+
*
|
|
202
|
+
* A child cannot prompt when any of these hold:
|
|
203
|
+
* - it is readonly (no write/execute tools → no `ask` on those),
|
|
204
|
+
* - the parent is not a TUI (no bridge to reach in the first place), or
|
|
205
|
+
* - the child's toolset contains no tool the policy can put in `ask`.
|
|
206
|
+
*
|
|
207
|
+
* Isolation is deliberately conservative here: when in doubt we keep the
|
|
208
|
+
* child in-process, because a stranded prompt is a hang and a hang is worse
|
|
209
|
+
* than lost isolation. Public for direct unit testing.
|
|
210
|
+
*/
|
|
211
|
+
childCanIsolate(resolved: {
|
|
212
|
+
allowed: ReadonlySet<string>;
|
|
213
|
+
readonly: boolean;
|
|
214
|
+
}, def: AgentDefinition, childOptions: RunOptions): boolean;
|
|
192
215
|
/** Compute a child's effective capabilities from the parent's own. */
|
|
193
216
|
private resolveChild;
|
|
194
217
|
/**
|
|
@@ -170,6 +170,45 @@ export class AgentOrchestrator {
|
|
|
170
170
|
applyTask: (taskId) => this.applyTask(taskId),
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* R2 — decide whether a child may run process-isolated.
|
|
175
|
+
*
|
|
176
|
+
* The only thing that forces a child to stay in-process is the possibility
|
|
177
|
+
* of an interactive approval prompt: the Ink bridge lives in the parent
|
|
178
|
+
* terminal, so a subprocess could never ask. A child that cannot prompt is
|
|
179
|
+
* therefore free to isolate.
|
|
180
|
+
*
|
|
181
|
+
* A child cannot prompt when any of these hold:
|
|
182
|
+
* - it is readonly (no write/execute tools → no `ask` on those),
|
|
183
|
+
* - the parent is not a TUI (no bridge to reach in the first place), or
|
|
184
|
+
* - the child's toolset contains no tool the policy can put in `ask`.
|
|
185
|
+
*
|
|
186
|
+
* Isolation is deliberately conservative here: when in doubt we keep the
|
|
187
|
+
* child in-process, because a stranded prompt is a hang and a hang is worse
|
|
188
|
+
* than lost isolation. Public for direct unit testing.
|
|
189
|
+
*/
|
|
190
|
+
childCanIsolate(resolved, def, childOptions) {
|
|
191
|
+
// Headless parents have no approval bridge — isolation is always safe.
|
|
192
|
+
if (!this.isTui)
|
|
193
|
+
return true;
|
|
194
|
+
// Readonly agents never write/execute, so never prompt.
|
|
195
|
+
if (resolved.readonly)
|
|
196
|
+
return true;
|
|
197
|
+
// A child with an inherited bridge (grandchildren possible) must stay
|
|
198
|
+
// in-process: its own children need the bridge chain.
|
|
199
|
+
if (childOptions.agentBridge)
|
|
200
|
+
return false;
|
|
201
|
+
// Any tool in the child's set that the policy can escalate to `ask`
|
|
202
|
+
// pins it in-process. `execute` is the class that most commonly prompts
|
|
203
|
+
// (shell_exec), so its presence is the deciding signal alongside writes.
|
|
204
|
+
const prompting = new Set(['shell_exec', 'write_file', 'edit_file', 'multi_edit', 'apply_patch', 'run_verify']);
|
|
205
|
+
for (const t of resolved.allowed) {
|
|
206
|
+
if (prompting.has(t))
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
void def;
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
173
212
|
/** Compute a child's effective capabilities from the parent's own. */
|
|
174
213
|
resolveChild(def, parent, registryTools) {
|
|
175
214
|
const input = {
|
|
@@ -348,10 +387,18 @@ export class AgentOrchestrator {
|
|
|
348
387
|
depth: childDepth,
|
|
349
388
|
...(typeof childModel === 'string' ? { model: childModel } : {}),
|
|
350
389
|
});
|
|
351
|
-
// G2 — process isolation for
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
390
|
+
// G2/R2 — process isolation for sub-agents. A child can be spawned as a
|
|
391
|
+
// real OS process whenever it will never need to surface an interactive
|
|
392
|
+
// approval prompt to the operator. Every child that might prompt must stay
|
|
393
|
+
// in-process so the TUI approval bridge (tied to the parent terminal) can
|
|
394
|
+
// reach the operator. Headless/readonly/DenyAll children — which is the
|
|
395
|
+
// ordinary case — isolate into a subprocess. The blanket exclusion of ALL
|
|
396
|
+
// TUI children (V1) is replaced by this capability-aware rule, so a TUI
|
|
397
|
+
// session with readonly or non-interactive children gets real isolation
|
|
398
|
+
// too. Explicitly opt out with KLYRO_WORKER=0.
|
|
399
|
+
// See orchestratorOpts.isTui, capabilities.resolveCapabilities, and
|
|
400
|
+
// child-worker.buildChildDeps (DenyAll approval).
|
|
401
|
+
const useProcessIsolation = process.env.KLYRO_WORKER !== '0' && this.childCanIsolate(resolved, def, childOptions);
|
|
355
402
|
this.workerSpawner.spawn(async (signal) => {
|
|
356
403
|
// Both the in-process path and the forked child resolve to the same
|
|
357
404
|
// minimal outcome shape the settle tail needs.
|
package/dist/agent/runtime.js
CHANGED
|
@@ -22,7 +22,7 @@ import * as path from 'node:path';
|
|
|
22
22
|
import { verify, diagnosticForModel } from '../verification/engine.js';
|
|
23
23
|
import { detectVerifyCommand } from '../verification/auto.js';
|
|
24
24
|
import { ensureBaseline, getBaseline } from '../verification/baseline.js';
|
|
25
|
-
import { compressTranscript, totalTokens } from '../context/tokenizer.js';
|
|
25
|
+
import { compressTranscript, totalTokens, calibrateEstimate, transcriptCharLength } from '../context/tokenizer.js';
|
|
26
26
|
import { ratesFor, isAnthropicModel } from '../providers/model-info.js';
|
|
27
27
|
import { classifyFailure, rerunOnce, gatherRepairContext, guardRepair } from '../verification/classify.js';
|
|
28
28
|
import { findRelatedTests, buildScopedCommand, runScopedVerify, syntaxCheck, checkImports } from '../verification/scoped.js';
|
|
@@ -369,6 +369,10 @@ export async function run(opts, deps) {
|
|
|
369
369
|
if (ev.usage.cacheWrite !== undefined)
|
|
370
370
|
usage.cacheWrite = (usage.cacheWrite ?? 0) + ev.usage.cacheWrite;
|
|
371
371
|
telemetry.recordUsage(ev.usage.input, ev.usage.output);
|
|
372
|
+
// R3 — calibrate the local chars/4 heuristic toward the real
|
|
373
|
+
// per-character ratio this provider/model reports, so future budget
|
|
374
|
+
// checks (and overflow recovery) estimate accurately.
|
|
375
|
+
calibrateEstimate(transcriptCharLength(systemForBudget, reqMessages), ev.usage.input);
|
|
372
376
|
emit?.({
|
|
373
377
|
kind: 'usage', input: usage.input, output: usage.output,
|
|
374
378
|
...(usage.cacheRead !== undefined ? { cacheRead: usage.cacheRead } : {}),
|
package/dist/cli/markdown.js
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
* 1.5 — incremental markdown renderer (headers, code w/ highlight, lists, tables)
|
|
3
3
|
* Minimal: handles headers, code blocks, lists, tables, width-aware wrap, plain-text for non-TTY.
|
|
4
4
|
*/
|
|
5
|
+
import { highlightCodeLine } from '../tui/markdown.js';
|
|
6
|
+
/** ANSI color code per MdPart color name (R4 shared highlighter). */
|
|
7
|
+
const ANSI = {
|
|
8
|
+
red: '\x1b[31m',
|
|
9
|
+
green: '\x1b[32m',
|
|
10
|
+
yellow: '\x1b[33m',
|
|
11
|
+
blue: '\x1b[34m',
|
|
12
|
+
magenta: '\x1b[35m',
|
|
13
|
+
cyan: '\x1b[36m',
|
|
14
|
+
gray: '\x1b[90m',
|
|
15
|
+
};
|
|
16
|
+
const RESET = '\x1b[0m';
|
|
17
|
+
function renderHighlightedCode(line, lang) {
|
|
18
|
+
const parts = highlightCodeLine(line, lang);
|
|
19
|
+
let out = '';
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
let s = p.text;
|
|
22
|
+
if (p.dim)
|
|
23
|
+
s = `\x1b[2m${s}\x1b[22m`;
|
|
24
|
+
if (p.color && ANSI[p.color])
|
|
25
|
+
s = `${ANSI[p.color]}${s}${RESET}`;
|
|
26
|
+
out += s;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
5
30
|
export function renderMarkdown(md, opts = {}) {
|
|
6
31
|
const width = opts.width ?? (process.stdout.columns || 80);
|
|
7
32
|
const isTTY = opts.isTTY ?? !!process.stdout.isTTY;
|
|
@@ -12,14 +37,17 @@ export function renderMarkdown(md, opts = {}) {
|
|
|
12
37
|
let out = '';
|
|
13
38
|
const lines = md.split('\n');
|
|
14
39
|
let inCodeBlock = false;
|
|
40
|
+
let fenceLang = '';
|
|
15
41
|
for (let line of lines) {
|
|
16
42
|
if (line.startsWith('```')) {
|
|
43
|
+
if (!inCodeBlock)
|
|
44
|
+
fenceLang = line.replace(/^```/, '').trim().toLowerCase();
|
|
17
45
|
inCodeBlock = !inCodeBlock;
|
|
18
46
|
out += (inCodeBlock ? '┌ code ──\n' : '└──────\n');
|
|
19
47
|
continue;
|
|
20
48
|
}
|
|
21
49
|
if (inCodeBlock) {
|
|
22
|
-
out += '│ ' + line + '\n';
|
|
50
|
+
out += '│ ' + renderHighlightedCode(line, fenceLang) + '\n';
|
|
23
51
|
continue;
|
|
24
52
|
}
|
|
25
53
|
if (line.startsWith('# ')) {
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* never overflows the model's true window because the heuristic
|
|
8
8
|
* overestimates mixed text.
|
|
9
9
|
*
|
|
10
|
+
* R3 — accuracy improvement: the heuristic is *calibrated* against the
|
|
11
|
+
* provider's reported usage when available. The runtime calls
|
|
12
|
+
* `calibrateEstimate` after each message_end that carries usage, which
|
|
13
|
+
* adjusts the per-character ratio toward the real value observed for this
|
|
14
|
+
* model. The calibration is bounded (0.1–0.6 chars/token) so a bad sample
|
|
15
|
+
* can't make the budget non-conservative. Until the first sample arrives,
|
|
16
|
+
* the safe chars/4 default is used.
|
|
17
|
+
*
|
|
10
18
|
* Strategy:
|
|
11
19
|
* 1. Always preserve the system prompt, the latest user task, and the
|
|
12
20
|
* latest assistant message.
|
|
@@ -28,10 +36,20 @@ export interface BudgetCheck {
|
|
|
28
36
|
used: number;
|
|
29
37
|
cap: number;
|
|
30
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Calibrate the heuristic against provider-reported usage. Pass the actual
|
|
41
|
+
* input token count and the char length of the transcript that was sent.
|
|
42
|
+
* The ratio self-corrects toward the model's true tokenizer behavior.
|
|
43
|
+
*/
|
|
44
|
+
export declare function calibrateEstimate(usedChars: number, reportedInputTokens: number): number;
|
|
45
|
+
/** Current chars/token ratio (after calibration, if any). */
|
|
46
|
+
export declare function charsPerTokenRatio(): number;
|
|
31
47
|
export declare function estimateTokens(s: string): number;
|
|
32
48
|
export declare function estimateMessage(m: Message): number;
|
|
33
49
|
/** Total input tokens for a transcript + optional system prompt. */
|
|
34
50
|
export declare function totalTokens(system: string | undefined, messages: Message[]): number;
|
|
51
|
+
/** Count the raw character length of a transcript for calibration. */
|
|
52
|
+
export declare function transcriptCharLength(system: string | undefined, messages: Message[]): number;
|
|
35
53
|
/** True if the input fits under the budget cap. */
|
|
36
54
|
export declare function withinBudget(system: string | undefined, messages: Message[], budget: TokenBudget): BudgetCheck;
|
|
37
55
|
/**
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* never overflows the model's true window because the heuristic
|
|
8
8
|
* overestimates mixed text.
|
|
9
9
|
*
|
|
10
|
+
* R3 — accuracy improvement: the heuristic is *calibrated* against the
|
|
11
|
+
* provider's reported usage when available. The runtime calls
|
|
12
|
+
* `calibrateEstimate` after each message_end that carries usage, which
|
|
13
|
+
* adjusts the per-character ratio toward the real value observed for this
|
|
14
|
+
* model. The calibration is bounded (0.1–0.6 chars/token) so a bad sample
|
|
15
|
+
* can't make the budget non-conservative. Until the first sample arrives,
|
|
16
|
+
* the safe chars/4 default is used.
|
|
17
|
+
*
|
|
10
18
|
* Strategy:
|
|
11
19
|
* 1. Always preserve the system prompt, the latest user task, and the
|
|
12
20
|
* latest assistant message.
|
|
@@ -16,8 +24,32 @@
|
|
|
16
24
|
* 3. If still over budget, summarize the surviving tail into a single
|
|
17
25
|
* user message ("Earlier in this session: …").
|
|
18
26
|
*/
|
|
27
|
+
/** Calibration ratio: chars per token. Starts at 4.0 (the classic heuristic)
|
|
28
|
+
* and self-corrects toward the provider's reported usage. Bounded to
|
|
29
|
+
* [MIN_RATIO, MAX_RATIO] = [2.0, 6.0] so a pathological sample (a tool dump
|
|
30
|
+
* that tokenizes densely, or a sparse prompt) can't drive the budget into a
|
|
31
|
+
* non-conservative regime. Real tokenizers sit around 3.5–4.5 chars/token. */
|
|
32
|
+
const MIN_RATIO = 2.0;
|
|
33
|
+
const MAX_RATIO = 6.0;
|
|
34
|
+
let charsPerToken = 4.0;
|
|
35
|
+
/**
|
|
36
|
+
* Calibrate the heuristic against provider-reported usage. Pass the actual
|
|
37
|
+
* input token count and the char length of the transcript that was sent.
|
|
38
|
+
* The ratio self-corrects toward the model's true tokenizer behavior.
|
|
39
|
+
*/
|
|
40
|
+
export function calibrateEstimate(usedChars, reportedInputTokens) {
|
|
41
|
+
if (reportedInputTokens <= 0 || usedChars <= 0)
|
|
42
|
+
return charsPerToken;
|
|
43
|
+
const newRatio = usedChars / reportedInputTokens;
|
|
44
|
+
charsPerToken = Math.max(MIN_RATIO, Math.min(MAX_RATIO, newRatio));
|
|
45
|
+
return charsPerToken;
|
|
46
|
+
}
|
|
47
|
+
/** Current chars/token ratio (after calibration, if any). */
|
|
48
|
+
export function charsPerTokenRatio() {
|
|
49
|
+
return charsPerToken;
|
|
50
|
+
}
|
|
19
51
|
export function estimateTokens(s) {
|
|
20
|
-
return Math.ceil(s.length /
|
|
52
|
+
return Math.ceil(s.length / charsPerToken);
|
|
21
53
|
}
|
|
22
54
|
export function estimateMessage(m) {
|
|
23
55
|
let n = 4; // role + structural overhead
|
|
@@ -43,6 +75,23 @@ export function totalTokens(system, messages) {
|
|
|
43
75
|
n += estimateMessage(m);
|
|
44
76
|
return n;
|
|
45
77
|
}
|
|
78
|
+
/** Count the raw character length of a transcript for calibration. */
|
|
79
|
+
export function transcriptCharLength(system, messages) {
|
|
80
|
+
let n = system ? system.length : 0;
|
|
81
|
+
for (const m of messages) {
|
|
82
|
+
for (const b of m.content) {
|
|
83
|
+
if (b.kind === 'text')
|
|
84
|
+
n += b.text.length;
|
|
85
|
+
else if (b.kind === 'tool_use')
|
|
86
|
+
n += b.name.length + JSON.stringify(b.input).length;
|
|
87
|
+
else if (b.kind === 'tool_result') {
|
|
88
|
+
const out = typeof b.output === 'string' ? b.output : JSON.stringify(b.output ?? '');
|
|
89
|
+
n += out.length + (b.name?.length ?? 0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return n;
|
|
94
|
+
}
|
|
46
95
|
/** True if the input fits under the budget cap. */
|
|
47
96
|
export function withinBudget(system, messages, budget) {
|
|
48
97
|
const used = totalTokens(system, messages);
|
package/dist/events/bus.d.ts
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { KlyroEvent } from './catalog.js';
|
|
6
6
|
type Listener = (ev: KlyroEvent) => void;
|
|
7
|
+
/** Cap for the retained event history. Long sessions emit a lot of
|
|
8
|
+
* stream.delta / tool.result events; keeping them all grows memory
|
|
9
|
+
* without bound. We retain a fixed recent window plus the structural
|
|
10
|
+
* events (phase, verify, error) so replays stay coherent. */
|
|
11
|
+
export declare const HISTORY_CAP = 10000;
|
|
7
12
|
export declare class EventBus {
|
|
8
13
|
private listeners;
|
|
9
14
|
private history;
|
package/dist/events/bus.js
CHANGED
|
@@ -2,11 +2,21 @@
|
|
|
2
2
|
* 3.1 — core/events emitter
|
|
3
3
|
* In-memory pub/sub for KlyroEvents. Sync delivery, no buffering.
|
|
4
4
|
*/
|
|
5
|
+
/** Cap for the retained event history. Long sessions emit a lot of
|
|
6
|
+
* stream.delta / tool.result events; keeping them all grows memory
|
|
7
|
+
* without bound. We retain a fixed recent window plus the structural
|
|
8
|
+
* events (phase, verify, error) so replays stay coherent. */
|
|
9
|
+
export const HISTORY_CAP = 10_000;
|
|
5
10
|
export class EventBus {
|
|
6
11
|
listeners = new Set();
|
|
7
12
|
history = [];
|
|
8
13
|
emit(ev) {
|
|
9
14
|
this.history.push(ev);
|
|
15
|
+
if (this.history.length > HISTORY_CAP) {
|
|
16
|
+
// Cheap uniform prune: drop every other oldest event so a flood of
|
|
17
|
+
// deltas can't force a reallocation on each emit.
|
|
18
|
+
this.history = this.history.filter((_, i) => i % 2 === 1);
|
|
19
|
+
}
|
|
10
20
|
for (const l of [...this.listeners]) {
|
|
11
21
|
try {
|
|
12
22
|
l(ev);
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* Backend strategy (defense-in-depth, cheapest-first):
|
|
9
9
|
* - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
|
|
10
10
|
* Code's sandbox docs reference. Primary backend when present.
|
|
11
|
-
* - landlock — kernel LSM, no
|
|
12
|
-
*
|
|
11
|
+
* - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
|
|
12
|
+
* issue the syscalls directly, so we drive it through the `llkr` helper
|
|
13
|
+
* (from the ll_start project). Fallback backend when bwrap is absent.
|
|
13
14
|
*
|
|
14
15
|
* Detection is done once at startup and cached. When no backend is present we
|
|
15
16
|
* *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
|
|
16
17
|
* reported status, never a pretend "sandboxed" that isn't.
|
|
17
18
|
*/
|
|
18
|
-
export type SandboxBackend = 'bwrap' | 'none';
|
|
19
|
+
export type SandboxBackend = 'bwrap' | 'landlock' | 'none';
|
|
19
20
|
export interface SandboxStatus {
|
|
20
21
|
backend: SandboxBackend;
|
|
21
22
|
/** True when a real kernel/namespace boundary will be applied. */
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* Backend strategy (defense-in-depth, cheapest-first):
|
|
9
9
|
* - `bwrap` (bubblewrap) — rootless, cross-distro, the same primitive Claude
|
|
10
10
|
* Code's sandbox docs reference. Primary backend when present.
|
|
11
|
-
* - landlock — kernel LSM, no
|
|
12
|
-
*
|
|
11
|
+
* - landlock — kernel LSM (Linux 5.13+), no namespaces needed. Node can't
|
|
12
|
+
* issue the syscalls directly, so we drive it through the `llkr` helper
|
|
13
|
+
* (from the ll_start project). Fallback backend when bwrap is absent.
|
|
13
14
|
*
|
|
14
15
|
* Detection is done once at startup and cached. When no backend is present we
|
|
15
16
|
* *degrade cleanly* to `undefined` — an empty sandbox command and a clearly
|
|
@@ -66,10 +67,17 @@ export function detectSandbox() {
|
|
|
66
67
|
cachedStatus = { backend: 'bwrap', active: true, reason: `bubblewrap at ${bwrap}` };
|
|
67
68
|
return cachedStatus;
|
|
68
69
|
}
|
|
70
|
+
// Landlock: the helper binary (`llkr` from the ll_start project) exposes the
|
|
71
|
+
// kernel LSM without a subprocess namespace. Requires Linux 5.13+.
|
|
72
|
+
const llkr = findOnPath('llkr');
|
|
73
|
+
if (process.platform === 'linux' && llkr && bwrapUsable(llkr)) {
|
|
74
|
+
cachedStatus = { backend: 'landlock', active: true, reason: `landlock via llkr at ${llkr}` };
|
|
75
|
+
return cachedStatus;
|
|
76
|
+
}
|
|
69
77
|
cachedStatus = {
|
|
70
78
|
backend: 'none',
|
|
71
79
|
active: false,
|
|
72
|
-
reason: 'bwrap
|
|
80
|
+
reason: 'no sandbox backend (bwrap or llkr) on $PATH — install bubblewrap (apt install bubblewrap) or run unsandboxed; policy+path guards remain active',
|
|
73
81
|
};
|
|
74
82
|
return cachedStatus;
|
|
75
83
|
}
|
|
@@ -88,6 +96,18 @@ export function sandboxCommand(cmd, args, opts) {
|
|
|
88
96
|
const status = detectSandbox();
|
|
89
97
|
if (!status.active)
|
|
90
98
|
return undefined;
|
|
99
|
+
// Landlock backend: the helper confines filesystem access to the read-write
|
|
100
|
+
// set and then execs the command. Kernel-enforced (LSM), no namespaces.
|
|
101
|
+
if (status.backend === 'landlock') {
|
|
102
|
+
const rw = [opts.cwd, ...(opts.readWriteDirs ?? [])];
|
|
103
|
+
const llArgs = [];
|
|
104
|
+
for (const d of rw)
|
|
105
|
+
llArgs.push('--rw', d);
|
|
106
|
+
if (!opts.allowNetwork)
|
|
107
|
+
llArgs.push('--no-net');
|
|
108
|
+
llArgs.push('--', cmd, ...args);
|
|
109
|
+
return { cmd: 'llkr', args: llArgs };
|
|
110
|
+
}
|
|
91
111
|
const roDirs = ['/usr', '/bin', '/etc', '/lib', '/lib64', '/opt'];
|
|
92
112
|
const bwrapArgs = [
|
|
93
113
|
'--unshare-all',
|
package/dist/tui/app.js
CHANGED
|
@@ -96,7 +96,26 @@ function MarkdownText({ text, dim, width }) {
|
|
|
96
96
|
const lines = useMemo(() => renderMarkdownLines(text), [text]);
|
|
97
97
|
const dimColor = tokens.colors.dim;
|
|
98
98
|
const softColor = tokens.colors.soft;
|
|
99
|
-
return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) =>
|
|
99
|
+
return (_jsx(Text, { wrap: "wrap", color: dim ? dimColor : undefined, children: lines.map((l, i) => (_jsxs(React.Fragment, { children: [i > 0 ? '\n' : null, l.parts.map((p, j) => {
|
|
100
|
+
// R4: syntax-highlight color wins; otherwise dim for code/comment,
|
|
101
|
+
// soft for bold, plain otherwise. file:line links surface as a
|
|
102
|
+
// distinct accent (href is carried for tooling/terminal emit).
|
|
103
|
+
// Inside dimmed (thinking) blocks the dim tone always wins.
|
|
104
|
+
let color;
|
|
105
|
+
if (dim)
|
|
106
|
+
color = p.dim ? dimColor : undefined;
|
|
107
|
+
else if (p.color)
|
|
108
|
+
color = p.color;
|
|
109
|
+
else if (p.href)
|
|
110
|
+
color = 'blue';
|
|
111
|
+
else if (p.dim)
|
|
112
|
+
color = dimColor;
|
|
113
|
+
else if (p.code)
|
|
114
|
+
color = softColor;
|
|
115
|
+
else if (p.bold)
|
|
116
|
+
color = softColor;
|
|
117
|
+
return (_jsx(Text, { bold: p.bold || undefined, color: color, children: p.text }, j));
|
|
118
|
+
})] }, i))) }));
|
|
100
119
|
}
|
|
101
120
|
// Chat scroll — scroll.md §5 anchor model adapted to Ink.
|
|
102
121
|
//
|
package/dist/tui/app.test.js
CHANGED
|
@@ -354,10 +354,11 @@ describe('App', () => {
|
|
|
354
354
|
stdin.write('hello again');
|
|
355
355
|
await new Promise((r) => setTimeout(r, 20));
|
|
356
356
|
stdin.write('\x0d');
|
|
357
|
-
//
|
|
358
|
-
|
|
357
|
+
// The bottom-pin settle effect (immediate + microtask + timeout(0)) runs
|
|
358
|
+
// on React's schedule — poll for the observable outcome instead of a
|
|
359
|
+
// fixed 150ms sleep, which flaked under load.
|
|
360
|
+
await waitForMatch(lastFrame, /MSG-24-tag/);
|
|
359
361
|
expect(onPrompt).toHaveBeenCalledWith('hello again');
|
|
360
|
-
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
361
362
|
});
|
|
362
363
|
it('onMounted transcript handle runs the four commands (scroll.md §2)', async () => {
|
|
363
364
|
let handle = null;
|
|
@@ -537,23 +538,28 @@ describe('App', () => {
|
|
|
537
538
|
} }));
|
|
538
539
|
await new Promise((r) => setTimeout(r, 100));
|
|
539
540
|
stdin.write(KEY_HOME);
|
|
540
|
-
await
|
|
541
|
+
await waitForMatch(lastFrame, /⇅ 0\/\d+/);
|
|
541
542
|
hooks.append({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
|
|
542
543
|
// Badge counts lines grown while pinned.
|
|
543
544
|
await waitForMatch(lastFrame, /↓ \d+ new/);
|
|
544
545
|
hooks.clearTranscript();
|
|
545
|
-
|
|
546
|
-
//
|
|
546
|
+
// Wait for transcript content to actually clear (no stale MSG-* items) —
|
|
547
|
+
// this is the "no stale badge" invariant: after a clear, the view must
|
|
548
|
+
// drop back to follow-tail with the pin/count reset.
|
|
549
|
+
await waitForAbsent(lastFrame, /MSG-\d{2}-tag/);
|
|
550
|
+
await waitForAbsent(lastFrame, /↓ \d+ new/);
|
|
551
|
+
// Fresh session: seed 25 more, let them settle to a bottom-anchored view.
|
|
547
552
|
for (let i = 0; i < 25; i++) {
|
|
548
553
|
hooks.append({ id: `n-${i}`, kind: 'text', text: `NEW-${i.toString().padStart(2, '0')}-tag`, role: 'user' });
|
|
549
554
|
}
|
|
550
|
-
await
|
|
551
|
-
|
|
552
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
555
|
+
await waitForMatch(lastFrame, /NEW-24-tag/);
|
|
556
|
+
// Follow-tail after a clear: growing content shows NO stale badge.
|
|
553
557
|
hooks.append({ id: 'n-late', kind: 'text', text: 'NEWLATE-tag', role: 'assistant' });
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
558
|
+
await waitForMatch(lastFrame, /NEWLATE-tag/);
|
|
559
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
560
|
+
const afterFollow = lastFrame() ?? '';
|
|
561
|
+
expect(afterFollow).not.toMatch(/↓ \d+ new/);
|
|
562
|
+
expect(afterFollow).not.toMatch(/MSG-\d{2}-tag/);
|
|
557
563
|
});
|
|
558
564
|
it('status bar shows scroll position when content overflows', async () => {
|
|
559
565
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
@@ -34,6 +34,21 @@ describe('ApprovalModal', () => {
|
|
|
34
34
|
const { lastFrame, stdin } = render(_jsx(ApprovalModal, { bridge: bridge }));
|
|
35
35
|
return { bridge, lastFrame, stdin };
|
|
36
36
|
}
|
|
37
|
+
/** Poll the rendered frame instead of sleeping a fixed interval — Ink
|
|
38
|
+
* flushes on React's schedule, so fixed sleeps flake under load. */
|
|
39
|
+
async function waitForFrame(getFrame, re, timeout = 4000) {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
let frame = '';
|
|
42
|
+
for (;;) {
|
|
43
|
+
frame = getFrame() ?? '';
|
|
44
|
+
if (re.test(frame))
|
|
45
|
+
return frame;
|
|
46
|
+
if (Date.now() - start > timeout) {
|
|
47
|
+
throw new Error(`timed out waiting for ${re}\nlast frame:\n${frame.slice(0, 2000)}`);
|
|
48
|
+
}
|
|
49
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
37
52
|
it('renders nothing when no prompt is pending', () => {
|
|
38
53
|
const { lastFrame } = setup();
|
|
39
54
|
expect(lastFrame()).toBe('');
|
|
@@ -53,11 +68,13 @@ describe('ApprovalModal', () => {
|
|
|
53
68
|
await promise;
|
|
54
69
|
});
|
|
55
70
|
it('"y" resolves to allow', async () => {
|
|
56
|
-
const { bridge, stdin } = setup();
|
|
71
|
+
const { bridge, stdin, lastFrame } = setup();
|
|
57
72
|
const promise = bridge.ask({ toolName: 'x', reason: 'r', summary: 's' });
|
|
58
|
-
|
|
73
|
+
// Poll until the modal is actually rendered: the useInput handler reads
|
|
74
|
+
// `pending` from a closure, so typing before React flushes the pending
|
|
75
|
+
// state silently drops the key. A blind 50ms sleep is the flake source.
|
|
76
|
+
await waitForFrame(lastFrame, /approval needed/i);
|
|
59
77
|
stdin.write('y');
|
|
60
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
61
78
|
await expect(promise).resolves.toBe('allow');
|
|
62
79
|
});
|
|
63
80
|
it('"a" resolves to always', async () => {
|
package/dist/tui/markdown.d.ts
CHANGED
|
@@ -10,11 +10,24 @@ export interface MdPart {
|
|
|
10
10
|
bold?: boolean;
|
|
11
11
|
dim?: boolean;
|
|
12
12
|
code?: boolean;
|
|
13
|
+
/** color name for inline syntax highlighting within code blocks (R4). */
|
|
14
|
+
color?: 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'gray';
|
|
15
|
+
/** OSC-8 hyperlink target (R4): file:line, urls, etc. */
|
|
16
|
+
href?: string;
|
|
13
17
|
}
|
|
14
18
|
export interface MdLine {
|
|
15
19
|
parts: MdPart[];
|
|
16
20
|
/** inside a ``` fence (render the whole line dimmed) */
|
|
17
21
|
fence: boolean;
|
|
18
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Highlight a single code line. Returns an array of parts that may carry a
|
|
25
|
+
* `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
|
|
26
|
+
* language is unknown or the line is a fence marker.
|
|
27
|
+
*/
|
|
28
|
+
export declare function highlightCodeLine(line: string, lang: string): MdPart[];
|
|
29
|
+
/** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
|
|
30
|
+
export declare const FILE_LINE_RE: RegExp;
|
|
31
|
+
export declare function annotateFileLinks(parts: MdPart[]): MdPart[];
|
|
19
32
|
/** Split assistant text into styled lines. Pure — unit-tested. */
|
|
20
33
|
export declare function renderMarkdownLines(text: string): MdLine[];
|
package/dist/tui/markdown.js
CHANGED
|
@@ -6,6 +6,168 @@
|
|
|
6
6
|
* (best-effort passthrough). Everything renders as terminal text, no HTML.
|
|
7
7
|
*/
|
|
8
8
|
const INLINE_RE = /\*\*(.+?)\*\*|\*([^*\n]+?)\*|`([^`\n]+?)`|\[([^\]]+?)\]\(([^)]+?)\)/g;
|
|
9
|
+
/**
|
|
10
|
+
* R4 — regex-based lightweight syntax highlighting for fenced code blocks.
|
|
11
|
+
* No heavy tokenizer dependency; keyword/string/comment/number detection
|
|
12
|
+
* keeps tool output scannable in the terminal. Pure, unit-testable.
|
|
13
|
+
*/
|
|
14
|
+
/** Comment prefixes per common language (used to dim comments). */
|
|
15
|
+
const COMMENT_MARKERS = {
|
|
16
|
+
ts: ['//', '/*', '*'],
|
|
17
|
+
js: ['//', '/*', '*'],
|
|
18
|
+
jsx: ['//', '/*', '*'],
|
|
19
|
+
tsx: ['//', '/*', '*'],
|
|
20
|
+
py: ['#'],
|
|
21
|
+
rb: ['#'],
|
|
22
|
+
go: ['//', '/*'],
|
|
23
|
+
rs: ['//', '/*'],
|
|
24
|
+
java: ['//', '/*', '*'],
|
|
25
|
+
c: ['//', '/*', '*'],
|
|
26
|
+
cpp: ['//', '/*', '*'],
|
|
27
|
+
sh: ['#'],
|
|
28
|
+
bash: ['#'],
|
|
29
|
+
yaml: ['#'],
|
|
30
|
+
yml: ['#'],
|
|
31
|
+
toml: ['#'],
|
|
32
|
+
sql: ['--', '/*'],
|
|
33
|
+
};
|
|
34
|
+
const STRING_DELIMS = ["'", '"', '`'];
|
|
35
|
+
/** Keywords that get highlighted, grouped by language family. */
|
|
36
|
+
const KEYWORDS = {
|
|
37
|
+
js: ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
|
|
38
|
+
ts: ['interface', 'type', 'enum', 'namespace', 'readonly', 'declare', 'extends', 'implements', 'public', 'private', 'protected', 'abstract', 'as', 'satisfies', 'const', 'let', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'class', 'import', 'export', 'default', 'from', 'await', 'async', 'new', 'this', 'null', 'undefined', 'true', 'false', 'typeof', 'instanceof', 'throw', 'try', 'catch', 'switch', 'case', 'break', 'continue'],
|
|
39
|
+
py: ['import', 'from', 'def', 'class', 'return', 'if', 'elif', 'else', 'for', 'in', 'while', 'with', 'as', 'lambda', 'pass', 'break', 'continue', 'True', 'False', 'None', 'and', 'or', 'not', 'is', 'raise', 'try', 'except', 'finally', 'yield', 'global', 'async', 'await', 'self'],
|
|
40
|
+
go: ['func', 'func(', 'package', 'import', 'var', 'const', 'type', 'struct', 'interface', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'break', 'continue', 'defer', 'go', 'select', 'map', 'chan', 'nil', 'true', 'false', 'err', 'make'],
|
|
41
|
+
rs: ['fn', 'let', 'mut', 'const', 'use', 'mod', 'struct', 'enum', 'impl', 'trait', 'pub', 'async', 'await', 'match', 'if', 'else', 'for', 'in', 'while', 'loop', 'return', 'move', 'ref', 'dyn', 'Self', 'self', 'true', 'false', 'None', 'Some', 'Ok', 'Err', 'Vec', 'String'],
|
|
42
|
+
sh: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset', 'command'],
|
|
43
|
+
bash: ['export', 'local', 'if', 'then', 'fi', 'else', 'elif', 'for', 'in', 'do', 'done', 'while', 'case', 'esac', 'function', 'echo', 'printf', 'read', 'cd', 'source', '.', 'return', 'exit', 'set', 'unset'],
|
|
44
|
+
json: ['true', 'false', 'null'],
|
|
45
|
+
yaml: ['true', 'false', 'null', 'yes', 'no'],
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Highlight a single code line. Returns an array of parts that may carry a
|
|
49
|
+
* `color` for Ink/TerminalRenderer to render. Falls back to dim text when the
|
|
50
|
+
* language is unknown or the line is a fence marker.
|
|
51
|
+
*/
|
|
52
|
+
export function highlightCodeLine(line, lang) {
|
|
53
|
+
const trimmed = line.trimStart();
|
|
54
|
+
const comments = COMMENT_MARKERS[lang] ?? [];
|
|
55
|
+
const keywords = KEYWORDS[lang] ?? [];
|
|
56
|
+
const kwSet = new Set(keywords);
|
|
57
|
+
// Whole-line comment → dim.
|
|
58
|
+
for (const c of comments) {
|
|
59
|
+
if (trimmed.startsWith(c))
|
|
60
|
+
return [{ text: line, dim: true, code: true }];
|
|
61
|
+
}
|
|
62
|
+
const parts = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
let inString = false;
|
|
65
|
+
let strDelim = '';
|
|
66
|
+
let buf = '';
|
|
67
|
+
const flush = () => {
|
|
68
|
+
if (!buf)
|
|
69
|
+
return;
|
|
70
|
+
parts.push({ text: buf, code: true });
|
|
71
|
+
buf = '';
|
|
72
|
+
};
|
|
73
|
+
const flushKw = (tok) => {
|
|
74
|
+
if (kwSet.has(tok))
|
|
75
|
+
parts.push({ text: tok, color: 'cyan', code: true });
|
|
76
|
+
else if (/^\d+(\.\d+)?$/.test(tok))
|
|
77
|
+
parts.push({ text: tok, color: 'yellow', code: true });
|
|
78
|
+
else
|
|
79
|
+
parts.push({ text: tok, code: true });
|
|
80
|
+
};
|
|
81
|
+
while (i < line.length) {
|
|
82
|
+
const ch = line[i];
|
|
83
|
+
if (inString) {
|
|
84
|
+
buf += ch;
|
|
85
|
+
if (ch === strDelim) {
|
|
86
|
+
inString = false;
|
|
87
|
+
flush();
|
|
88
|
+
}
|
|
89
|
+
else if (ch === '\\' && i + 1 < line.length) {
|
|
90
|
+
buf += line[i + 1];
|
|
91
|
+
i += 2;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Inline comment inside a code line.
|
|
98
|
+
for (const c of comments) {
|
|
99
|
+
if (c && line.slice(i, i + c.length) === c) {
|
|
100
|
+
flush();
|
|
101
|
+
parts.push({ text: line.slice(i), dim: true, code: true });
|
|
102
|
+
return parts;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (STRING_DELIMS.includes(ch)) {
|
|
106
|
+
flush();
|
|
107
|
+
inString = true;
|
|
108
|
+
strDelim = ch;
|
|
109
|
+
buf = ch;
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (/[A-Za-z_$]/.test(ch)) {
|
|
114
|
+
let j = i;
|
|
115
|
+
while (j < line.length && /[A-Za-z0-9_$]/.test(line[j]))
|
|
116
|
+
j++;
|
|
117
|
+
const tok = line.slice(i, j);
|
|
118
|
+
flushKw(tok);
|
|
119
|
+
i = j;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (/[0-9]/.test(ch)) {
|
|
123
|
+
let j = i;
|
|
124
|
+
while (j < line.length && /[0-9._]/.test(line[j]))
|
|
125
|
+
j++;
|
|
126
|
+
flush();
|
|
127
|
+
parts.push({ text: line.slice(i, j), color: 'yellow', code: true });
|
|
128
|
+
i = j;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
buf += ch;
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
flush();
|
|
135
|
+
if (parts.length === 0)
|
|
136
|
+
parts.push({ text: line, code: true });
|
|
137
|
+
return parts;
|
|
138
|
+
}
|
|
139
|
+
/** Detect `file:line` / `file:line:col` references and attach OSC-8 hrefs. */
|
|
140
|
+
export const FILE_LINE_RE = /((?:\.{0,2}\/)?[\w./-]+\.[a-zA-Z]+\w*):(\d+)(?::(\d+))?/g;
|
|
141
|
+
export function annotateFileLinks(parts) {
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const p of parts) {
|
|
144
|
+
if (p.code || !p.text) {
|
|
145
|
+
out.push(p);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Preserve the original part's styling on the unattributed plain-text runs.
|
|
149
|
+
const style = { text: '', ...(p.bold ? { bold: true } : {}), ...(p.dim ? { dim: true } : {}) };
|
|
150
|
+
FILE_LINE_RE.lastIndex = 0;
|
|
151
|
+
let last = 0;
|
|
152
|
+
let m;
|
|
153
|
+
let matched = false;
|
|
154
|
+
while ((m = FILE_LINE_RE.exec(p.text))) {
|
|
155
|
+
matched = true;
|
|
156
|
+
if (m.index > last)
|
|
157
|
+
out.push({ ...style, text: p.text.slice(last, m.index) });
|
|
158
|
+
const path = m[1];
|
|
159
|
+
const line = m[2];
|
|
160
|
+
const col = m[3] ? `:${m[3]}` : '';
|
|
161
|
+
out.push({ text: `${path}:${line}${col}`, href: `file://${path}#L${line}` });
|
|
162
|
+
last = m.index + m[0].length;
|
|
163
|
+
}
|
|
164
|
+
if (!matched)
|
|
165
|
+
out.push(p);
|
|
166
|
+
else if (last < p.text.length)
|
|
167
|
+
out.push({ ...style, text: p.text.slice(last) });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
9
171
|
function parseInline(s) {
|
|
10
172
|
const parts = [];
|
|
11
173
|
let last = 0;
|
|
@@ -37,15 +199,19 @@ function parseInline(s) {
|
|
|
37
199
|
export function renderMarkdownLines(text) {
|
|
38
200
|
const out = [];
|
|
39
201
|
let inFence = false;
|
|
202
|
+
let fenceLang = '';
|
|
40
203
|
for (const raw of text.split('\n')) {
|
|
41
204
|
const trimmed = raw.trim();
|
|
42
205
|
if (trimmed.startsWith('```')) {
|
|
206
|
+
if (!inFence)
|
|
207
|
+
fenceLang = trimmed.replace(/^```/, '').trim().toLowerCase();
|
|
43
208
|
inFence = !inFence;
|
|
44
209
|
out.push({ parts: [{ text: raw, dim: true }], fence: true });
|
|
45
210
|
continue;
|
|
46
211
|
}
|
|
47
212
|
if (inFence) {
|
|
48
|
-
|
|
213
|
+
// R4: syntax-highlight the code line, annotated with file:line links.
|
|
214
|
+
out.push({ parts: annotateFileLinks(highlightCodeLine(raw, fenceLang)), fence: true });
|
|
49
215
|
continue;
|
|
50
216
|
}
|
|
51
217
|
const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
|
|
@@ -57,7 +223,8 @@ export function renderMarkdownLines(text) {
|
|
|
57
223
|
});
|
|
58
224
|
continue;
|
|
59
225
|
}
|
|
60
|
-
|
|
226
|
+
// R4: clickable file:line references in ordinary prose too.
|
|
227
|
+
out.push({ parts: annotateFileLinks(parseInline(raw)), fence: false });
|
|
61
228
|
}
|
|
62
229
|
return out;
|
|
63
230
|
}
|
|
@@ -73,6 +73,8 @@ describe('scroll flow diagnostics', () => {
|
|
|
73
73
|
expect(lastFrame() ?? '').toContain('number 4');
|
|
74
74
|
});
|
|
75
75
|
it('wrapped long item: pin mid-item, stream, same first line stays', async () => {
|
|
76
|
+
// Heavy string measurement + React flush under full-suite parallel load;
|
|
77
|
+
// the default 10s testTimeout flaked — extend for this one.
|
|
76
78
|
const long = Array.from({ length: 10 }, (_, i) => `WRAPLINE-${i} ` + 'x'.repeat(180)).join('\n');
|
|
77
79
|
const items = [
|
|
78
80
|
{ id: 'w1', kind: 'text', text: long, role: 'assistant' },
|
|
@@ -90,5 +92,5 @@ describe('scroll flow diagnostics', () => {
|
|
|
90
92
|
}
|
|
91
93
|
const after = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
|
|
92
94
|
expect(after).toBe(before); // anchor stability at line granularity
|
|
93
|
-
});
|
|
95
|
+
}, 30_000);
|
|
94
96
|
});
|