+
{slashOptions.map((item, index) => (
event.preventDefault()}
- onClick={() => selectSlashCommand(item)}
+ onClick={() => selectPaletteItem(item)}
>
- {item.command}
+ {item.kind === 'argument' ? item.label : item.command}
{item.kind === 'command' ? runs on laptop : null}
- {item.kind === 'command' ? item.hint : item.template}
+
+ {item.kind === 'template' ? item.template : item.hint}
+
))}
@@ -702,7 +750,7 @@ export function Composer({
onClick={() => {
const cmd = pendingCommand;
setPendingCommand(null);
- runCommand(cmd.name, cmd.input);
+ runCommand(cmd.name, cmd.input || undefined);
}}
>
Run /{pendingCommand.name}
diff --git a/mobile/src/ui/composer/__tests__/Composer.test.tsx b/mobile/src/ui/composer/__tests__/Composer.test.tsx
index e9716da..200744a 100644
--- a/mobile/src/ui/composer/__tests__/Composer.test.tsx
+++ b/mobile/src/ui/composer/__tests__/Composer.test.tsx
@@ -499,6 +499,88 @@ describe('Composer', () => {
expect(textbox).toHaveValue('');
});
+ it('uses the same keyboard flow for command selection and required text input', async () => {
+ const user = userEvent.setup();
+ const onCommand = vi.fn();
+ renderComposer({ onCommand });
+ const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' });
+
+ await user.type(textbox, '/ren');
+ fireEvent.keyDown(textbox, { key: 'Enter' });
+ expect(textbox).toHaveValue('/rename ');
+ expect(screen.getByRole('listbox', { name: 'Arguments for /rename' })).toBeInTheDocument();
+
+ await user.type(textbox, 'My Session');
+ fireEvent.keyDown(textbox, { key: 'Enter' });
+
+ expect(onCommand).toHaveBeenCalledWith('rename', 'My Session');
+ expect(textbox).toHaveValue('');
+ });
+
+ it('filters model choices by friendly metadata and invokes the hidden value on touch selection', async () => {
+ const user = userEvent.setup();
+ const onCommand = vi.fn();
+ const { container } = renderComposer({ onCommand });
+ const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' });
+
+ await user.type(textbox, '/mod');
+ await user.click(screen.getByRole('option', { name: /\/model/i }));
+ expect(textbox).toHaveValue('/model ');
+
+ const modelMenu = screen.getByRole('listbox', { name: 'Arguments for /model' });
+ expect(within(modelMenu).getByText('Auto')).toBeInTheDocument();
+ expect(within(modelMenu).getByText('Recommended')).toBeInTheDocument();
+ expect(container).not.toHaveTextContent('gpt-5.6-sol');
+ expect(container).not.toHaveTextContent('claude-sonnet-5');
+ expect(container).not.toHaveTextContent('gemini-3.8-flash');
+
+ await user.type(textbox, 'recommended');
+ expect(within(modelMenu).getAllByRole('option')).toHaveLength(1);
+ await user.click(within(modelMenu).getByRole('option', { name: /AutoRecommended/i }));
+
+ expect(onCommand).toHaveBeenCalledWith('model', 'auto');
+ expect(textbox).toHaveValue('');
+ });
+
+ it('canonicalizes a friendly model label when the command is submitted directly', async () => {
+ const user = userEvent.setup();
+ const onCommand = vi.fn();
+ renderComposer({ onCommand });
+ const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' });
+
+ await user.type(textbox, '/model Claude Sonnet 5');
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+
+ expect(onCommand).toHaveBeenCalledWith('model', 'claude-sonnet-5');
+ });
+
+ it('does not submit a curated option command with an unknown value', async () => {
+ const user = userEvent.setup();
+ const onCommand = vi.fn();
+ const onPrompt = vi.fn();
+ renderComposer({ onCommand, onPrompt });
+ const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' });
+
+ await user.type(textbox, '/model unavailable');
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }));
+
+ expect(onCommand).not.toHaveBeenCalled();
+ expect(onPrompt).not.toHaveBeenCalled();
+ expect(textbox).toHaveValue('/model unavailable');
+ });
+
+ it('allows optional text commands to run from the generic argument stage without text', async () => {
+ const user = userEvent.setup();
+ const onCommand = vi.fn();
+ renderComposer({ onCommand });
+ const textbox = screen.getByRole('textbox', { name: 'Message your Copilot session' });
+
+ await user.type(textbox, '/compact ');
+ await user.click(screen.getByRole('option', { name: /Optional focusOptional text/i }));
+
+ expect(onCommand).toHaveBeenCalledWith('compact', undefined);
+ });
+
it('invokes a no-arg command with undefined input', async () => {
const user = userEvent.setup();
const onCommand = vi.fn();
diff --git a/mobile/src/ui/composer/__tests__/commandPalette.test.ts b/mobile/src/ui/composer/__tests__/commandPalette.test.ts
new file mode 100644
index 0000000..a1b0091
--- /dev/null
+++ b/mobile/src/ui/composer/__tests__/commandPalette.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest';
+import type { PhoneCommand } from '@aasis21/weft-shared';
+import {
+ filterCommandOptions,
+ getCommandArgumentStage,
+ getCommandArgumentSuggestions,
+} from '@/ui/composer/commandPalette';
+
+const optionCommand: PhoneCommand = {
+ name: 'target',
+ label: '/target',
+ hint: 'Choose a target',
+ input: {
+ kind: 'options',
+ required: true,
+ allowCustom: true,
+ placeholder: 'Choose a target',
+ options: [
+ { value: 'hidden-one', label: 'Friendly One', hint: 'Recommended', aliases: ['default'] },
+ { value: 'hidden-two', label: 'Friendly Two', hint: 'Fast choice', aliases: ['quick'] },
+ ],
+ },
+};
+
+describe('commandPalette', () => {
+ it('opens the argument stage only after a command and separator', () => {
+ expect(getCommandArgumentStage('/target', optionCommand)).toBeNull();
+ expect(getCommandArgumentStage('/target ', optionCommand)).toEqual({
+ command: optionCommand,
+ query: '',
+ });
+ });
+
+ it('filters options by friendly labels, hints, and aliases without requiring internal values', () => {
+ if (optionCommand.input.kind !== 'options') throw new Error('test definition must use options');
+ expect(filterCommandOptions(optionCommand.input.options, 'friendly two').map((option) => option.value))
+ .toEqual(['hidden-two']);
+ expect(filterCommandOptions(optionCommand.input.options, 'recommended').map((option) => option.value))
+ .toEqual(['hidden-one']);
+ expect(filterCommandOptions(optionCommand.input.options, 'quick').map((option) => option.value))
+ .toEqual(['hidden-two']);
+ });
+
+ it('offers custom input only when the option definition allows it', () => {
+ if (optionCommand.input.kind !== 'options') throw new Error('test definition must use options');
+ expect(getCommandArgumentSuggestions(optionCommand, 'other').at(-1)).toMatchObject({
+ label: 'Use “other”',
+ hint: 'Custom value',
+ input: 'other',
+ });
+
+ const curatedOnly: PhoneCommand = {
+ ...optionCommand,
+ input: { ...optionCommand.input, allowCustom: false },
+ };
+ expect(getCommandArgumentSuggestions(curatedOnly, 'other')).toEqual([]);
+ });
+
+ it('represents required and optional text through the same argument stage', () => {
+ const required: PhoneCommand = {
+ name: 'rename',
+ label: '/rename',
+ hint: 'Rename',
+ input: { kind: 'text', required: true, placeholder: 'Session name' },
+ };
+ expect(getCommandArgumentSuggestions(required, '')[0]).toMatchObject({
+ label: 'Session name',
+ disabled: true,
+ });
+ expect(getCommandArgumentSuggestions(required, 'My Session')[0]).toMatchObject({
+ label: 'Use “My Session”',
+ input: 'My Session',
+ disabled: false,
+ });
+ });
+});
diff --git a/mobile/src/ui/composer/commandPalette.ts b/mobile/src/ui/composer/commandPalette.ts
new file mode 100644
index 0000000..156477b
--- /dev/null
+++ b/mobile/src/ui/composer/commandPalette.ts
@@ -0,0 +1,67 @@
+import type { PhoneCommand, PhoneCommandOption } from '@aasis21/weft-shared';
+
+export interface CommandArgumentStage {
+ command: PhoneCommand;
+ query: string;
+}
+
+export interface CommandArgumentSuggestion {
+ key: string;
+ label: string;
+ hint: string;
+ input: string;
+ disabled?: boolean;
+}
+
+export function getCommandArgumentStage(value: string, command: PhoneCommand | null): CommandArgumentStage | null {
+ if (!command || command.input.kind === 'none') return null;
+ const match = value.match(/^\/[a-z][a-z-]*\s([\s\S]*)$/i);
+ if (!match) return null;
+ return { command, query: match[1] ?? '' };
+}
+
+export function filterCommandOptions(
+ options: ReadonlyArray
,
+ query: string,
+): ReadonlyArray {
+ const normalized = query.trim().toLocaleLowerCase();
+ if (!normalized) return options;
+ return options.filter((option) =>
+ [option.label, option.hint ?? '', ...(option.aliases ?? [])].some((candidate) =>
+ candidate.toLocaleLowerCase().includes(normalized),
+ ),
+ );
+}
+
+export function getCommandArgumentSuggestions(
+ command: PhoneCommand,
+ query: string,
+): CommandArgumentSuggestion[] {
+ if (command.input.kind === 'none') return [];
+ const trimmed = query.trim();
+ if (command.input.kind === 'text') {
+ return [{
+ key: 'text',
+ label: trimmed ? `Use “${trimmed}”` : command.input.placeholder,
+ hint: command.input.required ? 'Required text' : 'Optional text',
+ input: trimmed,
+ disabled: command.input.required && !trimmed,
+ }];
+ }
+
+ const suggestions = filterCommandOptions(command.input.options, query).map((option) => ({
+ key: option.value,
+ label: option.label,
+ hint: option.hint ?? `Use ${option.label}`,
+ input: option.value,
+ }));
+ if (command.input.allowCustom && trimmed) {
+ suggestions.push({
+ key: `custom:${trimmed}`,
+ label: `Use “${trimmed}”`,
+ hint: 'Custom value',
+ input: trimmed,
+ });
+ }
+ return suggestions;
+}
diff --git a/mobile/src/ui/explore/ExploreScreen.tsx b/mobile/src/ui/explore/ExploreScreen.tsx
index 2592a54..23da1cb 100644
--- a/mobile/src/ui/explore/ExploreScreen.tsx
+++ b/mobile/src/ui/explore/ExploreScreen.tsx
@@ -12,8 +12,9 @@ import type {
PointerEvent as ReactPointerEvent,
} from 'react';
import type { SessionView } from '@/session/view';
-import type { AssistantItem, TimelineItem, ToolItem } from '@/lib/timeline';
+import type { TimelineItem, ToolItem } from '@/lib/timeline';
import { isWorking } from '@/ui/sessions/sessionStatus';
+import { compactToolDetail, toolDisplayName } from '@/ui/tools/toolPresentation';
import {
DISCOVER_CARDS,
DISCOVER_TOPIC_LABELS,
@@ -26,6 +27,7 @@ type ExploreView = ExploreCategory | null;
type ExploreHistoryState = {
weftView: 'explore';
exploreView?: ExploreCategory;
+ entry?: 'direct-discover';
};
function historyExploreView(state: ExploreHistoryState | null): ExploreView {
@@ -44,6 +46,8 @@ interface ExploreScreenProps {
onOpenChat(): void;
onGoHome(): void;
desktopDocked?: boolean;
+ initialView?: ExploreCategory;
+ directFromChat?: boolean;
}
export type LiveDockTone = 'attention' | 'error' | 'working' | 'ready' | 'idle';
@@ -61,6 +65,7 @@ export interface LiveDockActivity {
text: string;
kind: 'assistant' | 'tool' | 'context';
state: 'live' | 'complete' | 'error' | 'context';
+ count?: number;
}
const CATEGORY_META: Record = {
@@ -171,11 +176,9 @@ function latestRunningTool(active: SessionView): ToolItem | null {
}
function toolLabel(tool: ToolItem): string {
- const record =
- tool.args && typeof tool.args === 'object' ? (tool.args as Record) : undefined;
- const description = typeof record?.description === 'string' ? record.description.trim() : '';
- if (description) return description;
- return `${tool.name.replace(/[_-]+/g, ' ')} in progress`;
+ const name = toolDisplayName(tool.name, tool.args);
+ const detail = compactToolDetail(tool.name, tool.args);
+ return detail ? `${name}: ${detail}` : name;
}
function latestAssistant(active: SessionView, onlyStreaming = false) {
@@ -195,10 +198,6 @@ function dockExcerpt(value: string, max = 220): string {
return `…${clean.slice(clean.length - max + 1)}`;
}
-function plainToolName(name: string): string {
- return name.replace(/[_-]+/g, ' ').trim();
-}
-
export function deriveLiveDockActivity(
active: SessionView,
dock: LiveDockState,
@@ -222,48 +221,21 @@ export function deriveLiveDockActivity(
];
}
- const activity = active.timeline.items
- .filter((item): item is AssistantItem | ToolItem =>
- (item.kind === 'assistant' && Boolean(item.text.trim())) || item.kind === 'tool',
- )
- .slice(-6)
- .map((item): LiveDockActivity => {
- if (item.kind === 'assistant') {
- return {
- id: `assistant-${item.id}`,
- text: dockExcerpt(item.text, 150),
- kind: 'assistant',
- state: item.final ? 'complete' : 'live',
- };
- }
- const activityLabel = toolLabel(item).replace(/ in progress$/, '');
- return {
- id: `tool-${item.id}`,
- text:
- item.status === 'running'
- ? toolLabel(item)
- : item.status === 'success'
- ? `${activityLabel} completed`
- : `${activityLabel} failed`,
- kind: 'tool',
- state:
- item.status === 'running'
- ? 'live'
- : item.status === 'success'
- ? 'complete'
- : 'error',
- };
- });
-
- const intent = active.intent?.trim();
- if (intent && activity.length < 3 && !activity.some((item) => item.text === intent)) {
- activity.unshift({
- id: 'current-intent',
- text: dockExcerpt(intent, 150),
- kind: 'context',
- state: 'context',
- });
+ const tools: LiveDockActivity[] = [];
+ for (const item of active.timeline.items.filter((entry): entry is ToolItem => entry.kind === 'tool').slice(-8)) {
+ const text = toolLabel(item);
+ const state = item.status === 'running' ? 'live' : item.status === 'success' ? 'complete' : 'error';
+ const previous = tools.at(-1);
+ if (previous?.text === text) {
+ previous.id = `tool-${item.id}`;
+ previous.state = state;
+ previous.count = (previous.count ?? 1) + 1;
+ } else {
+ tools.push({ id: `tool-${item.id}`, text, kind: 'tool', state, count: 1 });
+ }
}
+
+ const activity = tools.slice(-2);
if (activity.length === 0) {
activity.push({
id: 'working-context',
@@ -272,7 +244,17 @@ export function deriveLiveDockActivity(
state: 'live',
});
}
- return activity.slice(-3);
+
+ const streaming = latestAssistant(active, true);
+ if (streaming?.kind === 'assistant') {
+ activity.push({
+ id: `assistant-${streaming.id}`,
+ text: dockExcerpt(streaming.text, 220),
+ kind: 'assistant',
+ state: 'live',
+ });
+ }
+ return activity;
}
export function deriveLiveDock(active: SessionView, replyCompletedInExplore = false): LiveDockState {
@@ -323,7 +305,7 @@ export function deriveLiveDock(active: SessionView, replyCompletedInExplore = fa
text: streamed
? dockExcerpt(streamed)
: active.intent?.trim() || (running ? toolLabel(running) : 'Working in the active session'),
- detail: running ? `Using ${plainToolName(running.name)}` : active.intent?.trim() || null,
+ detail: running ? `Using ${toolDisplayName(running.name, running.args)}` : active.intent?.trim() || null,
startedAt: active.thinkingSince ?? running?.startedAt ?? active.timeline.busyFrom,
};
}
@@ -352,11 +334,12 @@ function recentActivityLabel(items: TimelineItem[]): string {
if (!latest) return 'No recent agent activity';
if (latest.kind === 'assistant') return 'Last response is available in chat';
if (latest.kind === 'tool') {
+ const name = toolDisplayName(latest.name, latest.args);
return latest.status === 'success'
- ? `${latest.name} completed`
+ ? `${name} completed`
: latest.status === 'error'
- ? `${latest.name} reported an error`
- : `${latest.name} is running`;
+ ? `${name} reported an error`
+ : `${name} is running`;
}
return latest.text;
}
@@ -364,6 +347,7 @@ function recentActivityLabel(items: TimelineItem[]): string {
function formatElapsed(startedAt: number | null, now: number): string | null {
if (startedAt == null) return null;
const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
+ if (seconds < 5) return null;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return minutes > 0 ? `${minutes}:${remainder.toString().padStart(2, '0')}` : `${seconds}s`;
@@ -408,9 +392,13 @@ export function ExploreScreen({
onOpenChat,
onGoHome,
desktopDocked = false,
+ initialView,
+ directFromChat = false,
}: ExploreScreenProps): JSX.Element {
const [stored, setStored] = useState>(() => parseStoredState());
- const [view, setView] = useState(null);
+ const [view, setView] = useState(
+ () => initialView ?? historyExploreView(window.history.state as ExploreHistoryState | null),
+ );
const [now, setNow] = useState(Date.now());
const latestFinal = latestAssistant(active);
const latestFinalId =
@@ -475,7 +463,11 @@ export function ExploreScreen({
}, []);
const navigate = (next: ExploreCategory): void => {
- const state = { weftView: 'explore', exploreView: next } satisfies ExploreHistoryState;
+ const state = {
+ weftView: 'explore',
+ exploreView: next,
+ ...(directFromChat ? { entry: 'direct-discover' as const } : {}),
+ } satisfies ExploreHistoryState;
if (view === null) window.history.pushState(state, '');
else window.history.replaceState(state, '');
setView(next);
@@ -490,7 +482,12 @@ export function ExploreScreen({
- {desktopDocked ? (
+ {directFromChat ? (
+
+ ‹
+ Chat
+
+ ) : desktopDocked ? (
⎈
@@ -602,6 +599,11 @@ function LiveCopilotDock({
}): JSX.Element {
const elapsed = formatElapsed(dock.startedAt, now);
const activityText = activity.map((item) => item.text).join('. ');
+ const streaming = activity.find((item) => item.kind === 'assistant' && item.state === 'live') ?? null;
+ const steps = activity.filter((item) => item !== streaming);
+ const current = steps.at(-1) ?? null;
+ const previous = steps.length > 1 ? steps.at(-2) ?? null : null;
+ const needsAttention = dock.tone === 'attention' || dock.tone === 'error';
return (
+
+ ✦
+
@@ -617,20 +622,32 @@ function LiveCopilotDock({
{elapsed ? {elapsed} : null}
-
- {activity.map((item) => (
-
- {item.kind === 'tool' ? '›' : item.state === 'complete' ? '✓' : '•'}
- {item.text}
- {item.state === 'live' ? : null}
-
- ))}
+ {needsAttention ? (
+
+ {dock.text}
+ {dock.detail ? {dock.detail} : null}
+
+ ) : (
+
+ {current ? (
+
+ {current.state === 'complete' ? '✓' : current.state === 'error' ? '!' : '›'}
+ {current.text}{(current.count ?? 1) > 1 ? ` ×${current.count}` : ''}
+
+ ) : null}
+ {streaming ? {streaming.text} : null}
+ {previous ? (
+
+ {previous.text}{(previous.count ?? 1) > 1 ? ` ×${previous.count}` : ''}
+
+ ) : null}
+
+ )}
+
+ {needsAttention ? (dock.tone === 'attention' ? 'Review in chat' : 'Open chat') : 'Open chat'}
+ ›
- ›
);
}
diff --git a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx
index 0231d28..c4786bc 100644
--- a/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx
+++ b/mobile/src/ui/explore/__tests__/ExploreScreen.test.tsx
@@ -177,12 +177,14 @@ describe('Live Copilot Dock', () => {
const activity = deriveLiveDockActivity(active, dock);
expect(activity.map((item) => item.text)).toEqual([
- 'Read ExploreScreen.tsx completed',
+ 'View: Read ExploreScreen.tsx',
+ 'Run Command: Run focused Explore tests',
'I am tightening the card layout.',
- 'Run focused Explore tests',
]);
renderExplore(active);
- expect(document.querySelectorAll('.live-copilot-line')).toHaveLength(3);
+ expect(document.querySelectorAll('.live-copilot-current')).toHaveLength(1);
+ expect(document.querySelectorAll('.live-copilot-previous')).toHaveLength(1);
+ expect(document.querySelectorAll('.live-copilot-stream')).toHaveLength(1);
expect(document.querySelector('.live-copilot-feed')).toHaveTextContent(
'Run focused Explore tests',
);
@@ -303,7 +305,7 @@ describe('Live Copilot Dock', () => {
ts: 100,
}],
},
- }))).toMatchObject({ tone: 'working', text: 'Run mobile tests' });
+ }))).toMatchObject({ tone: 'working', text: 'Run Command: Run mobile tests' });
expect(deriveLiveDock(session({
unread: true,
@@ -321,9 +323,97 @@ describe('Live Copilot Dock', () => {
expect(deriveLiveDock(session())).toMatchObject({ tone: 'idle', label: 'Copilot ready' });
});
+
+ it('collapses adjacent duplicate tools and never exposes raw compact arguments', () => {
+ const active = session({
+ timeline: {
+ ...emptyTimeline(),
+ busy: true,
+ items: [
+ {
+ kind: 'tool',
+ id: 'search-1',
+ name: 'rg',
+ args: { pattern: 'private first query', paths: 'C:\\private\\repo' },
+ status: 'success',
+ startedAt: 10,
+ finishedAt: 11,
+ ts: 10,
+ },
+ {
+ kind: 'tool',
+ id: 'search-2',
+ name: 'rg',
+ args: { pattern: 'private second query', paths: 'C:\\private\\repo' },
+ status: 'running',
+ startedAt: 12,
+ ts: 12,
+ },
+ ],
+ },
+ });
+
+ expect(deriveLiveDockActivity(active, deriveLiveDock(active))).toEqual([
+ expect.objectContaining({ text: 'Search', count: 2, state: 'live' }),
+ ]);
+ renderExplore(active);
+ expect(screen.getByRole('button', { name: /Search/ })).toHaveTextContent('Search ×2');
+ expect(document.body).not.toHaveTextContent('private first query');
+ expect(document.body).not.toHaveTextContent('C:\\private\\repo');
+ });
+
+ it('hides short elapsed times and presents attention as one chat action', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-09-26T00:00:03Z'));
+ const short = session({
+ timeline: { ...emptyTimeline(), busy: true, busyFrom: Date.parse('2026-09-26T00:00:00Z') },
+ });
+ const { unmount } = renderExplore(short);
+ expect(document.querySelector('.live-copilot-status time')).not.toBeInTheDocument();
+ unmount();
+
+ vi.setSystemTime(new Date('2026-09-26T00:00:08Z'));
+ const elapsed = renderExplore(short);
+ expect(document.querySelector('.live-copilot-status time')).toHaveTextContent('8s');
+ elapsed.unmount();
+
+ const attention = session({
+ timeline: {
+ ...emptyTimeline(),
+ approvals: [{
+ requestId: 'approval-1',
+ toolName: 'powershell',
+ toolArgs: {},
+ options: [{ id: 'approve', label: 'Approve' }],
+ }],
+ },
+ });
+ renderExplore(attention);
+ expect(screen.getByRole('button', { name: /Approval needed/ })).toHaveTextContent('Review in chat');
+ expect(document.querySelector('.live-copilot-feed')).not.toBeInTheDocument();
+ });
});
describe('Explore navigation and Discover deck', () => {
+ it('opens direct Discover with one Back to chat affordance', async () => {
+ const onOpenChat = vi.fn();
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('region', { name: 'Discover card deck' })).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Back to chat' }));
+ expect(onOpenChat).toHaveBeenCalledOnce();
+ });
+
it('uses the shared navigation affordance and compact four-choice home', async () => {
const onOpenSessions = vi.fn();
const user = userEvent.setup();
diff --git a/mobile/src/ui/screens/SessionScreen.tsx b/mobile/src/ui/screens/SessionScreen.tsx
index 0d7ca2e..6c3ee7e 100644
--- a/mobile/src/ui/screens/SessionScreen.tsx
+++ b/mobile/src/ui/screens/SessionScreen.tsx
@@ -1,5 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
-import type { JSX } from 'react';
+import type { CSSProperties, JSX, TouchEvent as ReactTouchEvent } from 'react';
import type { PromptAttachment, PromptDelivery, SessionMode } from '@aasis21/weft-shared';
import type { SessionView } from '@/session/view';
import type { ListenerDeviceState } from '@/session/model';
@@ -116,6 +116,17 @@ function readRecord(value: unknown): Record {
return value && typeof value === 'object' ? (value as Record) : {};
}
+function isTextEntryElement(value: Element | null): boolean {
+ if (!(value instanceof HTMLElement)) return false;
+ if (value.isContentEditable) return true;
+ const tag = value.tagName.toLowerCase();
+ if (tag === 'textarea') return true;
+ if (tag !== 'input') return false;
+ return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes(
+ (value as HTMLInputElement).type,
+ );
+}
+
/** Persisted across sessions: whether the user collapsed the desktop docked sidebar (#183). */
const SIDEBAR_COLLAPSED_KEY = 'weft.desktop-sidebar-collapsed';
@@ -209,7 +220,10 @@ interface SessionScreenProps {
onSelectSession(channelId: string): void;
onAddSession(): void;
onOpenExplore?(): void;
+ onOpenDiscover?(): void;
exploreOpen?: boolean;
+ exploreInitialView?: 'discover';
+ exploreDirectFromChat?: boolean;
onCloseExplore?(): void;
onStartSession?(): void;
onOpenDevices?(): void;
@@ -246,7 +260,10 @@ export function SessionScreen({
onSelectSession,
onAddSession,
onOpenExplore,
+ onOpenDiscover,
exploreOpen = false,
+ exploreInitialView,
+ exploreDirectFromChat = false,
onCloseExplore,
onStartSession,
onOpenDevices,
@@ -270,6 +287,11 @@ export function SessionScreen({
const [voiceOpen, setVoiceOpen] = useState(false);
const [voxOpen, setVoxOpen] = useState(false);
const [confirmRemoveId, setConfirmRemoveId] = useState(null);
+ const [keyboardOpen, setKeyboardOpen] = useState(false);
+ const [discoverPreview, setDiscoverPreview] = useState<{
+ progress: number;
+ settling: boolean;
+ } | null>(null);
const [now, setNow] = useState(() => Date.now());
const [approvalMountTimes, setApprovalMountTimes] = useState>({});
// Desktop (wide viewport): dock the session list as a persistent, collapsible sidebar
@@ -286,6 +308,14 @@ export function SessionScreen({
};
const confirmDialogRef = useRef(null);
const rootRef = useRef(null);
+ const discoverGestureRef = useRef<{
+ startX: number;
+ startY: number;
+ startedAt: number;
+ locked: boolean;
+ cancelled: boolean;
+ } | null>(null);
+ const discoverPreviewTimerRef = useRef(null);
const composerDockRef = useRef(null);
const approvalStackRef = useRef(null);
const prevApprovalCount = useRef(0);
@@ -540,24 +570,14 @@ export function SessionScreen({
// Only lift the fixed shell while a text-entry control is focused, so browser
// chrome animation never moves the composer.
const MIN_KEYBOARD_INSET = 160;
- const isTextEntryFocused = (): boolean => {
- const active = document.activeElement;
- if (!(active instanceof HTMLElement)) return false;
- if (active.isContentEditable) return true;
- const tag = active.tagName.toLowerCase();
- if (tag === 'textarea') return true;
- if (tag !== 'input') return false;
- return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes(
- (active as HTMLInputElement).type,
- );
- };
const apply = (): void => {
const el = rootRef.current;
if (!el) return;
const raw = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
const keyboardThreshold = Math.max(MIN_KEYBOARD_INSET, Math.round(window.innerHeight * 0.18));
- const inset = isTextEntryFocused() && raw >= keyboardThreshold ? raw : 0;
+ const inset = isTextEntryElement(document.activeElement) && raw >= keyboardThreshold ? raw : 0;
el.style.setProperty('--weft-kb', `${inset}px`);
+ setKeyboardOpen(inset > 0);
};
apply();
vv.addEventListener('resize', apply);
@@ -572,6 +592,122 @@ export function SessionScreen({
};
}, []);
+ useEffect(() => () => {
+ if (discoverPreviewTimerRef.current !== null) {
+ window.clearTimeout(discoverPreviewTimerRef.current);
+ }
+ }, []);
+
+ useEffect(() => {
+ discoverGestureRef.current = null;
+ setDiscoverPreview(null);
+ }, [activeId, exploreOpen]);
+
+ const reducedMotion = (): boolean =>
+ globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
+
+ const conflictingOverlayOpen =
+ drawerOpen ||
+ debugOpen ||
+ settingsOpen ||
+ voiceOpen ||
+ voxOpen ||
+ confirmRemoveId !== null ||
+ timeline.approvals.length > 0 ||
+ timeline.elicitations.length > 0;
+
+ const cancelDiscoverPreview = (): void => {
+ discoverGestureRef.current = null;
+ if (!discoverPreview || reducedMotion()) {
+ setDiscoverPreview(null);
+ return;
+ }
+ setDiscoverPreview({ progress: 0, settling: true });
+ if (discoverPreviewTimerRef.current !== null) {
+ window.clearTimeout(discoverPreviewTimerRef.current);
+ }
+ discoverPreviewTimerRef.current = window.setTimeout(() => {
+ setDiscoverPreview(null);
+ discoverPreviewTimerRef.current = null;
+ }, 180);
+ };
+
+ const onDiscoverTouchStart = (event: ReactTouchEvent): void => {
+ if (
+ !onOpenDiscover ||
+ exploreOpen ||
+ isDesktopWide ||
+ isDesktopInput() ||
+ keyboardOpen ||
+ isTextEntryElement(document.activeElement) ||
+ conflictingOverlayOpen ||
+ event.touches.length !== 1 ||
+ rootRef.current?.querySelector('.slash-menu, [role="dialog"], .drawer:not(.docked)')
+ ) {
+ discoverGestureRef.current = null;
+ return;
+ }
+ const touch = event.touches[0];
+ const width = Math.max(window.innerWidth, document.documentElement.clientWidth);
+ if (touch.clientX < width - 28) return;
+ discoverGestureRef.current = {
+ startX: touch.clientX,
+ startY: touch.clientY,
+ startedAt: performance.now(),
+ locked: false,
+ cancelled: false,
+ };
+ };
+
+ const onDiscoverTouchMove = (event: ReactTouchEvent): void => {
+ const gesture = discoverGestureRef.current;
+ const touch = event.touches[0];
+ if (!gesture || !touch || gesture.cancelled) return;
+ const deltaX = touch.clientX - gesture.startX;
+ const deltaY = touch.clientY - gesture.startY;
+ const horizontalDistance = Math.abs(deltaX);
+ const verticalDistance = Math.abs(deltaY);
+
+ if (!gesture.locked) {
+ if (horizontalDistance < 10 && verticalDistance < 10) return;
+ if (deltaX >= 0 || horizontalDistance <= verticalDistance * 1.2) {
+ gesture.cancelled = true;
+ cancelDiscoverPreview();
+ return;
+ }
+ gesture.locked = true;
+ }
+
+ event.preventDefault();
+ const width = Math.max(window.innerWidth, 1);
+ const progress = Math.min(1, Math.max(0, -deltaX / width));
+ if (!reducedMotion()) setDiscoverPreview({ progress, settling: false });
+ };
+
+ const onDiscoverTouchEnd = (event: ReactTouchEvent): void => {
+ const gesture = discoverGestureRef.current;
+ discoverGestureRef.current = null;
+ if (!gesture || !gesture.locked || gesture.cancelled) {
+ cancelDiscoverPreview();
+ return;
+ }
+ const touch = event.changedTouches[0];
+ if (!touch) {
+ cancelDiscoverPreview();
+ return;
+ }
+ const distance = gesture.startX - touch.clientX;
+ const elapsedMs = Math.max(1, performance.now() - gesture.startedAt);
+ const velocity = distance / elapsedMs;
+ const threshold = Math.max(72, window.innerWidth * 0.22);
+ if (distance >= threshold || velocity >= 0.55) {
+ setDiscoverPreview(null);
+ onOpenDiscover?.();
+ return;
+ }
+ cancelDiscoverPreview();
+ };
+
// The "jump to latest" pill floats above the composer. Track the composer dock's
// live height in --composer-h so the pill always clears it (even when the composer
// grows with multi-line drafts or queued messages), instead of overlapping it.
@@ -589,7 +725,14 @@ export function SessionScreen({
}, []);
return (
-
+
+ {discoverPreview ? (
+
+ ✦
+ Discover
+
+ ) : null}
+
{exploreOpen && onCloseExplore ? (
Opening Explore…}>
) : null}
diff --git a/mobile/src/ui/screens/StartSessionScreen.tsx b/mobile/src/ui/screens/StartSessionScreen.tsx
index cd796a7..b47ef0e 100644
--- a/mobile/src/ui/screens/StartSessionScreen.tsx
+++ b/mobile/src/ui/screens/StartSessionScreen.tsx
@@ -75,6 +75,8 @@ export function StartSessionScreen({
const modeTouchedRef = useRef(false);
const modeDeviceRef = useRef
(null);
const [name, setName] = useState('');
+ const [nameEditing, setNameEditing] = useState(false);
+ const nameInputRef = useRef(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [blocked, setBlocked] = useState(null);
@@ -170,6 +172,19 @@ export function StartSessionScreen({
setSessionFolder(defaultFolder);
}, [defaultFolder]);
+ useEffect(() => {
+ if (!nameEditing) return undefined;
+ const keepNameVisible = (): void => {
+ nameInputRef.current?.scrollIntoView({ block: 'center' });
+ };
+ const frame = window.requestAnimationFrame(keepNameVisible);
+ window.visualViewport?.addEventListener('resize', keepNameVisible);
+ return () => {
+ window.cancelAnimationFrame(frame);
+ window.visualViewport?.removeEventListener('resize', keepNameVisible);
+ };
+ }, [nameEditing]);
+
// Opening the Resume tab is itself the request to see what is resumable, so pull the list rather
// than parking behind a Load button — the empty and loading states already cover the wait. Only
// while the device is connected: asking an offline laptop just produces a timeout. Re-pulls on
@@ -306,7 +321,7 @@ export function StartSessionScreen({
: `Start on ${selected ? deviceLabel(selected) : 'device'}`;
return (
-
+
diff --git a/mobile/src/ui/thread/__tests__/ChatThread.test.tsx b/mobile/src/ui/thread/__tests__/ChatThread.test.tsx
index a86a5b6..9759c88 100644
--- a/mobile/src/ui/thread/__tests__/ChatThread.test.tsx
+++ b/mobile/src/ui/thread/__tests__/ChatThread.test.tsx
@@ -90,14 +90,14 @@ describe('ChatThread', () => {
/>,
);
- const toolButton = screen.getByRole('button', { name: /Runnpm test42ms/i });
+ const toolButton = screen.getByRole('button', { name: /Run Commandnpm test42ms/i });
expect(toolButton.closest('.tool-card')).toHaveClass('success');
expect(toolButton).toHaveAttribute('aria-expanded', 'false');
await user.click(toolButton);
expect(toolButton).toHaveAttribute('aria-expanded', 'true');
- expect(screen.getByText('ARGUMENTS')).toBeInTheDocument();
- expect(screen.getByText('RESULT')).toBeInTheDocument();
+ expect(screen.getByText('INPUT')).toBeInTheDocument();
+ expect(screen.getByText('OUTPUT')).toBeInTheDocument();
expect(screen.getByText(/"command": "npm test"/)).toBeInTheDocument();
expect(screen.getByText('passed')).toBeInTheDocument();
});
@@ -510,6 +510,31 @@ describe('letting the reader scroll away while the agent is still writing', () =
expect(screen.getByRole('button', { name: 'Scroll to latest' })).toBeInTheDocument();
});
+ it('honors touch intent when a heartbeat lands before momentum scroll updates position', async () => {
+ const { scroller, scrollIntoView, rerender } = mountStreaming();
+ await settleThread();
+ Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 1000 });
+ Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 600 });
+ Object.defineProperty(scroller, 'scrollTop', { configurable: true, value: 400 });
+ fireEvent.scroll(scroller);
+
+ fireEvent.touchStart(scroller, { touches: [{ clientY: 200 }] });
+ fireEvent.touchMove(scroller, { touches: [{ clientY: 230 }] });
+ fireEvent.touchEnd(scroller);
+ scrollIntoView.mockClear();
+
+ // A real heartbeat toggles activity/status and can resize the available thread area before
+ // Android reports the momentum scroll position. Neither rerender nor resize may reclaim it.
+ rerender([{ kind: 'assistant', id: 'a1', text: 'one two', ts: now }]);
+ fireEvent(window, new Event('resize'));
+
+ expect(scrollIntoView).not.toHaveBeenCalled();
+
+ scrollUp(scroller);
+ fireEvent.scroll(scroller);
+ expect(screen.getByRole('button', { name: 'Scroll to latest' })).toBeInTheDocument();
+ });
+
it('does not treat prepended history as a newly sent phone prompt', async () => {
const scrollIntoView = vi.fn();
Element.prototype.scrollIntoView = scrollIntoView as never;
diff --git a/mobile/src/ui/thread/__tests__/Markdown.test.tsx b/mobile/src/ui/thread/__tests__/Markdown.test.tsx
new file mode 100644
index 0000000..e7c8c2a
--- /dev/null
+++ b/mobile/src/ui/thread/__tests__/Markdown.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Markdown } from '@/ui/thread/Markdown';
+
+describe('Markdown', () => {
+ it('preserves ordered-list numbering when paragraphs split items into separate lists', () => {
+ render(
+
,
+ );
+
+ const lists = screen.getAllByRole('list');
+ expect(lists).toHaveLength(3);
+ expect(lists[0]).toHaveAttribute('start', '1');
+ expect(lists[1]).toHaveAttribute('start', '2');
+ expect(lists[2]).toHaveAttribute('start', '3');
+ });
+
+ it('preserves the starting marker for a contiguous ordered list', () => {
+ render(
);
+
+ expect(screen.getByRole('list')).toHaveAttribute('start', '4');
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ });
+});
diff --git a/mobile/src/ui/thread/__tests__/ToolCard.test.tsx b/mobile/src/ui/thread/__tests__/ToolCard.test.tsx
index 6937266..a0692bf 100644
--- a/mobile/src/ui/thread/__tests__/ToolCard.test.tsx
+++ b/mobile/src/ui/thread/__tests__/ToolCard.test.tsx
@@ -4,6 +4,46 @@ import { describe, expect, it } from 'vitest';
import { ToolCard } from '@/ui/thread/ToolCard';
describe('ToolCard', () => {
+ it('renders shell input, output, and completion status as separate surfaces', async () => {
+ const user = userEvent.setup();
+
+ render(
+
',
+ startedAt: 1,
+ finishedAt: 2001,
+ ts: 1,
+ }}
+ />,
+ );
+
+ await user.click(screen.getByRole('button', { name: /Run CommandCheck working tree2\.0s/i }));
+
+ expect(screen.getByText('INPUT')).toBeInTheDocument();
+ expect(screen.getByText('OUTPUT')).toBeInTheDocument();
+ expect(screen.getByText('git -C "C:\\repos\\weft" status --short')).toHaveClass('tc-command');
+ expect(screen.getByText('M mobile/src/App.tsx')).toHaveClass('tc-output');
+ expect(screen.getByText('Exit 0')).toBeInTheDocument();
+ expect(screen.getByText('shell 7')).toBeInTheDocument();
+ expect(screen.getByText('sync')).toBeInTheDocument();
+ expect(screen.getByText('wait up to 120s')).toBeInTheDocument();
+ expect(screen.getByText(/"command":/)).not.toBeVisible();
+
+ await user.click(screen.getByText('View raw arguments'));
+ expect(screen.getByText(/"command":/)).toBeVisible();
+ });
+
it('renders edit tool arguments as a colored unified diff', async () => {
const user = userEvent.setup();
diff --git a/mobile/src/ui/tools/__tests__/toolPresentation.test.ts b/mobile/src/ui/tools/__tests__/toolPresentation.test.ts
new file mode 100644
index 0000000..af85e69
--- /dev/null
+++ b/mobile/src/ui/tools/__tests__/toolPresentation.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { compactToolDetail, toolDisplayName } from '@/ui/tools/toolPresentation';
+
+describe('tool presentation', () => {
+ it('uses the shared explainable labels for internal tool names', () => {
+ expect([
+ 'rg',
+ 'glob',
+ 'view',
+ 'apply_patch',
+ 'powershell',
+ 'skill',
+ 'task',
+ 'read_agent',
+ 'write_agent',
+ 'web_fetch',
+ 'web_search',
+ 'ask_user',
+ ].map((name) => toolDisplayName(name))).toEqual([
+ 'Search',
+ 'Find Files',
+ 'View',
+ 'Edit Files',
+ 'Run Command',
+ 'Activate Skill',
+ 'Start Agent',
+ 'Read Agent',
+ 'Message Agent',
+ 'Fetch Web Page',
+ 'Search Web',
+ 'Ask User',
+ ]);
+ });
+
+ it('title-cases understandable names and keeps compact details private', () => {
+ expect(toolDisplayName('create_report')).toBe('Create Report');
+ expect(compactToolDetail('view', { path: 'C:\\private\\repo\\App.tsx' })).toBe('App.tsx');
+ expect(compactToolDetail('rg', { pattern: 'private search text' })).toBeNull();
+ expect(compactToolDetail('powershell', { command: 'secret command' })).toBeNull();
+ expect(compactToolDetail('view', { description: 'Read C:\\private\\repo\\App.tsx' })).toBe('Read App.tsx');
+ });
+});
diff --git a/mobile/src/ui/tools/toolPresentation.ts b/mobile/src/ui/tools/toolPresentation.ts
new file mode 100644
index 0000000..932b8b4
--- /dev/null
+++ b/mobile/src/ui/tools/toolPresentation.ts
@@ -0,0 +1,76 @@
+const TOOL_LABELS: Record = {
+ rg: 'Search',
+ glob: 'Find Files',
+ view: 'View',
+ apply_patch: 'Edit Files',
+ powershell: 'Run Command',
+ skill: 'Activate Skill',
+ task: 'Start Agent',
+ read_agent: 'Read Agent',
+ write_agent: 'Message Agent',
+ web_fetch: 'Fetch Web Page',
+ web_search: 'Search Web',
+ ask_user: 'Ask User',
+};
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function titleCase(name: string): string {
+ return name
+ .replace(/[_-]+/g, ' ')
+ .replace(/\b\w/g, (character) => character.toUpperCase())
+ .trim();
+}
+
+function basename(path: string): string {
+ const normalized = path.replace(/\\/g, '/');
+ return normalized.slice(normalized.lastIndexOf('/') + 1);
+}
+
+function hideFullPaths(value: string): string {
+ return value
+ .replace(/[A-Za-z]:\\(?:[^\\\s]+\\)*([^\\\s]+)/g, '$1')
+ .replace(/(^|\s)\/(?:[^/\s]+\/)+([^/\s]+)/g, '$1$2');
+}
+
+function inferredToolLabel(args: unknown): string | null {
+ const record = asRecord(args);
+ if (!record) return null;
+ if (typeof record.command === 'string') return 'Run Command';
+ if (typeof record.old_string === 'string' || typeof record.new_string === 'string') return 'Edit Files';
+ if (typeof record.pattern === 'string' || typeof record.query === 'string') return 'Search';
+ if (typeof record.url === 'string') return 'Fetch Web Page';
+ if (typeof record.path === 'string' || typeof record.file === 'string') return 'View';
+ return null;
+}
+
+export function toolDisplayName(name: string, args?: unknown): string {
+ const normalized = name.trim().toLowerCase();
+ if (TOOL_LABELS[normalized]) return TOOL_LABELS[normalized];
+ if (!normalized || normalized === 'tool') return inferredToolLabel(args) ?? 'Tool';
+ return titleCase(name);
+}
+
+export function compactToolDetail(name: string, args: unknown): string | null {
+ const record = asRecord(args);
+ if (!record) return null;
+
+ const description = typeof record.description === 'string' ? record.description.trim() : '';
+ if (description) return hideFullPaths(description);
+
+ const normalized = name.trim().toLowerCase();
+ if (normalized === 'view' || normalized === 'read' || normalized === 'edit' || normalized === 'create') {
+ const path =
+ typeof record.path === 'string' ? record.path :
+ typeof record.file === 'string' ? record.file :
+ typeof record.file_path === 'string' ? record.file_path :
+ null;
+ return path ? basename(path) : null;
+ }
+
+ return null;
+}
diff --git a/openspec/changes/polish-session-command-experience/.openspec.yaml b/openspec/changes/polish-session-command-experience/.openspec.yaml
new file mode 100644
index 0000000..0ca5fbe
--- /dev/null
+++ b/openspec/changes/polish-session-command-experience/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-09-26
diff --git a/openspec/changes/polish-session-command-experience/design.md b/openspec/changes/polish-session-command-experience/design.md
new file mode 100644
index 0000000..e0d8b4a
--- /dev/null
+++ b/openspec/changes/polish-session-command-experience/design.md
@@ -0,0 +1,91 @@
+## Context
+
+Weft `0.2.27` already keeps the active chat mounted beneath Explore, projects real assistant/tool activity into a bottom dock, and exposes a shared whitelist of phone-invokable CLI commands. However, command arguments are represented only as `none | optional | required`, Chat and Explore humanize tool names independently, and the chat-follow state depends too heavily on transient layout measurements. Two local commits from the previous 48 hours also contain useful UX fixes that never received a PR and now include stale release metadata.
+
+The implementation spans `shared`, `extension`, and `mobile`, so compatibility and validation must remain centralized. Relays continue to carry encrypted envelopes only; this change does not expose additional private session content.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Recover only the still-missing behavior from the abandoned commits.
+- Make explicit reader intent authoritative over heartbeat and layout churn.
+- Provide one generic command-argument interface that supports future curated commands without Composer-specific branches.
+- Offer a small, validated mobile model list while keeping model IDs out of visible UI.
+- Make Chat and Explore tool names consistent and understandable.
+- Add a direct mobile gesture into Discover without breaking browser Back, vertical scrolling, or Discover card swipes.
+- Simplify the Explore live Copilot tile while preserving real ordered activity.
+
+**Non-Goals:**
+
+- Enumerating every model available to every Copilot account.
+- Claiming an authoritative current model after reconnect when the CLI cannot report one.
+- Changing relay encryption, pairing, or transport behavior.
+- Restoring obsolete package versions, changelog entries, or entire abandoned commits.
+- Replacing existing slash commands with a new remote execution mechanism.
+
+## Decisions
+
+### Recover abandoned work as focused edits
+
+The ordered-list, Start Session, resume-list, and shell ToolCard changes will be reapplied against current `main`. The stale commits will not be cherry-picked because they mix release bumps and unrelated code with behavior that has since evolved.
+
+### Deepen the shared command definition
+
+`PhoneCommand.arg` will become a discriminated input definition:
+
+- `none`
+- `text` with required/optional semantics and a placeholder
+- `options` with centrally declared values, labels, hints, aliases, and optional custom input
+
+Shared helpers will resolve commands and validate option values. The mobile composer will consume the definition to drive command search, argument filtering, keyboard/touch selection, hidden values, confirmation, and submission. The extension will use the same definition to reject values that the phone was not allowed to send.
+
+The curated `/model` definition will expose `Auto`, `GPT-5.6 Sol`, `Claude Sonnet 5`, and `Gemini 3.8 Flash`. Visible labels remain separate from internal CLI arguments. A successful command invocation can confirm the phone-requested choice for the current connection, but reconnects will not pretend that locally remembered state is authoritative.
+
+### Centralize tool presentation
+
+A small mobile presentation module will map internal names to explainable labels such as `Search`, `View`, `Edit Files`, `Activate Skill`, and `Read Agent`. It will prefer an existing human description for secondary detail, preserve useful basenames, and avoid exposing full paths or raw arguments in compact activity. ToolCard and Explore will share this interface.
+
+### Latch reader detachment from touch intent
+
+ChatThread will track the initial touch position and mark the reader detached as soon as a vertical gesture toward older messages crosses a small threshold. That latch survives heartbeat, busy-state, streaming, ResizeObserver, and momentum-scroll timing. It clears only when the reader genuinely returns near the bottom, taps Jump to latest, sends a phone prompt, or changes conversations.
+
+### Treat direct Discover entry as a distinct history shape
+
+App history will distinguish Explore-home navigation from a direct Chat-to-Discover entry. A right-edge gesture begins only on narrow/touch layouts, within a small right-edge activation zone, and only when conflicting overlays or the soft keyboard are absent. Horizontal dominance locks the gesture; a lightweight Discover preview follows the finger. Crossing the distance or velocity threshold opens Discover directly. Cancelling springs the preview back. One Back action returns to the same chat.
+
+### Make the live Copilot tile a presence card
+
+The tile will show:
+
+- a Copilot glyph and sentence-case state,
+- elapsed time only after it becomes meaningful,
+- one primary current activity,
+- up to two lines of streaming assistant text,
+- at most one muted previous activity,
+- consecutive duplicate tool events collapsed,
+- an integrated `Open chat` affordance.
+
+Attention and error states replace the activity feed with a single clear action. The card remains fully tappable and uses only real session events.
+
+## Risks / Trade-offs
+
+- **Curated model IDs may become unavailable** → Keep the list small, validate in the extension, surface the CLI failure, and preserve the prior session behavior.
+- **Direct Discover gestures can conflict with vertical scrolling** → Require an edge start and horizontal dominance before preventing native scrolling.
+- **Touch-intent latching can stop legitimate following** → Clear only on measured return to the bottom, explicit Jump to latest, phone send, or conversation change.
+- **Shared command definitions can become overly general** → Support only the three input shapes needed now; avoid dynamic remote option providers until a second real adapter exists.
+- **Activity deduplication can hide individual calls** → Collapse only adjacent identical compact labels; full Chat tool cards remain available.
+
+## Migration Plan
+
+1. Add backward-compatible shared command metadata and update both mobile and extension in the same release.
+2. Reapply current-source versions of the abandoned UX changes.
+3. Add focused tests for every recovered or newly introduced invariant.
+4. Validate the complete monorepo and protocol artifacts.
+5. Merge the feature PR, run the normal patch release, and verify production plus immutable manifests.
+
+Rollback is a normal revert followed by a patch release. No persistent data migration is required.
+
+## Open Questions
+
+None. The curated model catalogue is intentionally controlled by Weft and can be revised in future releases.
diff --git a/openspec/changes/polish-session-command-experience/proposal.md b/openspec/changes/polish-session-command-experience/proposal.md
new file mode 100644
index 0000000..fc35dcd
--- /dev/null
+++ b/openspec/changes/polish-session-command-experience/proposal.md
@@ -0,0 +1,30 @@
+## Why
+
+Several recently completed mobile UX fixes never reached `main`, while the shipped chat and Explore surfaces still expose avoidable friction: heartbeat updates can pull readers to the latest message, internal tool names leak into user-facing activity, command arguments require ad hoc handling, and Discover lacks a direct gesture from chat. These issues should land together because they share the same goal: make Weft feel like a coherent mobile control surface for a live Copilot session.
+
+## What Changes
+
+- Recover the abandoned ordered-list numbering, Start Session name-field, compact resume-list, and shell ToolCard improvements without restoring stale release metadata.
+- Preserve a reader's explicit scroll-away intent across heartbeat, streaming, and layout updates until they deliberately return to the latest message.
+- Add a right-edge mobile gesture that reveals Discover from chat and returns to the same chat with one Back action.
+- Centralize lightly humanized tool labels so Chat and Explore show consistent, explainable names without exposing raw internal identifiers.
+- Redesign the Explore live Copilot tile around one current activity, streaming assistant text, deduplicated recent activity, meaningful elapsed time, and an integrated Open Chat action.
+- Deepen the shared phone-command definition into a generic command-argument platform supporting no input, free text, curated options, filtering, hidden values, optional custom text, validation, and confirmation.
+- Use that platform to present a curated mobile `/model` chooser while keeping model IDs internal and validating the selected value again on the extension.
+
+## Capabilities
+
+### New Capabilities
+
+- `mobile-session-command-experience`: Mobile chat, Explore, session-launch, resume-list, ToolCard, Markdown, and phone-command behavior for controlling a live Copilot CLI session.
+
+### Modified Capabilities
+
+None.
+
+## Impact
+
+- Shared phone-command declarations and runtime validation.
+- Extension command invocation and tests.
+- Mobile session state, composer command palette, ChatThread scrolling, ToolCard and Markdown rendering, Start Session and resume-list UX, Explore navigation and live activity presentation.
+- OpenSpec, product documentation, focused tests, complete monorepo validation, release packaging, and hosted PWA deployment.
diff --git a/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md b/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md
new file mode 100644
index 0000000..842e88a
--- /dev/null
+++ b/openspec/changes/polish-session-command-experience/specs/mobile-session-command-experience/spec.md
@@ -0,0 +1,87 @@
+## ADDED Requirements
+
+### Requirement: Ordered Markdown preserves authored numbering
+The mobile transcript SHALL preserve the starting marker of every ordered Markdown list, including lists separated by paragraphs.
+
+#### Scenario: Paragraphs split numbered findings
+- **WHEN** an assistant response contains separately rendered ordered-list blocks starting at 1, 2, and 3
+- **THEN** each rendered list starts at its authored number
+
+### Requirement: Start Session prioritizes naming before permissions
+The New Session form SHALL place the optional session name before permission selection and keep the focused field usable above the mobile keyboard.
+
+#### Scenario: User names and starts a session from the keyboard
+- **WHEN** the user focuses the session-name field, enters a valid name, and presses Enter
+- **THEN** the field remains visible and the session starts with that name
+
+### Requirement: Resume sessions use a compact readable list
+The Resume view SHALL present sessions as compact rows with title and age on the primary line, repository/branch metadata on the secondary line, and a clear lightweight selected state.
+
+#### Scenario: Many resumable sessions are visible
+- **WHEN** the device returns multiple sessions
+- **THEN** the view fits more rows than the previous card layout without losing title, age, repository, branch, or selection information
+
+### Requirement: Shell ToolCards separate command, output, and status
+Expanded shell ToolCards SHALL present the command, options, output, completion status, exit code, and shell identifier as distinct readable surfaces while keeping raw arguments optional.
+
+#### Scenario: Completed shell command includes a shell marker
+- **WHEN** a shell tool result ends with an internal shell completion marker
+- **THEN** the visible output excludes that marker and the card displays the parsed exit code and shell identifier separately
+
+### Requirement: Reader scroll intent survives session heartbeats
+The chat SHALL treat an explicit upward touch gesture as detached reading intent until the reader deliberately returns to the latest content.
+
+#### Scenario: Heartbeat arrives during momentum scrolling
+- **WHEN** the reader swipes upward, releases the screen, and a heartbeat or resize update arrives before the next decisive scroll event
+- **THEN** the transcript remains at the reader's position and offers Jump to latest
+
+### Requirement: Phone commands support generic argument experiences
+The shared phone-command catalogue SHALL describe no-input, free-text, and curated-option arguments so the mobile composer can render and validate command arguments without command-specific UI branches.
+
+#### Scenario: Option command is selected
+- **WHEN** the user selects a command with curated options
+- **THEN** the existing suggestion panel displays filterable friendly choices and executes the hidden validated value
+
+#### Scenario: Text command is selected
+- **WHEN** the user selects a command that accepts required or optional text
+- **THEN** the composer accepts free text according to the shared requirement and placeholder
+
+### Requirement: Mobile model selection is curated and validated
+The mobile `/model` experience SHALL expose only Weft-approved friendly choices, keep CLI model IDs out of visible UI, and validate the chosen value on both phone and extension.
+
+#### Scenario: User selects an approved model
+- **WHEN** the user chooses a curated model from the `/model` argument list
+- **THEN** Weft invokes `/model` with the hidden internal value and reports the real command outcome
+
+#### Scenario: Model is unavailable
+- **WHEN** the CLI rejects a curated model for the current account or version
+- **THEN** Weft displays the failure and does not claim that the model changed
+
+### Requirement: Tool activity uses consistent explainable labels
+Chat and Explore SHALL use one centralized mapping for compact tool names while preserving detailed raw arguments only in expandable ToolCards.
+
+#### Scenario: Ripgrep activity is projected
+- **WHEN** the session emits an `rg` tool event
+- **THEN** compact activity displays `Search` rather than `Rg` or a raw search pattern
+
+### Requirement: Chat can enter Discover with a right-edge gesture
+On supported mobile layouts, Chat SHALL allow a deliberate right-edge swipe left to reveal and open Discover without interfering with vertical scrolling or other overlays.
+
+#### Scenario: Gesture crosses the threshold
+- **WHEN** a touch begins in the right-edge activation zone and moves left with horizontal dominance beyond the commit threshold
+- **THEN** Discover opens directly and one Back action returns to the same chat
+
+#### Scenario: Gesture is cancelled
+- **WHEN** the movement remains below threshold or becomes vertically dominant
+- **THEN** the preview springs back and Chat remains active
+
+### Requirement: Explore live Copilot tile prioritizes current work
+The Explore live Copilot tile SHALL present one clear current activity, real streaming assistant text, limited deduplicated history, meaningful elapsed time, and an integrated path back to chat.
+
+#### Scenario: Repeated identical tools complete
+- **WHEN** adjacent tool events have the same compact presentation
+- **THEN** the tile collapses them into one truthful summarized activity rather than rendering duplicate rows
+
+#### Scenario: Approval needs attention
+- **WHEN** a pending approval or elicitation exists
+- **THEN** the tile replaces the normal feed with one clear attention action that opens Chat
diff --git a/openspec/changes/polish-session-command-experience/tasks.md b/openspec/changes/polish-session-command-experience/tasks.md
new file mode 100644
index 0000000..8ca8ffe
--- /dev/null
+++ b/openspec/changes/polish-session-command-experience/tasks.md
@@ -0,0 +1,47 @@
+## 1. Recover Abandoned Mobile UX
+
+- [x] 1.1 Preserve authored ordered-list start values and add focused Markdown tests
+- [x] 1.2 Move the New Session name field above permissions and restore keyboard visibility plus Enter submission
+- [x] 1.3 Restore compact resume rows with separated age and repository metadata
+- [x] 1.4 Restore rich shell ToolCard input, output, metadata, status, and parsing tests
+
+## 2. Protect Chat Reading Position
+
+- [x] 2.1 Track older-message touch intent independently from transient bottom-gap measurements
+- [x] 2.2 Preserve detached reading through heartbeat, streaming, resize, and momentum-scroll timing
+- [x] 2.3 Add regression tests for heartbeat and ResizeObserver updates during mobile scrolling
+
+## 3. Deepen Phone Command Arguments
+
+- [x] 3.1 Replace the shallow command argument flag with shared no-input, text, and option definitions
+- [x] 3.2 Add shared option lookup and validation helpers used by mobile and extension
+- [x] 3.3 Refactor the composer suggestion flow into command and argument stages with filtering and keyboard/touch selection
+- [x] 3.4 Add curated mobile model choices with hidden IDs and extension-side allowlist validation
+- [x] 3.5 Add shared, extension, and mobile tests for text, option, custom, confirmation, failure, and model flows
+
+## 4. Refine Chat and Explore Navigation
+
+- [x] 4.1 Add direct Discover history state and correct one-step return to Chat
+- [x] 4.2 Add right-edge swipe intent, preview, commit, cancellation, overlay guards, and reduced-motion behavior
+- [x] 4.3 Add focused tests for threshold, direction, cancellation, direct Back, and disabled contexts
+
+## 5. Centralize Tool Presentation and Redesign the Live Tile
+
+- [x] 5.1 Create one explainable tool-label mapping shared by ToolCard and Explore
+- [x] 5.2 Replace the debug-log tile layout with a Copilot presence card and integrated Open Chat action
+- [x] 5.3 Deduplicate adjacent compact tool activities and preserve real streaming assistant text
+- [x] 5.4 Add tests for mappings, elapsed-time thresholds, deduplication, attention states, and chat return
+
+## 6. Documentation and Validation
+
+- [x] 6.1 Update README and relevant product documentation for command options, model selection, gestures, and live activity
+- [x] 6.2 Run focused shared, extension, composer, chat, Start Session, ToolCard, Markdown, App, and Explore tests
+- [x] 6.3 Run mobile type tests, complete repository tests, build, lint, version check, and strict OpenSpec validation
+- [x] 6.4 Review the integrated diff for protocol compatibility, privacy, accessibility, gesture conflicts, and unrelated changes
+
+## 7. Land and Release
+
+- [x] 7.1 Create and link the matching work item and feature PR
+- [ ] 7.2 Merge the feature after all required checks pass
+- [ ] 7.3 Build and deploy the next patch release with the normal release script
+- [ ] 7.4 Merge the release PR, verify production and immutable manifests, and complete the work item
diff --git a/shared/commands.d.ts b/shared/commands.d.ts
index 5a8f430..a49196a 100644
--- a/shared/commands.d.ts
+++ b/shared/commands.d.ts
@@ -1,7 +1,27 @@
// SPDX-License-Identifier: Apache-2.0
// Types for the phone-invokable Copilot CLI slash-command whitelist. See commands.mjs.
-export type PhoneCommandArg = "none" | "optional" | "required";
+export interface PhoneCommandOption {
+ /** Internal CLI value; never use as visible UI copy. */
+ value: string;
+ /** Friendly value shown in the phone palette. */
+ label: string;
+ /** Optional supporting copy shown under the label. */
+ hint?: string;
+ /** Friendly search and input aliases. */
+ aliases?: ReadonlyArray;
+}
+
+export type PhoneCommandInput =
+ | { kind: "none" }
+ | { kind: "text"; required: boolean; placeholder: string }
+ | {
+ kind: "options";
+ required: boolean;
+ allowCustom: boolean;
+ placeholder: string;
+ options: ReadonlyArray;
+ };
export interface PhoneCommand {
/** Canonical command name (no leading slash), lower-case. */
@@ -10,8 +30,8 @@ export interface PhoneCommand {
label: string;
/** One-line description shown under the label. */
hint: string;
- /** Whether the command takes free-text input after the name. */
- arg: PhoneCommandArg;
+ /** Argument experience and validation rules. */
+ input: PhoneCommandInput;
/** Require an explicit phone confirmation before running (destructive / permission-broadening). */
confirm?: boolean;
}
@@ -27,3 +47,17 @@ export function getPhoneCommand(name: string): PhoneCommand | null;
/** True iff `name` is a command the phone is allowed to invoke. */
export function isPhoneCommandAllowed(name: string): boolean;
+
+/** Resolve a curated option by internal value, friendly label, or alias. */
+export function getPhoneCommandOption(
+ commandOrName: PhoneCommand | string,
+ raw: unknown,
+): PhoneCommandOption | null;
+
+/** Validate and canonicalize a phone-command argument. */
+export function validatePhoneCommandInput(
+ commandOrName: PhoneCommand | string,
+ raw: unknown,
+):
+ | { valid: true; input?: string; option?: PhoneCommandOption }
+ | { valid: false; error: string };
diff --git a/shared/commands.mjs b/shared/commands.mjs
index cb98b12..3f3d816 100644
--- a/shared/commands.mjs
+++ b/shared/commands.mjs
@@ -11,33 +11,76 @@
// /theme, /ide, /help, /copy, /login, /voice (weft Voice Mode), /new (weft spawn), /pr, /delegate…
/**
- * @typedef {"none" | "optional" | "required"} PhoneCommandArg
+ * @typedef {Object} PhoneCommandOption
+ * @property {string} value Internal CLI value; never used as visible copy.
+ * @property {string} label Friendly value shown in the phone palette.
+ * @property {string} [hint] Optional supporting copy shown under the label.
+ * @property {ReadonlyArray} [aliases] Friendly search and input aliases.
+ *
+ * @typedef {{ kind: "none" }} PhoneCommandNoInput
+ * @typedef {{ kind: "text", required: boolean, placeholder: string }} PhoneCommandTextInput
+ * @typedef {{ kind: "options", required: boolean, allowCustom: boolean, placeholder: string, options: ReadonlyArray }} PhoneCommandOptionsInput
+ * @typedef {PhoneCommandNoInput | PhoneCommandTextInput | PhoneCommandOptionsInput} PhoneCommandInput
+ *
* @typedef {Object} PhoneCommand
* @property {string} name Canonical command name (no leading slash), lower-case.
* @property {string} label Short human label for the palette.
* @property {string} hint One-line description shown under the label.
- * @property {PhoneCommandArg} arg Whether the command takes free-text input after the name.
+ * @property {PhoneCommandInput} input Argument experience and validation rules.
* @property {boolean} [confirm] Require an explicit phone confirmation before running
* (destructive / permission-broadening commands).
*/
+const noInput = Object.freeze({ kind: "none" });
+
+function textInput(required, placeholder) {
+ return Object.freeze({ kind: "text", required, placeholder });
+}
+
+function optionsInput(required, allowCustom, placeholder, options) {
+ return Object.freeze({
+ kind: "options",
+ required,
+ allowCustom,
+ placeholder,
+ options: Object.freeze(
+ options.map((option) =>
+ Object.freeze({
+ ...option,
+ ...(option.aliases ? { aliases: Object.freeze([...option.aliases]) } : {}),
+ }),
+ ),
+ ),
+ });
+}
+
/** @type {ReadonlyArray} */
export const PHONE_COMMANDS = Object.freeze(
[
// --- Tier 1: safe, fire-and-return, meaningful when driving from a phone ---
- { name: "rename", label: "/rename", hint: "Rename this session", arg: "required" },
- { name: "compact", label: "/compact", hint: "Summarize context to free space", arg: "optional" },
- { name: "model", label: "/model", hint: "Switch model (give a model id)", arg: "required" },
- { name: "autopilot", label: "/autopilot", hint: "Toggle autopilot mode", arg: "optional" },
- { name: "plan", label: "/plan", hint: "Enter plan mode", arg: "none" },
- { name: "review", label: "/review", hint: "Review the current changes", arg: "none" },
- { name: "security-review", label: "/security-review", hint: "Security-review the changes", arg: "none" },
- { name: "rubber-duck", label: "/rubber-duck", hint: "Independent critique of the work", arg: "none" },
- { name: "keep-alive", label: "/keep-alive", hint: "Keep the laptop awake", arg: "optional" },
+ { name: "rename", label: "/rename", hint: "Rename this session", input: textInput(true, "Session name") },
+ { name: "compact", label: "/compact", hint: "Summarize context to free space", input: textInput(false, "Optional focus") },
+ {
+ name: "model",
+ label: "/model",
+ hint: "Switch the model for this session",
+ input: optionsInput(true, false, "Choose a model", [
+ { value: "auto", label: "Auto", hint: "Recommended", aliases: ["recommended", "default"] },
+ { value: "gpt-5.6-sol", label: "GPT-5.6 Sol", aliases: ["gpt", "sol"] },
+ { value: "claude-sonnet-5", label: "Claude Sonnet 5", aliases: ["claude", "sonnet"] },
+ { value: "gemini-3.8-flash", label: "Gemini 3.8 Flash", aliases: ["gemini", "flash"] },
+ ]),
+ },
+ { name: "autopilot", label: "/autopilot", hint: "Toggle autopilot mode", input: textInput(false, "Optional instructions") },
+ { name: "plan", label: "/plan", hint: "Enter plan mode", input: noInput },
+ { name: "review", label: "/review", hint: "Review the current changes", input: noInput },
+ { name: "security-review", label: "/security-review", hint: "Security-review the changes", input: noInput },
+ { name: "rubber-duck", label: "/rubber-duck", hint: "Independent critique of the work", input: noInput },
+ { name: "keep-alive", label: "/keep-alive", hint: "Keep the laptop awake", input: textInput(false, "Optional duration") },
// --- Tier 2: allowed but require an explicit confirm on the phone ---
- { name: "allow-all", label: "/allow-all", hint: "Allow all tools, paths & URLs", arg: "none", confirm: true },
- { name: "clear", label: "/clear", hint: "Abandon this session, start fresh", arg: "none", confirm: true },
- ].map((c) => Object.freeze(c)),
+ { name: "allow-all", label: "/allow-all", hint: "Allow all tools, paths & URLs", input: noInput, confirm: true },
+ { name: "clear", label: "/clear", hint: "Abandon this session, start fresh", input: noInput, confirm: true },
+ ].map((command) => Object.freeze(command)),
);
/** Normalize free-form input ("/Rename", " rename ") to a canonical command name. */
@@ -60,3 +103,60 @@ export function getPhoneCommand(name) {
export function isPhoneCommandAllowed(name) {
return getPhoneCommand(name) !== null;
}
+
+function normalizeOptionLookup(raw) {
+ return typeof raw === "string" ? raw.trim().toLocaleLowerCase() : "";
+}
+
+/**
+ * Resolve a curated option by internal value, friendly label, or alias.
+ * @param {PhoneCommand | string} commandOrName
+ * @param {unknown} raw
+ * @returns {PhoneCommandOption | null}
+ */
+export function getPhoneCommandOption(commandOrName, raw) {
+ const command =
+ typeof commandOrName === "string" ? getPhoneCommand(commandOrName) : commandOrName;
+ if (!command || command.input.kind !== "options") return null;
+ const lookup = normalizeOptionLookup(raw);
+ if (!lookup) return null;
+ return (
+ command.input.options.find((option) =>
+ [option.value, option.label, ...(option.aliases ?? [])].some(
+ (candidate) => candidate.toLocaleLowerCase() === lookup,
+ ),
+ ) ?? null
+ );
+}
+
+/**
+ * Validate and canonicalize a phone-command argument.
+ * @param {PhoneCommand | string} commandOrName
+ * @param {unknown} raw
+ * @returns {{ valid: true, input?: string, option?: PhoneCommandOption } | { valid: false, error: string }}
+ */
+export function validatePhoneCommandInput(commandOrName, raw) {
+ const command =
+ typeof commandOrName === "string" ? getPhoneCommand(commandOrName) : commandOrName;
+ if (!command) return { valid: false, error: "Command is not allowed from the phone." };
+
+ const input = typeof raw === "string" ? raw.trim() : "";
+ if (command.input.kind === "none") {
+ return input
+ ? { valid: false, error: `/${command.name} does not accept an argument.` }
+ : { valid: true };
+ }
+
+ if (!input) {
+ return command.input.required
+ ? { valid: false, error: `/${command.name} needs an argument.` }
+ : { valid: true };
+ }
+
+ if (command.input.kind === "text") return { valid: true, input };
+
+ const option = getPhoneCommandOption(command, input);
+ if (option) return { valid: true, input: option.value, option };
+ if (command.input.allowCustom) return { valid: true, input };
+ return { valid: false, error: `That value isn't available for /${command.name}.` };
+}
diff --git a/shared/test/commands.test.mjs b/shared/test/commands.test.mjs
index 8c582a4..57c130b 100644
--- a/shared/test/commands.test.mjs
+++ b/shared/test/commands.test.mjs
@@ -7,7 +7,9 @@ import {
PHONE_COMMANDS,
normalizeCommandName,
getPhoneCommand,
+ getPhoneCommandOption,
isPhoneCommandAllowed,
+ validatePhoneCommandInput,
} from "../commands.mjs";
import { EVENT_TYPE, SUBTYPE, invokeCommand } from "../messages.mjs";
@@ -20,7 +22,12 @@ test("PHONE_COMMANDS is a frozen, non-empty list with well-formed entries", () =
assert.equal(c.name, c.name.toLowerCase());
assert.doesNotMatch(c.name, /^\//); // no leading slash in canonical name
assert.equal(c.label, `/${c.name}`);
- assert.ok(["none", "optional", "required"].includes(c.arg));
+ assert.ok(["none", "text", "options"].includes(c.input.kind));
+ assert.ok(Object.isFrozen(c.input));
+ if (c.input.kind === "options") {
+ assert.ok(Object.isFrozen(c.input.options));
+ assert.ok(c.input.options.every((option) => Object.isFrozen(option)));
+ }
assert.ok(Object.isFrozen(c));
}
});
@@ -53,6 +60,71 @@ test("confirm-gated commands are marked and destructive", () => {
assert.equal(getPhoneCommand("plan")?.confirm, undefined);
});
+test("model exposes curated friendly options with hidden canonical values", () => {
+ const model = getPhoneCommand("model");
+ assert.equal(model?.input.kind, "options");
+ assert.deepEqual(
+ model.input.options.map(({ value, label, hint }) => ({ value, label, hint })),
+ [
+ { value: "auto", label: "Auto", hint: "Recommended" },
+ { value: "gpt-5.6-sol", label: "GPT-5.6 Sol", hint: undefined },
+ { value: "claude-sonnet-5", label: "Claude Sonnet 5", hint: undefined },
+ { value: "gemini-3.8-flash", label: "Gemini 3.8 Flash", hint: undefined },
+ ],
+ );
+});
+
+test("option lookup accepts values, friendly labels, and aliases", () => {
+ assert.equal(getPhoneCommandOption("model", "GPT-5.6 Sol")?.value, "gpt-5.6-sol");
+ assert.equal(getPhoneCommandOption("model", "recommended")?.value, "auto");
+ assert.equal(getPhoneCommandOption("model", "claude-sonnet-5")?.label, "Claude Sonnet 5");
+ assert.equal(getPhoneCommandOption("rename", "anything"), null);
+});
+
+test("input validation preserves text/no-input behavior and canonicalizes options", () => {
+ assert.deepEqual(validatePhoneCommandInput("plan", ""), { valid: true });
+ assert.deepEqual(validatePhoneCommandInput("plan", "extra"), {
+ valid: false,
+ error: "/plan does not accept an argument.",
+ });
+ assert.deepEqual(validatePhoneCommandInput("rename", " My Session "), {
+ valid: true,
+ input: "My Session",
+ });
+ assert.deepEqual(validatePhoneCommandInput("rename", ""), {
+ valid: false,
+ error: "/rename needs an argument.",
+ });
+
+ const model = validatePhoneCommandInput("model", "Sonnet");
+ assert.equal(model.valid, true);
+ assert.equal(model.input, "claude-sonnet-5");
+ assert.equal(model.option?.label, "Claude Sonnet 5");
+ assert.deepEqual(validatePhoneCommandInput("model", "unlisted-model"), {
+ valid: false,
+ error: "That value isn't available for /model.",
+ });
+});
+
+test("option validation supports custom values only when the definition allows them", () => {
+ const customCommand = {
+ name: "custom",
+ label: "/custom",
+ hint: "Custom option command",
+ input: {
+ kind: "options",
+ required: true,
+ allowCustom: true,
+ placeholder: "Choose or type",
+ options: [{ value: "known", label: "Known" }],
+ },
+ };
+ assert.deepEqual(validatePhoneCommandInput(customCommand, "other"), {
+ valid: true,
+ input: "other",
+ });
+});
+
test("invokeCommand builds a CONTROL/INVOKE_COMMAND envelope, omitting empty input", () => {
const bare = invokeCommand("plan");
assert.equal(bare.eventType, EVENT_TYPE.CONTROL);