Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"format:check": "prettier --check .",
"typecheck": "tsc -b",
"ci": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
"release": "node scripts/release.mjs"
"release": "node scripts/release.mjs",
"prepare": "husky"
},
"devDependencies": {
"@commitlint/cli": "^20.5.0",
Expand Down
2 changes: 0 additions & 2 deletions packages/cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
// cursor positioning — which together eliminate the CJK/IME jitter the
// original Ink exhibits on long-running chat UIs. Nothing in our codebase
// changes: the fork is API-compatible with `ink`.
import React from 'react'

import { render } from 'ink'

import type { AgentOptions, LanguageModel, LoadedSession } from '@x-code-cli/core'
Expand Down
25 changes: 0 additions & 25 deletions packages/cli/src/config/index.ts

This file was deleted.

67 changes: 14 additions & 53 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
import type { AgentOptions, LoadedSession } from '@x-code-cli/core'

import { getCleanupFn, getSessionExitInfo, startApp } from './app.js'
import { detectShell, formatPersistCommand } from './shell.js'
import type { ShellType } from './shell.js'
import { setSyntaxTheme } from './ui/syntax-highlight.js'
import { getThemeColors, parseThemeName, setTheme } from './ui/theme.js'
import { VERSION } from './version.js'
Expand Down Expand Up @@ -396,28 +398,19 @@ function printNoApiKeyMessage() {
)

const shell = detectShell()
const restartHint: Record<ShellType, string> = {
powershell: '# restart PowerShell, then run:',
cmd: ':: restart CMD, then run:',
zsh: '',
bash: '',
fish: '',
sh: '',
}
console.error(`\nDetected shell: ${chalk.bold(shell)}`)
console.error('Persist it so you do not need to set it every session:\n')
switch (shell) {
case 'powershell':
console.error(` ${code(`[Environment]::SetEnvironmentVariable('ANTHROPIC_API_KEY','sk-ant-...','User')`)}`)
console.error(` ${comment('# restart PowerShell, then run:')} ${code('xc')}`)
break
case 'cmd':
console.error(` ${code('setx ANTHROPIC_API_KEY "sk-ant-..."')}`)
console.error(` ${comment(':: restart CMD, then run:')} ${code('xc')}`)
break
case 'zsh':
console.error(` ${code(`echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.zshrc && source ~/.zshrc`)}`)
break
case 'fish':
console.error(` ${code('set -Ux ANTHROPIC_API_KEY sk-ant-...')}`)
break
case 'bash':
default:
console.error(` ${code(`echo 'export ANTHROPIC_API_KEY=sk-ant-...' >> ~/.bashrc && source ~/.bashrc`)}`)
break
}
console.error(` ${code(formatPersistCommand('ANTHROPIC_API_KEY', 'sk-ant-...', shell))}`)
const hint = restartHint[shell]
if (hint) console.error(` ${comment(hint)} ${code('xc')}`)
console.error(`\nAlternatively, put keys in a project-local ${chalk.bold('.env')} file (loaded from cwd upward).`)
}

Expand All @@ -434,42 +427,10 @@ function printNoWebSearchKeyHint(): void {
console.error(` ${bold('TAVILY_API_KEY')} ${dim('1000/month — https://tavily.com')}`)
console.error(` ${bold('BRAVE_API_KEY')} ${dim('2000/month — https://api.search.brave.com')}`)

let cmd: string
switch (shell) {
case 'powershell':
cmd = `[Environment]::SetEnvironmentVariable('TAVILY_API_KEY','tvly-...','User')`
break
case 'cmd':
cmd = `setx TAVILY_API_KEY "tvly-..."`
break
case 'zsh':
cmd = `echo 'export TAVILY_API_KEY=tvly-...' >> ~/.zshrc && source ~/.zshrc`
break
case 'fish':
cmd = `set -Ux TAVILY_API_KEY tvly-...`
break
case 'bash':
default:
cmd = `echo 'export TAVILY_API_KEY=tvly-...' >> ~/.bashrc && source ~/.bashrc`
break
}
const cmd = formatPersistCommand('TAVILY_API_KEY', 'tvly-...', shell)
console.error(` ${dim(`(${shell})`)} ${code(cmd)}\n`)
}

