mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 23:05:51 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f69c705166 |
@@ -165,6 +165,12 @@ export default class Ink {
|
||||
private frontFrame: Frame;
|
||||
private backFrame: Frame;
|
||||
private lastPoolResetTime = performance.now();
|
||||
/** Timestamp of last periodic full-redraw in main screen mode. Used to
|
||||
* recover from accumulated cursor drift / blit ghosting. Wall-clock
|
||||
* based (not frame-count) so drain scroll frames (250fps) don't
|
||||
* accelerate the cycle. Alt-screen doesn't need this — CSI H resets
|
||||
* cursor every frame. */
|
||||
private lastMainScreenHealTime = performance.now();
|
||||
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastYogaCounters: {
|
||||
ms: number;
|
||||
@@ -521,7 +527,25 @@ export default class Ink {
|
||||
// an extra React re-render cycle.
|
||||
this.options.onBeforeRender?.();
|
||||
|
||||
// Periodic self-healing: every ~5s in main screen mode, force a full
|
||||
// terminal redraw to recover from accumulated cursor drift / blit
|
||||
// ghosting. Alt-screen doesn't need this — CSI H resets cursor to
|
||||
// (0,0) every frame. Wall-clock based so drain scroll frames (250fps)
|
||||
// don't accelerate the cycle. Guarded by isTTY so ANSI escape
|
||||
// sequences are not leaked into pipes / redirected output.
|
||||
const renderStart = performance.now();
|
||||
if (
|
||||
!this.altScreenActive &&
|
||||
!this.isPaused &&
|
||||
this.options.stdout.isTTY &&
|
||||
renderStart - this.lastMainScreenHealTime > 5000
|
||||
) {
|
||||
this.lastMainScreenHealTime = renderStart;
|
||||
this.repaint();
|
||||
this.prevFrameContaminated = true;
|
||||
this.needsEraseBeforePaint = true;
|
||||
}
|
||||
|
||||
const terminalWidth = this.options.stdout.columns || 80;
|
||||
const terminalRows = this.options.stdout.rows || 24;
|
||||
|
||||
@@ -725,6 +749,10 @@ export default class Ink {
|
||||
const optimized = optimize(diff);
|
||||
const optimizeMs = performance.now() - tOptimize;
|
||||
const hasDiff = optimized.length > 0;
|
||||
// Periodic self-healing: for main-screen mode, emit ERASE_SCREEN + HOME
|
||||
// to clear the terminal before the diff. Alt-screen has its own CSI H
|
||||
// anchor + cursor park below. BSU/ESU wraps erase+paint atomically on
|
||||
// supported terminals (main-screen always uses sync markers).
|
||||
if (this.altScreenActive && hasDiff) {
|
||||
// Prepend CSI H to anchor the physical cursor to (0,0) so
|
||||
// log-update's relative moves compute from a known spot (self-healing
|
||||
@@ -752,6 +780,13 @@ export default class Ink {
|
||||
optimized.unshift(CURSOR_HOME_PATCH);
|
||||
}
|
||||
optimized.push(this.altScreenParkPatch);
|
||||
} else if (this.needsEraseBeforePaint && hasDiff) {
|
||||
// Main-screen periodic self-healing: clear visible terminal before
|
||||
// painting the diff. Without this, rows past the new frame's height
|
||||
// would retain stale content from the previous frame. BSU/ESU keeps
|
||||
// old content visible until the full erase+paint is flushed atomically.
|
||||
this.needsEraseBeforePaint = false;
|
||||
optimized.unshift(ERASE_THEN_HOME_PATCH);
|
||||
}
|
||||
|
||||
// Native cursor positioning: park the terminal cursor at the declared
|
||||
|
||||
37
src/setup.ts
37
src/setup.ts
@@ -401,10 +401,39 @@ export async function setup(
|
||||
process.env.IS_SANDBOX !== '1' &&
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP)
|
||||
) {
|
||||
console.error(
|
||||
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
|
||||
)
|
||||
process.exit(1)
|
||||
// Root + bypass = every tool call executes without review at uid 0.
|
||||
// Interactive TTY: warn and require explicit "y" to proceed.
|
||||
// Non-interactive (pipe, ACP, CI, no TTY): cannot prompt, must abort.
|
||||
if (process.stdin.isTTY) {
|
||||
console.error(
|
||||
chalk.bold.red(
|
||||
'WARNING: Running as root/sudo with bypass permissions mode is dangerous.',
|
||||
),
|
||||
)
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
'Bypass mode skips ALL permission checks. Combined with root, any command (rm -rf /, chmod, dd) executes without review.',
|
||||
),
|
||||
)
|
||||
const readline = await import('readline')
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
const answer = await new Promise<string>(resolve => {
|
||||
rl.question('\nI understand the risks. Continue? [y/N] ', resolve)
|
||||
})
|
||||
rl.close()
|
||||
if (answer.trim().toLowerCase() !== 'y') {
|
||||
console.error('Aborted.')
|
||||
process.exit(1)
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createUserInterruptionMessage,
|
||||
prepareUserContent,
|
||||
createToolResultStopMessage,
|
||||
createProgressMessage,
|
||||
extractTag,
|
||||
isNotEmptyMessage,
|
||||
deriveUUID,
|
||||
@@ -28,6 +29,9 @@ import {
|
||||
DONT_ASK_REJECT_MESSAGE,
|
||||
SYNTHETIC_MODEL,
|
||||
ensureToolResultPairing,
|
||||
buildMessageLookups,
|
||||
updateMessageLookupsIncremental,
|
||||
computeMessageStructureKey,
|
||||
} from '../messages'
|
||||
import type {
|
||||
Message,
|
||||
@@ -786,3 +790,168 @@ describe('normalizeMessagesForAPI – thinking + tool_use same turn (CC-1215)',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Progress tick replace (Bash/PowerShell elapsed-time freeze) ──────────
|
||||
|
||||
describe('computeMessageStructureKey + updateMessageLookupsIncremental: progress replace', () => {
|
||||
// REPL.tsx replaces ephemeral progress ticks (Bash/PowerShell/MCP) in-place
|
||||
// to bound the messages array. The lookups cache must invalidate when the
|
||||
// trailing progress tick changes, or ShellProgressMessage's elapsed time
|
||||
// freezes at the first tick forever.
|
||||
|
||||
type BashProgress = {
|
||||
type: 'bash_progress'
|
||||
elapsedTimeSeconds: number
|
||||
output: string
|
||||
fullOutput: string
|
||||
}
|
||||
|
||||
function makeAssistantWithToolUse(toolUseID: string): Message {
|
||||
return createAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseID,
|
||||
name: 'Bash',
|
||||
input: { command: 'sleep 10' },
|
||||
} as any,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function makeProgress(
|
||||
parentToolUseID: string,
|
||||
uuid: `${string}-${string}-${string}-${string}-${string}`,
|
||||
elapsedTimeSeconds: number,
|
||||
) {
|
||||
const msg = createProgressMessage<BashProgress>({
|
||||
toolUseID: `bash-progress-${elapsedTimeSeconds}`,
|
||||
parentToolUseID,
|
||||
data: {
|
||||
type: 'bash_progress',
|
||||
elapsedTimeSeconds,
|
||||
output: '',
|
||||
fullOutput: '',
|
||||
},
|
||||
})
|
||||
// Override uuid so the test is deterministic (createProgressMessage
|
||||
// generates a random uuid).
|
||||
return { ...msg, uuid }
|
||||
}
|
||||
|
||||
test('computeMessageStructureKey distinguishes progress ticks by uuid', () => {
|
||||
const assistant = makeAssistantWithToolUse('bash-1')
|
||||
const normalized = normalizeMessages([assistant])
|
||||
|
||||
const progress1 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
3,
|
||||
)
|
||||
const progress2 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
4,
|
||||
)
|
||||
|
||||
const keyBefore = computeMessageStructureKey(
|
||||
[...normalized, progress1 as any],
|
||||
[...normalized, progress1 as any] as any,
|
||||
)
|
||||
const keyAfter = computeMessageStructureKey(
|
||||
[...normalized, progress2 as any],
|
||||
[...normalized, progress2 as any] as any,
|
||||
)
|
||||
|
||||
// Same parentToolUseID, same length, but different uuid (tick replace).
|
||||
// Without uuid in the key, these would be identical and the lookups cache
|
||||
// would freeze on the first tick.
|
||||
expect(keyBefore).not.toEqual(keyAfter)
|
||||
})
|
||||
|
||||
test('updateMessageLookupsIncremental returns null when trailing progress was replaced (same length)', () => {
|
||||
const assistant = makeAssistantWithToolUse('bash-1')
|
||||
const normalized = normalizeMessages([assistant])
|
||||
|
||||
const progress1 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
3,
|
||||
)
|
||||
const progress2 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
4,
|
||||
)
|
||||
|
||||
const withProgress1 = [...normalized, progress1 as any]
|
||||
const withProgress2 = [...normalized, progress2 as any]
|
||||
|
||||
const existing = buildMessageLookups(
|
||||
withProgress1 as any,
|
||||
withProgress1 as any,
|
||||
)
|
||||
|
||||
// Same length, but the trailing progress is a fresh tick. Returning
|
||||
// `existing` here would leave progressMessagesByToolUseID stuck on u1.
|
||||
const result = updateMessageLookupsIncremental(
|
||||
existing,
|
||||
withProgress1.length,
|
||||
withProgress1.length,
|
||||
withProgress2 as any,
|
||||
withProgress2 as any,
|
||||
)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('updateMessageLookupsIncremental still returns existing when length same and trailing is NOT progress', () => {
|
||||
// Protect the original streaming-delta fast path: content-only changes
|
||||
// on a non-progress trailing message should not trigger a full rebuild.
|
||||
const assistant = makeAssistantWithToolUse('bash-1')
|
||||
const normalized = normalizeMessages([assistant])
|
||||
|
||||
const existing = buildMessageLookups(normalized as any, normalized as any)
|
||||
|
||||
const result = updateMessageLookupsIncremental(
|
||||
existing,
|
||||
normalized.length,
|
||||
normalized.length,
|
||||
normalized as any,
|
||||
normalized as any,
|
||||
)
|
||||
|
||||
expect(result).toBe(existing)
|
||||
})
|
||||
|
||||
test('full rebuild after progress replace yields the new tick in progressMessagesByToolUseID', () => {
|
||||
// End-to-end: buildMessageLookups after a tick replace must reflect the
|
||||
// fresh progress, not the stale one. This is what Messages.tsx falls back
|
||||
// to when updateMessageLookupsIncremental returns null.
|
||||
const assistant = makeAssistantWithToolUse('bash-1')
|
||||
const normalized = normalizeMessages([assistant])
|
||||
|
||||
const progress1 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
3,
|
||||
)
|
||||
const progress2 = makeProgress(
|
||||
'bash-1',
|
||||
'00000000-0000-0000-0000-000000000002',
|
||||
4,
|
||||
)
|
||||
|
||||
const withProgress2 = [...normalized, progress2 as any]
|
||||
const rebuilt = buildMessageLookups(
|
||||
withProgress2 as any,
|
||||
withProgress2 as any,
|
||||
)
|
||||
|
||||
const arr = rebuilt.progressMessagesByToolUseID.get('bash-1')
|
||||
expect(arr).toBeDefined()
|
||||
expect(arr).toHaveLength(1)
|
||||
expect(arr![0].uuid).toBe('00000000-0000-0000-0000-000000000002')
|
||||
expect((arr![0].data as BashProgress).elapsedTimeSeconds).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1417,11 +1417,21 @@ export function updateMessageLookupsIncremental(
|
||||
return null
|
||||
}
|
||||
|
||||
// No new messages — nothing to do
|
||||
// No new messages — nothing to do, UNLESS the trailing message is a
|
||||
// progress tick. REPL.tsx replaces ephemeral progress (Bash/PowerShell/MCP)
|
||||
// in-place to bound the messages array — same length, but the trailing
|
||||
// progress is a fresh tick. Returning `existing` here would leave
|
||||
// progressMessagesByToolUseID stuck on the first tick and elapsed-time
|
||||
// displays (ShellProgressMessage) would freeze. Force a full rebuild so
|
||||
// the fresh tick propagates.
|
||||
if (
|
||||
normalizedMessages.length === previousNormalizedCount &&
|
||||
messages.length === previousMessageCount
|
||||
) {
|
||||
const lastNormalized = normalizedMessages[normalizedMessages.length - 1]
|
||||
if (lastNormalized && lastNormalized.type === 'progress') {
|
||||
return null
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
@@ -1605,7 +1615,13 @@ export function computeMessageStructureKey(
|
||||
}
|
||||
for (const msg of normalizedMessages) {
|
||||
if (msg.type === 'progress') {
|
||||
parts.push('p', (msg as ProgressMessage).parentToolUseID as string)
|
||||
const pMsg = msg as ProgressMessage
|
||||
// Include uuid so ephemeral progress tick replacements
|
||||
// (Bash/PowerShell/MCP) invalidate the lookups cache. Without this,
|
||||
// REPL.tsx's in-place tick replacement (same parentToolUseID, same
|
||||
// length) yields an identical key, lookups cache the first tick
|
||||
// forever, and ShellProgressMessage's elapsed time freezes.
|
||||
parts.push('p', pMsg.parentToolUseID as string, pMsg.uuid)
|
||||
}
|
||||
}
|
||||
return parts.join(',')
|
||||
|
||||
Reference in New Issue
Block a user