mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 添加工具类命令(teleport、recap、break-cache、env、tui 等)
- /teleport: 从 claude.ai 恢复会话 - /recap: 生成会话摘要 - /break-cache: 提示缓存管理(once/always/off/status) - /env: 环境信息展示(含密钥脱敏) - /tui: 无闪烁 TUI 模式管理 - /onboarding: 引导流程 - /perf-issue: 性能问题诊断 - /debug-tool-call: 工具调用调试 - /usage: 用量统计(合并 /cost 和 /stats 别名) Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
246
src/commands/tui/__tests__/tui.test.ts
Normal file
246
src/commands/tui/__tests__/tui.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getClaudeConfigHomeDir } from '../../../utils/envUtils.js'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
stripProtoFields: (v: unknown) => v,
|
||||
}))
|
||||
|
||||
let tmpDir: string
|
||||
let claudeDir: string
|
||||
const origEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'tui-test-'))
|
||||
claudeDir = join(tmpDir, '.claude')
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir
|
||||
// getClaudeConfigHomeDir is `memoize(...)` — clear its cache so this
|
||||
// suite's CLAUDE_CONFIG_DIR overrides any value cached by an earlier
|
||||
// test file in the same process.
|
||||
getClaudeConfigHomeDir.cache?.clear?.()
|
||||
// Save env vars we may mutate
|
||||
origEnv.CLAUDE_CODE_NO_FLICKER = process.env.CLAUDE_CODE_NO_FLICKER
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
// Restore env vars
|
||||
if (origEnv.CLAUDE_CODE_NO_FLICKER === undefined) {
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = origEnv.CLAUDE_CODE_NO_FLICKER
|
||||
}
|
||||
})
|
||||
|
||||
// Helper: invoke the command's call function
|
||||
async function invokeCmd(
|
||||
args: string,
|
||||
): Promise<{ type: string; value: string }> {
|
||||
const { callTui } = await import('../index.js')
|
||||
return callTui(args) as Promise<{ type: string; value: string }>
|
||||
}
|
||||
|
||||
describe('tui command metadata', () => {
|
||||
test('has correct name, type, and description', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.default
|
||||
expect(cmd.name).toBe('tui')
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
expect(cmd.description).toContain('flicker')
|
||||
})
|
||||
|
||||
test('interactive and noninteractive entries are mutually gated', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const interactiveEnabled = mod.default.isEnabled?.()
|
||||
const nonInteractiveEnabled = mod.tuiNonInteractive.isEnabled?.()
|
||||
|
||||
expect(typeof interactiveEnabled).toBe('boolean')
|
||||
expect(nonInteractiveEnabled).toBe(!interactiveEnabled)
|
||||
})
|
||||
|
||||
test('supportsNonInteractive is true', async () => {
|
||||
const mod = await import('../index.js')
|
||||
const cmd = mod.tuiNonInteractive as unknown as {
|
||||
supportsNonInteractive: boolean
|
||||
type: string
|
||||
}
|
||||
expect(cmd.type).toBe('local')
|
||||
expect(cmd.supportsNonInteractive).toBe(true)
|
||||
})
|
||||
|
||||
test('local-jsx no args renders action panel without completing', async () => {
|
||||
const { call } = await import('../panel.js')
|
||||
const messages: string[] = []
|
||||
|
||||
const node = await call(
|
||||
msg => {
|
||||
if (msg) messages.push(msg)
|
||||
},
|
||||
{} as never,
|
||||
'',
|
||||
)
|
||||
|
||||
expect(node).not.toBeNull()
|
||||
expect(messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('local-jsx explicit args completes through onDone', async () => {
|
||||
const { call } = await import('../panel.js')
|
||||
const messages: string[] = []
|
||||
|
||||
const node = await call(
|
||||
msg => {
|
||||
if (msg) messages.push(msg)
|
||||
},
|
||||
{} as never,
|
||||
'status',
|
||||
)
|
||||
|
||||
expect(node).toBeNull()
|
||||
expect(messages.join('\n')).toContain('TUI Mode Status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui status subcommand', () => {
|
||||
test('reports disabled when no marker file', async () => {
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
})
|
||||
|
||||
test('reports enabled when marker file exists', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const markerPath = getTuiMarkerPath()
|
||||
// Write the marker
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
writeFileSync(markerPath, '1', 'utf8')
|
||||
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui on subcommand', () => {
|
||||
test('writes marker file', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const markerPath = getTuiMarkerPath()
|
||||
expect(existsSync(markerPath)).toBe(false)
|
||||
|
||||
const result = await invokeCmd('on')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
expect(existsSync(markerPath)).toBe(true)
|
||||
})
|
||||
|
||||
test('idempotent: on when already on reports already enabled', async () => {
|
||||
await invokeCmd('on')
|
||||
const result = await invokeCmd('on')
|
||||
expect(result.type).toBe('text')
|
||||
// Second call still returns a success message
|
||||
expect(result.value).toContain('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui off subcommand', () => {
|
||||
test('removes marker file', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
await invokeCmd('on')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
|
||||
const result = await invokeCmd('off')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(false)
|
||||
})
|
||||
|
||||
test('off when already off returns graceful message', async () => {
|
||||
const result = await invokeCmd('off')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('not active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui toggle subcommand', () => {
|
||||
test('toggle with no marker enables tui', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const result = await invokeCmd('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('enabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
})
|
||||
|
||||
test('toggle with marker disables tui', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
await invokeCmd('')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(true)
|
||||
|
||||
const result = await invokeCmd('')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('disabled')
|
||||
expect(existsSync(getTuiMarkerPath())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui unknown subcommand', () => {
|
||||
test('returns usage text for unknown subcommand', async () => {
|
||||
const result = await invokeCmd('foobar')
|
||||
expect(result.type).toBe('text')
|
||||
expect(result.value).toContain('Usage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTuiMarkerPath', () => {
|
||||
test('returns path under CLAUDE_CONFIG_DIR', async () => {
|
||||
const { getTuiMarkerPath } = await import('../index.js')
|
||||
const p = getTuiMarkerPath()
|
||||
expect(p).toContain(claudeDir)
|
||||
expect(p).toContain('.tui-mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tui status env var display', () => {
|
||||
test('shows forced-on when CLAUDE_CODE_NO_FLICKER=1', async () => {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = '1'
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.value).toContain('forced on via env var')
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
|
||||
test('shows forced-off when CLAUDE_CODE_NO_FLICKER=0', async () => {
|
||||
process.env.CLAUDE_CODE_NO_FLICKER = '0'
|
||||
const result = await invokeCmd('status')
|
||||
expect(result.value).toContain('forced off via env var')
|
||||
delete process.env.CLAUDE_CODE_NO_FLICKER
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTuiModeEnabled', () => {
|
||||
test('returns false when marker absent', async () => {
|
||||
const { isTuiModeEnabled } = await import('../index.js')
|
||||
expect(isTuiModeEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when marker present', async () => {
|
||||
const { isTuiModeEnabled, getTuiMarkerPath } = await import('../index.js')
|
||||
const { writeFileSync } = await import('node:fs')
|
||||
writeFileSync(getTuiMarkerPath(), '1', 'utf8')
|
||||
expect(isTuiModeEnabled()).toBe(true)
|
||||
})
|
||||
})
|
||||
184
src/commands/tui/index.ts
Normal file
184
src/commands/tui/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
|
||||
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
|
||||
import type { Command, LocalCommandResult } from '../../types/command.js'
|
||||
|
||||
/**
|
||||
* Path to the TUI-mode marker file.
|
||||
*
|
||||
* When this file exists, the user has opted in to flicker-free TUI mode
|
||||
* (alternate screen buffer via CLAUDE_CODE_NO_FLICKER=1). The marker is
|
||||
* session-independent: it persists across restarts so the user only needs to
|
||||
* run `/tui on` once.
|
||||
*
|
||||
* Shell-profile integration: add the following to ~/.bashrc / ~/.zshrc to
|
||||
* auto-enable TUI mode when the marker is present:
|
||||
*
|
||||
* [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1
|
||||
*
|
||||
* Note: setting CLAUDE_CODE_NO_FLICKER at runtime cannot retroactively enter
|
||||
* the alternate screen buffer — the Ink render tree is already mounted. The
|
||||
* change takes effect on the NEXT session start.
|
||||
*/
|
||||
export function getTuiMarkerPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), '.tui-mode')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the TUI-mode marker file is present, meaning the user has
|
||||
* opted in to flicker-free alternate-screen rendering.
|
||||
*/
|
||||
export function isTuiModeEnabled(): boolean {
|
||||
return existsSync(getTuiMarkerPath())
|
||||
}
|
||||
|
||||
const USAGE_TEXT = [
|
||||
'Usage: /tui [subcommand]',
|
||||
'',
|
||||
' (no args) Toggle flicker-free TUI mode (alternate screen buffer)',
|
||||
' on Enable TUI mode',
|
||||
' off Disable TUI mode',
|
||||
' status Show current TUI mode state',
|
||||
'',
|
||||
'TUI mode uses the ANSI alternate screen buffer (\\x1b[?1049h) so the',
|
||||
'Claude Code UI occupies a clean full-screen area with no scroll-back',
|
||||
'flicker. The setting is stored in ~/.claude/.tui-mode and takes effect',
|
||||
'on the next session start.',
|
||||
'',
|
||||
'Shell-profile integration (auto-enable on every start):',
|
||||
' [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1',
|
||||
'',
|
||||
'Environment override:',
|
||||
' CLAUDE_CODE_NO_FLICKER=1 force on (overrides marker)',
|
||||
' CLAUDE_CODE_NO_FLICKER=0 force off (overrides marker)',
|
||||
].join('\n')
|
||||
|
||||
function enableTui(): LocalCommandResult {
|
||||
const markerPath = getTuiMarkerPath()
|
||||
mkdirSync(getClaudeConfigHomeDir(), { recursive: true })
|
||||
writeFileSync(markerPath, new Date().toISOString(), 'utf8')
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI mode enabled',
|
||||
'',
|
||||
`Marker written: \`${markerPath}\``,
|
||||
'',
|
||||
'Flicker-free alternate-screen rendering will be active on the next',
|
||||
'session start. Add this to your shell profile to make it permanent:',
|
||||
'',
|
||||
' [ -f "$HOME/.claude/.tui-mode" ] && export CLAUDE_CODE_NO_FLICKER=1',
|
||||
'',
|
||||
'To disable: `/tui off`',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
function disableTui(): LocalCommandResult {
|
||||
const markerPath = getTuiMarkerPath()
|
||||
if (!existsSync(markerPath)) {
|
||||
return {
|
||||
type: 'text',
|
||||
value: 'TUI mode was not active.',
|
||||
}
|
||||
}
|
||||
unlinkSync(markerPath)
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI mode disabled',
|
||||
'',
|
||||
`Marker removed: \`${markerPath}\``,
|
||||
'',
|
||||
'Standard (non-alternate-screen) rendering will be used on the next',
|
||||
'session start.',
|
||||
'',
|
||||
'To re-enable: `/tui on`',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
export async function callTui(args: string): Promise<LocalCommandResult> {
|
||||
const sub = args.trim().toLowerCase()
|
||||
|
||||
// ── status ──────────────────────────────────────────────────────────
|
||||
if (sub === 'status') {
|
||||
const enabled = isTuiModeEnabled()
|
||||
const markerPath = getTuiMarkerPath()
|
||||
const envVal = process.env.CLAUDE_CODE_NO_FLICKER
|
||||
let envLine: string
|
||||
if (envVal === '1' || envVal === 'true') {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER=1 (forced on via env var)'
|
||||
} else if (envVal === '0' || envVal === 'false') {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER=0 (forced off via env var)'
|
||||
} else {
|
||||
envLine = 'CLAUDE_CODE_NO_FLICKER not set'
|
||||
}
|
||||
return {
|
||||
type: 'text',
|
||||
value: [
|
||||
'## TUI Mode Status',
|
||||
'',
|
||||
` Marker file: ${enabled ? 'present' : 'absent'} (\`${markerPath}\`)`,
|
||||
` Mode: ${enabled ? 'enabled' : 'disabled'}`,
|
||||
` Env var: ${envLine}`,
|
||||
'',
|
||||
'Note: changes take effect on the next session start.',
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
// ── on ───────────────────────────────────────────────────────────────
|
||||
if (sub === 'on') {
|
||||
return enableTui()
|
||||
}
|
||||
|
||||
// ── off ──────────────────────────────────────────────────────────────
|
||||
if (sub === 'off') {
|
||||
return disableTui()
|
||||
}
|
||||
|
||||
// ── toggle (legacy default) ──────────────────────────────────────────
|
||||
if (sub === '' || sub === 'toggle') {
|
||||
return isTuiModeEnabled() ? disableTui() : enableTui()
|
||||
}
|
||||
|
||||
// ── unknown subcommand ───────────────────────────────────────────────
|
||||
return {
|
||||
type: 'text',
|
||||
value: [`Unknown subcommand: "${sub}"`, '', USAGE_TEXT].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
const tuiCommand: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'tui',
|
||||
description:
|
||||
'Manage flicker-free TUI mode. Open actions or run: status, on, off, toggle',
|
||||
isHidden: false,
|
||||
isEnabled: () => !getIsNonInteractiveSession(),
|
||||
argumentHint: '[status|on|off|toggle]',
|
||||
bridgeSafe: true,
|
||||
getBridgeInvocationError: args =>
|
||||
args.trim()
|
||||
? undefined
|
||||
: 'Use /tui status/on/off/toggle over Remote Control.',
|
||||
load: () => import('./panel.js'),
|
||||
}
|
||||
|
||||
export const tuiNonInteractive: Command = {
|
||||
type: 'local',
|
||||
name: 'tui',
|
||||
description:
|
||||
'Toggle flicker-free TUI mode (alternate screen buffer). Subcommands: on, off, status',
|
||||
isHidden: false,
|
||||
isEnabled: () => getIsNonInteractiveSession(),
|
||||
supportsNonInteractive: true,
|
||||
bridgeSafe: true,
|
||||
load: async () => ({
|
||||
call: callTui,
|
||||
}),
|
||||
}
|
||||
|
||||
export default tuiCommand
|
||||
100
src/commands/tui/panel.tsx
Normal file
100
src/commands/tui/panel.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Box, Dialog, Text, useInput } from '@anthropic/ink';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { callTui } from './index.js';
|
||||
|
||||
type TuiAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
const ACTION_LABEL_COLUMN_WIDTH = 24;
|
||||
|
||||
async function runTuiAction(subcommand: string, onDone: LocalJSXCommandOnDone): Promise<void> {
|
||||
const result = await callTui(subcommand);
|
||||
if (result.type === 'text') {
|
||||
onDone(result.value, { display: 'system' });
|
||||
}
|
||||
}
|
||||
|
||||
function TuiPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const actions = useMemo<TuiAction[]>(
|
||||
() => [
|
||||
{
|
||||
label: 'Status',
|
||||
description: 'Show marker and environment override state',
|
||||
run: () => void runTuiAction('status', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Toggle',
|
||||
description: 'Flip persisted TUI mode for the next session',
|
||||
run: () => void runTuiAction('toggle', onDone),
|
||||
},
|
||||
{
|
||||
label: 'On',
|
||||
description: 'Enable flicker-free alternate-screen mode',
|
||||
run: () => void runTuiAction('on', onDone),
|
||||
},
|
||||
{
|
||||
label: 'Off',
|
||||
description: 'Disable flicker-free alternate-screen mode',
|
||||
run: () => void runTuiAction('off', onDone),
|
||||
},
|
||||
],
|
||||
[onDone],
|
||||
);
|
||||
|
||||
const selectCurrent = () => {
|
||||
const action = actions[selectedIndex];
|
||||
if (!action) return;
|
||||
action.run();
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (key.upArrow) {
|
||||
setSelectedIndex(index => Math.max(0, index - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSelectedIndex(index => Math.min(actions.length - 1, index + 1));
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
selectCurrent();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="TUI Mode"
|
||||
subtitle={`${actions.length} actions`}
|
||||
onCancel={() => onDone('TUI mode panel dismissed', { display: 'system' })}
|
||||
color="background"
|
||||
hideInputGuide
|
||||
>
|
||||
<Box flexDirection="column">
|
||||
{actions.map((action, index) => (
|
||||
<Box key={action.label} flexDirection="row">
|
||||
<Text>{`${index === selectedIndex ? '›' : ' '} ${action.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
|
||||
<Text dimColor>{action.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>↑/↓ select · Enter run · Esc close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
|
||||
const trimmed = args?.trim() ?? '';
|
||||
if (trimmed) {
|
||||
await runTuiAction(trimmed, onDone);
|
||||
return null;
|
||||
}
|
||||
return <TuiPanel onDone={onDone} />;
|
||||
}
|
||||
Reference in New Issue
Block a user