function detectShell(): 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | 'sh' {
if (process.platform === 'win32') {
// PowerShell sets PSModulePath; CMD typically doesn't (and no PSHOME).
if (process.env.PSModulePath) return 'powershell'
return 'cmd'
}
const shellPath = process.env.SHELL ?? ''
const base = shellPath.split('/').pop() ?? ''
if (base === 'zsh' || base === 'bash' || base === 'fish' || base === 'sh') return base
// macOS defaults to zsh since Catalina
if (process.platform === 'darwin') return 'zsh'
return 'bash'
}

function readStdin(): Promise<string> {
return new Promise((resolve) => {
let data = ''
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @x-code-cli/cli — Shell detection and persistence-command helpers.
//
// Shell detection is needed by multiple message-printing functions in
// index.ts. The formatPersistCommand helper extracts the copy-pasted
// switch(shell) block that appeared identically in printNoApiKeyMessage
// and printNoWebSearchKeyHint.

export type ShellType = 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | 'sh'

export function detectShell(): ShellType {
if (process.platform === 'win32') {
if (process.env.PSModulePath) return 'powershell'
return 'cmd'
}
const shellPath = process.env.SHELL ?? ''
const base = shellPath.split('/').pop() ?? ''
if (base === 'zsh' || base === 'bash' || base === 'fish' || base === 'sh') return base
if (process.platform === 'darwin') return 'zsh'
return 'bash'
}

/**
* Return a copy-pasteable shell command that persists an environment
* variable. The returned string is the command only (no prefix, no
* newline) — callers wrap it in chalk color and surrounding prose.
*
* envVar — e.g. "ANTHROPIC_API_KEY"
* exampleValue — e.g. "sk-ant-..."
* shell — result from detectShell()
*/
export function formatPersistCommand(envVar: string, exampleValue: string, shell: ShellType): string {
switch (shell) {
case 'powershell':
return `[Environment]::SetEnvironmentVariable('${envVar}','${exampleValue}','User')`
case 'cmd':
return `setx ${envVar} "${exampleValue}"`
case 'zsh':
return `echo 'export ${envVar}=${exampleValue}' >> ~/.zshrc && source ~/.zshrc`
case 'fish':
return `set -Ux ${envVar} ${exampleValue}`
case 'bash':
default:
return `echo 'export ${envVar}=${exampleValue}' >> ~/.bashrc && source ~/.bashrc`
}
}
39 changes: 9 additions & 30 deletions packages/cli/src/ui/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { buildThemePreview } from '../render-diff.js'
import { setSyntaxTheme } from '../syntax-highlight.js'
import { GLYPH_BULLET } from '../terminal-glyphs.js'
import { DEFAULT_THEME, THEMES, type ThemeName, getTheme, getThemeColors, parseThemeName, setTheme } from '../theme.js'
import { parseBooleanArg } from '../utils.js'
import { getHeaderRowCount } from './AppHeader.js'
import { ChatInput } from './ChatInput.js'

Expand Down Expand Up @@ -630,18 +631,8 @@ export function App({

// Direct-switch shortcut path.
if (trimmed) {
let next: boolean
if (trimmed === 'on' || trimmed === 'true' || trimmed === '1' || trimmed === 'enable' || trimmed === 'enabled') {
next = true
} else if (
trimmed === 'off' ||
trimmed === 'false' ||
trimmed === '0' ||
trimmed === 'disable' ||
trimmed === 'disabled'
) {
next = false
} else {
const next = parseBooleanArg(trimmed)
if (next === null) {
addCommandMessage(
commandText,
`Unknown value: \`${arg}\`. Use \`/thinking\`, \`/thinking on\`, or \`/thinking off\`.`,
Expand Down Expand Up @@ -812,25 +803,13 @@ export function App({
let next: boolean
if (!trimmed) {
next = !current
} else if (
trimmed === 'on' ||
trimmed === 'true' ||
trimmed === '1' ||
trimmed === 'enable' ||
trimmed === 'enabled'
) {
next = true
} else if (
trimmed === 'off' ||
trimmed === 'false' ||
trimmed === '0' ||
trimmed === 'disable' ||
trimmed === 'disabled'
) {
next = false
} else {
addCommandMessage(commandText, `Unknown value: \`${arg}\`. Use \`/plan\`, \`/plan on\`, or \`/plan off\`.`)
return
const parsed = parseBooleanArg(trimmed)
if (parsed === null) {
addCommandMessage(commandText, `Unknown value: \`${arg}\`. Use \`/plan\`, \`/plan on\`, or \`/plan off\`.`)
return
}
next = parsed
}

if (next === current) {
Expand Down
35 changes: 7 additions & 28 deletions packages/cli/src/ui/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
SPINNER_FRAMES,
} from '../terminal-glyphs.js'
import { charWidth, sliceByWidth, visualWidth } from '../text-width.js'
import { getToolInputPreview, getToolLabel, isCollapsibleReadOnlyTool } from '../tool-display.js'
import { formatTokenCount, getToolInputPreview, getToolLabel, isCollapsibleReadOnlyTool } from '../utils.js'

const PASTE_REF_MIN_LINES = 3
const PASTE_REF_MIN_CHARS = 400
Expand Down Expand Up @@ -349,7 +349,6 @@ function renderRowToAnsi(cells: Cell[]): string {
// — keep these two tables in sync.
const S_GRAY = '\x1b[38;2;136;136;136m' // promptBorder rgb(136,136,136) #888888
const S_ACCENT = '\x1b[38;2;215;119;87m' // claude rgb(215,119,87) #d77757
const S_ACCENT_BOLD = '\x1b[38;2;215;119;87;1m'
const S_ACCENT_DIM = '\x1b[38;2;153;153;153m' // inactive rgb(153,153,153) #999999
const S_SPINNER = '\x1b[38;2;147;165;255m' // claudeBlue rgb(147,165,255) #93a5ff
const S_SUCCESS = '\x1b[38;2;78;186;101;1m' // success rgb(78,186,101) #4eba65
Expand All @@ -376,11 +375,9 @@ const S_BOLD = '\x1b[0m\x1b[1m'
// (147,165,255) which is a DIFFERENT shade, producing a visible
// color shift at the live→committed handoff.
const S_BLUE_PURPLE = '\x1b[0m\x1b[38;2;177;185;249m'
const S_BLUE_PURPLE_DIM = '\x1b[0m\x1b[38;2;177;185;249;2m'
const S_BLUE_PURPLE_BOLD = '\x1b[0m\x1b[38;2;177;185;249;1m'
const S_WARNING = '\x1b[38;2;255;193;7m' // warning rgb(255,193,7) #ffc107
const S_WARNING_BOLD = '\x1b[38;2;255;193;7;1m'
const S_ERROR_FG = '\x1b[38;2;255;107;128m' // error rgb(255,107,128) #ff6b80
const S_ERROR_BOLD = '\x1b[38;2;255;107;128;1m'
// NB: leading `\x1b[0m` matters. Plain `\x1b[2m` just adds the "dim"
// attribute ON TOP of whatever foreground color is active — so meta
Expand Down Expand Up @@ -461,7 +458,6 @@ const S_CURSOR = '\x1b[7m'
* places it at the input column before ESU commits. When there is no
* active anchor (disabled / dialog) ESU_HIDE explicitly hides. */
const BSU = '\x1b[?2026h'
const ESU_SHOW = '\x1b[?2026l\x1b[?25h'
const ESU_HIDE = '\x1b[?2026l\x1b[?25l'

// NOTE: a DECSTBM-based `buildInsertHistoryAbove` existed briefly here
Expand Down Expand Up @@ -614,12 +610,6 @@ function formatElapsed(ms: number): string {
return `${minutes}m ${secs}s`
}

function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
return `${tokens}`
}

// ── Component ───────────────────────────────────────────────────────────

export function ChatInput({
Expand Down Expand Up @@ -2153,14 +2143,10 @@ export function ChatInput({
// Top separator
frame.push(textToCells(sepText, S_GRAY))

// Input lines. `cursorAnchor` captures the frame-row index (0-based)
// and 1-based visual column where the terminal's real cursor should
// be parked at end of render. ESU is then chosen based on whether an
// anchor is set: ESU_SHOW (trailing `\x1b[?25h`) reveals the caret at
// that column so it is the one and only visible cursor; ESU_HIDE
// (trailing `\x1b[?25l`) keeps it hidden when the input is disabled
// or there is no active cursor line.
let cursorAnchor: { row: number; col: number } | null = null
// Input lines. The terminal's hardware cursor is hidden for the
// entire TUI lifetime; the visible "cursor" the user sees is just an
// inverse-video cell (S_CURSOR) drawn into the frame at the cursor
// position. So we don't compute or emit a cursor-park CSI here.
for (let i = 0; i < displayLines.length; i++) {
const line = displayLines[i]
const prompt = i === 0 ? '> ' : ' '
Expand All @@ -2182,10 +2168,6 @@ export function ChatInput({

if (lw <= vpWidth) {
cells.push(...textToCells(before, S_RESET))
// Visual col = prompt width (2) + width of chars before cursor,
// +1 to convert to 1-based. Captured BEFORE pushing cursor cell
// so it reflects the cursor cell's starting column.
cursorAnchor = { row: frame.length, col: 2 + visualWidth(before) + 1 }
cells.push({ char: cursorChar, style: S_CURSOR, width: charWidth(cursorChar) })
cells.push(...textToCells(after, S_RESET))
} else {
Expand All @@ -2200,7 +2182,6 @@ export function ChatInput({
const remaining = vpWidth - visualWidth(vb) - charWidth(cursorChar)
const va = sliceByWidth(line.slice(afterStart), Math.max(0, remaining))
cells.push(...textToCells(vb, S_RESET))
cursorAnchor = { row: frame.length, col: 2 + visualWidth(vb) + 1 }
cells.push({ char: cursorChar, style: S_CURSOR, width: charWidth(cursorChar) })
cells.push(...textToCells(va, S_RESET))
}
Expand Down Expand Up @@ -2253,7 +2234,7 @@ export function ChatInput({
let rightText: string | null = null
if (contextUsage && contextUsage.used > 0 && contextUsage.window > 0) {
const pct = Math.round((contextUsage.used / contextUsage.window) * 100)
rightText = `${formatTokens(contextUsage.used)} / ${formatTokens(contextUsage.window)} · ${pct}%`
rightText = `${formatTokenCount(contextUsage.used)} / ${formatTokenCount(contextUsage.window)} · ${pct}%`
}

if (leftCells || rightText) {
Expand Down Expand Up @@ -3098,9 +3079,7 @@ export function ChatInput({
// above. Skipping the park removes one cursor-position command per
// flush — on weak terminals each such command kicks the renderer's
// state machine even when the cursor itself is hidden, so dropping
// it visibly reduces residual flicker. cursorAnchor is still
// computed because lower paths (and future revival of the visible
// cursor) read it; it's just no longer emitted as a CSI H here.
// it visibly reduces residual flicker.

// Flush everything as a single write: preBuf (BSU + DECSTBM scrollback
// insertion + any frame-height-change scrolling) + frame diff + ESU.
Expand Down
Loading
Loading