From 18525802f242021c1c2d5e64f94ac13d7155062e Mon Sep 17 00:00:00 2001 From: SummonLav Date: Sat, 4 Apr 2026 16:02:28 +0800 Subject: [PATCH] feat: add rename buddy option to start screen Add a "Rename buddy" menu entry to the start screen (both OpenTUI and Inquirer fallback) that lets users rename their companion without manually editing ~/.claude.json. Selecting it opens a dedicated rename page; on completion the user returns to the start screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tui/commands/start-screen.ts | 39 +++++++- src/tui/prompts.ts | 20 +++- src/tui/rename/index.ts | 153 +++++++++++++++++++++++++++++++ src/tui/start/index.ts | 20 +++- 4 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 src/tui/rename/index.ts diff --git a/src/tui/commands/start-screen.ts b/src/tui/commands/start-screen.ts index a86aee6..ab56115 100644 --- a/src/tui/commands/start-screen.ts +++ b/src/tui/commands/start-screen.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import type { CliFlags, DesiredTraits } from '@/types.js'; import type { Preset } from '@/presets.js'; -import { getProfiles } from '@/config/index.js'; +import { getProfiles, getCompanionName, renameCompanion } from '@/config/index.js'; import { selectStartAction, selectPreset } from '../prompts.ts'; import { banner } from '../display.ts'; import { runBuddies } from './buddies.ts'; @@ -32,7 +32,7 @@ function presetToTraits(preset: Preset): DesiredTraits { }; } -type StartAction = 'build' | 'presets' | 'buddies'; +type StartAction = 'build' | 'presets' | 'buddies' | 'rename'; export async function runStartScreen(flags: CliFlags = {}): Promise { if (hasTraitFlags(flags)) { @@ -76,7 +76,7 @@ export async function runStartScreen(flags: CliFlags = {}): Promise { ); } } - action = await selectStartAction(buddyCount); + action = await selectStartAction(buddyCount, { companionName: getCompanionName() }); } switch (action) { @@ -109,5 +109,38 @@ export async function runStartScreen(flags: CliFlags = {}): Promise { case 'buddies': return runBuddies(); + + case 'rename': { + let handled = false; + try { + const { canUseBuilder } = await import('../builder/index.ts'); + if (await canUseBuilder()) { + const { runRenameTUI } = await import('../rename/index.ts'); + await runRenameTUI(); + handled = true; + } + } catch { + // OpenTUI unavailable — fall through to inquirer + } + + if (!handled) { + const { input } = await import('@inquirer/prompts'); + const currentName = getCompanionName(); + const newName = ( + await input({ + message: `New name for your buddy (current: "${currentName}")`, + }) + ).trim(); + if (newName && newName !== currentName) { + try { + renameCompanion(newName); + console.log(chalk.green(` Renamed "${currentName}" → "${newName}"`)); + } catch (err) { + console.log(chalk.yellow(` Could not rename: ${(err as Error).message}`)); + } + } + } + return runStartScreen(flags); + } } } diff --git a/src/tui/prompts.ts b/src/tui/prompts.ts index c169605..8cc94a9 100644 --- a/src/tui/prompts.ts +++ b/src/tui/prompts.ts @@ -91,10 +91,22 @@ export async function selectMode(): Promise<'preset' | 'custom'> { }); } -export type StartAction = 'build' | 'presets' | 'buddies'; +export type StartAction = 'build' | 'presets' | 'buddies' | 'rename'; -export async function selectStartAction(buddyCount: number): Promise { - const choices: { name: string; value: StartAction }[] = [ +export async function selectStartAction( + buddyCount: number, + { companionName }: { companionName?: string | null } = {}, +): Promise { + const choices: { name: string; value: StartAction }[] = []; + + if (companionName) { + choices.push({ + name: `Rename buddy ${chalk.dim(`— current: "${companionName}"`)}`, + value: 'rename', + }); + } + + choices.push( { name: `Build your own ${chalk.dim('— customize species, rarity, eyes, hat')}`, value: 'build', @@ -103,7 +115,7 @@ export async function selectStartAction(buddyCount: number): Promise 0) { choices.push({ diff --git a/src/tui/rename/index.ts b/src/tui/rename/index.ts new file mode 100644 index 0000000..dc8e545 --- /dev/null +++ b/src/tui/rename/index.ts @@ -0,0 +1,153 @@ +import { ISSUE_URL } from '@/constants.js'; +import { BORDER_COLOR, DIM_COLOR } from '@/tui/builder/colors.js'; +import { getCompanionName, renameCompanion } from '@/config/index.js'; + +/** + * Full-screen rename TUI. Returns true if a rename was performed, false on cancel. + */ +export async function runRenameTUI(): Promise { + const otui = await import('@opentui/core'); + const { createCliRenderer, Box, Text, Input, InputRenderableEvents } = otui; + type TextRenderableType = InstanceType; + type InputRenderableType = InstanceType; + + let renderer: Awaited> | null = null; + + try { + renderer = await createCliRenderer({ + exitOnCtrlC: false, + screenMode: 'alternate-screen', + }); + + const r = renderer; + + return await new Promise((resolve) => { + let resolved = false; + + const currentName = getCompanionName() ?? ''; + + const handleCtrlC = (key: { ctrl?: boolean; name?: string }) => { + if (key.ctrl && key.name === 'c') finish(false); + }; + + function finish(result: boolean): void { + if (resolved) return; + resolved = true; + r.keyInput.removeListener('keypress', handleCtrlC); + r.keyInput.removeListener('keypress', handleEscape); + r.destroy(); + renderer = null; + resolve(result); + } + + function handleEscape(key: { name?: string }): void { + if (key.name === 'escape') finish(false); + } + + const rootBox = Box( + { + id: 'root', + flexDirection: 'column', + width: '100%', + height: '100%', + borderStyle: 'rounded', + border: true, + borderColor: BORDER_COLOR, + title: ' Rename buddy ', + titleAlignment: 'center', + padding: 0, + justifyContent: 'center', + alignItems: 'center', + }, + + Text({ + id: 'label', + content: ` Current name: "${currentName}"`, + fg: DIM_COLOR, + height: 1, + }), + + Text({ content: '', height: 1 }), + + Text({ + content: ' New name:', + fg: '#ffffff', + height: 1, + }), + + Text({ content: '', height: 1 }), + + Input({ + id: 'rename-input', + placeholder: currentName, + width: 40, + focusedTextColor: '#ffffff', + placeholderColor: '#555555', + }), + + Text({ content: '', height: 2 }), + + Text({ + id: 'status', + content: '', + height: 1, + }), + + Text({ content: '', height: 1 }), + + Text({ + content: 'Enter confirm Esc back', + fg: DIM_COLOR, + height: 1, + }), + ); + + r.root.add(rootBox); + + const input = r.root.findDescendantById('rename-input') as InputRenderableType | null; + const status = r.root.findDescendantById('status') as TextRenderableType | null; + + if (input) { + input.focus(); + input.on(InputRenderableEvents.ENTER, () => { + const newName = (input.value ?? '').trim(); + if (!newName) { + if (status) { + status.content = ' Name cannot be empty'; + status.fg = '#ff5555'; + } + return; + } + if (newName === currentName) { + finish(false); + return; + } + try { + renameCompanion(newName); + finish(true); + } catch (err) { + if (status) { + status.content = ` Error: ${(err as Error).message}`; + status.fg = '#ff5555'; + } + } + }); + } + + r.keyInput.on('keypress', handleCtrlC); + r.keyInput.on('keypress', handleEscape); + r.auto(); + }); + } catch (err) { + if (renderer) { + try { + renderer.destroy(); + } catch { + /* ignore */ + } + } + console.error(` Rename error: ${(err as Error).message}`); + console.error(` If this persists, please report at: ${ISSUE_URL}`); + return false; + } +} diff --git a/src/tui/start/index.ts b/src/tui/start/index.ts index b620a3b..c6b800a 100644 --- a/src/tui/start/index.ts +++ b/src/tui/start/index.ts @@ -1,7 +1,8 @@ import { ISSUE_URL } from '@/constants.js'; import { BORDER_COLOR, DIM_COLOR, FOCUS_BORDER } from '@/tui/builder/colors.js'; +import { getCompanionName } from '@/config/index.js'; -export type StartAction = 'build' | 'presets' | 'buddies'; +export type StartAction = 'build' | 'presets' | 'buddies' | 'rename'; interface MenuEntry { value: StartAction; @@ -38,7 +39,20 @@ export async function runStartTUI(buddyCount: number): Promise 0) { entries.push({