fix(cli): TUI polish — Ctrl+T, React keys, clipboard, version (#205)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #205.
This commit is contained in:
@@ -291,7 +291,7 @@ export class CommandExecutorService {
|
|||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
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,
|
conversationId,
|
||||||
data: { loginUrl, pollToken, provider: providerName },
|
data: { loginUrl, pollToken, provider: providerName },
|
||||||
};
|
};
|
||||||
|
|||||||
40
docs/scratchpads/BUG-CLI-scratchpad.md
Normal file
40
docs/scratchpads/BUG-CLI-scratchpad.md
Normal 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
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { createRequire } from 'module';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
import { createQualityRailsCli } from '@mosaic/quality-rails';
|
||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerPrdyCommand } from './commands/prdy.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();
|
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 ──────────────────────────────────────────────────────────────
|
// ─── login ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -176,6 +180,7 @@ program
|
|||||||
agentId,
|
agentId,
|
||||||
agentName: agentName ?? undefined,
|
agentName: agentName ?? undefined,
|
||||||
projectId,
|
projectId,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
{ exitOnCtrlC: false },
|
{ exitOnCtrlC: false },
|
||||||
);
|
);
|
||||||
@@ -249,6 +254,7 @@ sessionsCmd
|
|||||||
gatewayUrl: opts.gateway,
|
gatewayUrl: opts.gateway,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
sessionCookie: session.cookie,
|
sessionCookie: session.cookie,
|
||||||
|
version: CLI_VERSION,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface TuiAppProps {
|
|||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** CLI package version passed from the entry point (cli.ts). */
|
||||||
|
version?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
@@ -35,6 +37,7 @@ export function TuiApp({
|
|||||||
agentId,
|
agentId,
|
||||||
agentName,
|
agentName,
|
||||||
projectId: _projectId,
|
projectId: _projectId,
|
||||||
|
version = '0.0.0',
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const gitInfo = useGitInfo();
|
const gitInfo = useGitInfo();
|
||||||
@@ -77,6 +80,9 @@ export function TuiApp({
|
|||||||
const [tuiInput, setTuiInput] = useState('');
|
const [tuiInput, setTuiInput] = useState('');
|
||||||
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
// Ctrl+C double-press: first press with empty input shows hint; second exits
|
||||||
const ctrlCPendingExit = useRef(false);
|
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(
|
const handleLocalCommand = useCallback(
|
||||||
(parsed: ParsedCommand) => {
|
(parsed: ParsedCommand) => {
|
||||||
@@ -189,14 +195,23 @@ export function TuiApp({
|
|||||||
ctrlCPendingExit.current = false;
|
ctrlCPendingExit.current = false;
|
||||||
// Ctrl+L: toggle sidebar (refresh on open)
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
if (key.ctrl && ch === 'l') {
|
if (key.ctrl && ch === 'l') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
const willOpen = !appMode.sidebarOpen;
|
const willOpen = !appMode.sidebarOpen;
|
||||||
appMode.toggleSidebar();
|
appMode.toggleSidebar();
|
||||||
if (willOpen) {
|
if (willOpen) {
|
||||||
void conversations.refresh();
|
void conversations.refresh();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+N: create new conversation and switch to it
|
// Ctrl+N: create new conversation and switch to it
|
||||||
if (key.ctrl && ch === 'n') {
|
if (key.ctrl && ch === 'n') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
void conversations
|
void conversations
|
||||||
.createConversation()
|
.createConversation()
|
||||||
.then((conv) => {
|
.then((conv) => {
|
||||||
@@ -206,15 +221,21 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Ctrl+K: toggle search mode
|
// Ctrl+K: toggle search mode
|
||||||
if (key.ctrl && ch === 'k') {
|
if (key.ctrl && ch === 'k') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
if (appMode.mode === 'search') {
|
if (appMode.mode === 'search') {
|
||||||
search.clear();
|
search.clear();
|
||||||
appMode.setMode('chat');
|
appMode.setMode('chat');
|
||||||
} else {
|
} else {
|
||||||
appMode.setMode('search');
|
appMode.setMode('search');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Page Up / Page Down: scroll message history (only in chat mode)
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
if (appMode.mode === 'chat') {
|
if (appMode.mode === 'chat') {
|
||||||
@@ -227,6 +248,10 @@ export function TuiApp({
|
|||||||
}
|
}
|
||||||
// Ctrl+T: cycle thinking level
|
// Ctrl+T: cycle thinking level
|
||||||
if (key.ctrl && ch === 't') {
|
if (key.ctrl && ch === 't') {
|
||||||
|
ctrlJustFired.current = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
ctrlJustFired.current = false;
|
||||||
|
});
|
||||||
const levels = socket.availableThinkingLevels;
|
const levels = socket.availableThinkingLevels;
|
||||||
if (levels.length > 0) {
|
if (levels.length > 0) {
|
||||||
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||||
@@ -236,6 +261,7 @@ export function TuiApp({
|
|||||||
socket.setThinkingLevel(next);
|
socket.setThinkingLevel(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
@@ -292,7 +318,18 @@ export function TuiApp({
|
|||||||
|
|
||||||
<InputBar
|
<InputBar
|
||||||
value={tuiInput}
|
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}
|
onSubmit={socket.sendMessage}
|
||||||
onSystemMessage={socket.addSystemMessage}
|
onSystemMessage={socket.addSystemMessage}
|
||||||
onLocalCommand={handleLocalCommand}
|
onLocalCommand={handleLocalCommand}
|
||||||
@@ -311,7 +348,7 @@ export function TuiApp({
|
|||||||
<Box marginTop={1} />
|
<Box marginTop={1} />
|
||||||
<TopBar
|
<TopBar
|
||||||
gatewayUrl={gatewayUrl}
|
gatewayUrl={gatewayUrl}
|
||||||
version="0.0.0"
|
version={version}
|
||||||
modelName={socket.modelName}
|
modelName={socket.modelName}
|
||||||
thinkingLevel={socket.thinkingLevel}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
contextWindow={socket.tokenUsage.contextWindow}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ export class CommandRegistry {
|
|||||||
|
|
||||||
getAll(): CommandDef[] {
|
getAll(): CommandDef[] {
|
||||||
const gateway = this.gatewayManifest?.commands ?? [];
|
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[] {
|
getLocalCommands(): CommandDef[] {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function CommandAutocomplete({
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||||
{filtered.slice(0, 8).map((cmd, i) => (
|
{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}>
|
<Text color={i === clampedIndex ? 'cyan' : 'white'} bold={i === clampedIndex}>
|
||||||
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
{i === clampedIndex ? '▶ ' : ' '}/{cmd.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user