fix(cli): Ctrl+T leak, React key dupes, clipboard claim, version display
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

- #192: Suppress leaked character when Ctrl+T/L/N/K fires in useInput by
  setting a ctrlJustFired ref and rejecting the next onChange call in the
  InputBar onChange wrapper
- #193: CommandAutocomplete uses composite key `${cmd.execution}-${cmd.name}`
  to prevent duplicate key warnings; CommandRegistry.getAll() deduplicates
  gateway commands that share a name with local commands
- #194: /provider login message no longer claims '(URL copied to clipboard)'
  since clipboard access is unavailable server-side
- #199: cli.ts reads version from package.json via createRequire and passes
  it as a prop to TuiApp which forwards it to TopBar instead of hardcoded 0.0.0

Fixes #192, #193, #194, #199

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 21:39:48 -05:00
parent bf668e18f1
commit 4065fb02fc
6 changed files with 94 additions and 6 deletions

View File

@@ -291,7 +291,7 @@ export class CommandExecutorService {
return {
command: 'provider',
success: true,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}\n\n(URL copied to clipboard)`,
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
conversationId,
data: { loginUrl, pollToken, provider: providerName },
};

View File

@@ -0,0 +1,40 @@
# BUG-CLI Scratchpad
## Objective
Fix 4 CLI/TUI polish bugs in a single PR (issues #192, #193, #194, #199).
## Issues
- #192: Ctrl+T leaks 't' into input
- #193: Duplicate React keys in CommandAutocomplete
- #194: /provider login false clipboard claim
- #199: TUI shows hardcoded version "0.0.0"
## Plan and Fixes
### Bug #192 — Ctrl+T character leak
- Location: `packages/cli/src/tui/app.tsx`
- Fix: Added `ctrlJustFired` ref. Set synchronously in Ctrl+T/L/N/K handlers, cleared via microtask.
In the `onChange` wrapper passed to `InputBar`, if `ctrlJustFired.current` is true, suppress the
leaked character and return early.
### Bug #193 — Duplicate React keys
- Location: `packages/cli/src/tui/components/command-autocomplete.tsx`
- Fix: Changed `key={cmd.name}` to `key={`${cmd.execution}-${cmd.name}`}` for uniqueness.
- Also: `packages/cli/src/tui/commands/registry.ts``getAll()` now deduplicates gateway commands
that share a name with local commands. Local commands take precedence.
### Bug #194 — False clipboard claim
- Location: `apps/gateway/src/commands/command-executor.service.ts`
- Fix: Removed the `\n\n(URL copied to clipboard)` suffix from the provider login message.
### Bug #199 — Hardcoded version "0.0.0"
- Location: `packages/cli/src/cli.ts` + `packages/cli/src/tui/app.tsx`
- Fix: `cli.ts` reads version from `../package.json` via `createRequire`. Passes `version: CLI_VERSION`
to TuiApp in both render calls. TuiApp has new optional `version` prop (defaults to '0.0.0'),
passes it to TopBar instead of hardcoded `"0.0.0"`.
## Quality Gates
- CLI typecheck: PASSED
- CLI lint: PASSED
- Prettier format:check: PASSED
- Gateway lint: PASSED

View File

@@ -1,14 +1,18 @@
#!/usr/bin/env node
import { createRequire } from 'module';
import { Command } from 'commander';
import { createQualityRailsCli } from '@mosaic/quality-rails';
import { registerAgentCommand } from './commands/agent.js';
import { registerMissionCommand } from './commands/mission.js';
import { registerPrdyCommand } from './commands/prdy.js';
const _require = createRequire(import.meta.url);
const CLI_VERSION: string = (_require('../package.json') as { version: string }).version;
const program = new Command();
program.name('mosaic').description('Mosaic Stack CLI').version('0.0.0');
program.name('mosaic').description('Mosaic Stack CLI').version(CLI_VERSION);
// ─── login ──────────────────────────────────────────────────────────────
@@ -176,6 +180,7 @@ program
agentId,
agentName: agentName ?? undefined,
projectId,
version: CLI_VERSION,
}),
{ exitOnCtrlC: false },
);
@@ -249,6 +254,7 @@ sessionsCmd
gatewayUrl: opts.gateway,
conversationId: id,
sessionCookie: session.cookie,
version: CLI_VERSION,
}),
);
});

View File

@@ -24,6 +24,8 @@ export interface TuiAppProps {
agentId?: string;
agentName?: string;
projectId?: string;
/** CLI package version passed from the entry point (cli.ts). */
version?: string;
}
export function TuiApp({
@@ -35,6 +37,7 @@ export function TuiApp({
agentId,
agentName,
projectId: _projectId,
version = '0.0.0',
}: TuiAppProps) {
const { exit } = useApp();
const gitInfo = useGitInfo();
@@ -77,6 +80,9 @@ export function TuiApp({
const [tuiInput, setTuiInput] = useState('');
// Ctrl+C double-press: first press with empty input shows hint; second exits
const ctrlCPendingExit = useRef(false);
// Flag to suppress the character that ink-text-input leaks when a Ctrl+key
// combo is handled by the top-level useInput handler (e.g. Ctrl+T → 't').
const ctrlJustFired = useRef(false);
const handleLocalCommand = useCallback(
(parsed: ParsedCommand) => {
@@ -189,14 +195,23 @@ export function TuiApp({
ctrlCPendingExit.current = false;
// Ctrl+L: toggle sidebar (refresh on open)
if (key.ctrl && ch === 'l') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
const willOpen = !appMode.sidebarOpen;
appMode.toggleSidebar();
if (willOpen) {
void conversations.refresh();
}
return;
}
// Ctrl+N: create new conversation and switch to it
if (key.ctrl && ch === 'n') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
void conversations
.createConversation()
.then((conv) => {
@@ -206,15 +221,21 @@ export function TuiApp({
}
})
.catch(() => {});
return;
}
// Ctrl+K: toggle search mode
if (key.ctrl && ch === 'k') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
if (appMode.mode === 'search') {
search.clear();
appMode.setMode('chat');
} else {
appMode.setMode('search');
}
return;
}
// Page Up / Page Down: scroll message history (only in chat mode)
if (appMode.mode === 'chat') {
@@ -227,6 +248,10 @@ export function TuiApp({
}
// Ctrl+T: cycle thinking level
if (key.ctrl && ch === 't') {
ctrlJustFired.current = true;
queueMicrotask(() => {
ctrlJustFired.current = false;
});
const levels = socket.availableThinkingLevels;
if (levels.length > 0) {
const currentIdx = levels.indexOf(socket.thinkingLevel);
@@ -236,6 +261,7 @@ export function TuiApp({
socket.setThinkingLevel(next);
}
}
return;
}
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
if (key.escape) {
@@ -292,7 +318,18 @@ export function TuiApp({
<InputBar
value={tuiInput}
onChange={setTuiInput}
onChange={(val: string) => {
// Suppress the character that ink-text-input leaks when a Ctrl+key
// combo fires (e.g. Ctrl+T inserts 't'). The ctrlJustFired ref is
// set synchronously in the useInput handler and cleared via a
// microtask, so this callback sees it as still true on the same
// event-loop tick.
if (ctrlJustFired.current) {
ctrlJustFired.current = false;
return;
}
setTuiInput(val);
}}
onSubmit={socket.sendMessage}
onSystemMessage={socket.addSystemMessage}
onLocalCommand={handleLocalCommand}
@@ -311,7 +348,7 @@ export function TuiApp({
<Box marginTop={1} />
<TopBar
gatewayUrl={gatewayUrl}
version="0.0.0"
version={version}
modelName={socket.modelName}
thinkingLevel={socket.thinkingLevel}
contextWindow={socket.tokenUsage.contextWindow}

View File

@@ -95,7 +95,12 @@ export class CommandRegistry {
getAll(): CommandDef[] {
const gateway = this.gatewayManifest?.commands ?? [];
return [...LOCAL_COMMANDS, ...gateway];
// Local commands take precedence; deduplicate gateway commands that share
// a name with a local command to avoid duplicate React keys and confusing
// autocomplete entries.
const localNames = new Set(LOCAL_COMMANDS.map((c) => c.name));
const dedupedGateway = gateway.filter((c) => !localNames.has(c.name));
return [...LOCAL_COMMANDS, ...dedupedGateway];
}
getLocalCommands(): CommandDef[] {

View File

@@ -27,7 +27,7 @@ export function CommandAutocomplete({
return (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
{filtered.slice(0, 8).map((cmd, i) => (
<Box key={cmd.name}>
<Box key={`${cmd.execution}-${cmd.name}`}>
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
</Text>