feat(cli): TUI complete overhaul — components, sidebar, search, branding #157
@@ -12,6 +12,7 @@ import {
|
|||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
import type { Auth } from '@mosaic/auth';
|
import type { Auth } from '@mosaic/auth';
|
||||||
|
import type { SetThinkingPayload } from '@mosaic/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
@@ -112,6 +113,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
// Track channel connection
|
// Track channel connection
|
||||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||||
|
|
||||||
|
// Send session info so the client knows the model/provider
|
||||||
|
{
|
||||||
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
|
if (agentSession) {
|
||||||
|
const piSession = agentSession.piSession;
|
||||||
|
client.emit('session:info', {
|
||||||
|
conversationId,
|
||||||
|
provider: agentSession.provider,
|
||||||
|
modelId: agentSession.modelId,
|
||||||
|
thinkingLevel: piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: piSession.getAvailableThinkingLevels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||||
|
|
||||||
@@ -130,6 +146,43 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('set:thinking')
|
||||||
|
handleSetThinking(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() data: SetThinkingPayload,
|
||||||
|
): void {
|
||||||
|
const session = this.agentService.getSession(data.conversationId);
|
||||||
|
if (!session) {
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: 'No active session for this conversation.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validLevels = session.piSession.getAvailableThinkingLevels();
|
||||||
|
if (!validLevels.includes(data.level as never)) {
|
||||||
|
client.emit('error', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
error: `Invalid thinking level "${data.level}". Available: ${validLevels.join(', ')}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.piSession.setThinkingLevel(data.level as never);
|
||||||
|
this.logger.log(
|
||||||
|
`Thinking level set to "${data.level}" for conversation ${data.conversationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
client.emit('session:info', {
|
||||||
|
conversationId: data.conversationId,
|
||||||
|
provider: session.provider,
|
||||||
|
modelId: session.modelId,
|
||||||
|
thinkingLevel: session.piSession.thinkingLevel,
|
||||||
|
availableThinkingLevels: session.piSession.getAvailableThinkingLevels(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -143,9 +196,31 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'agent_end':
|
case 'agent_end': {
|
||||||
client.emit('agent:end', { conversationId });
|
// Gather usage stats from the Pi session
|
||||||
|
const agentSession = this.agentService.getSession(conversationId);
|
||||||
|
const piSession = agentSession?.piSession;
|
||||||
|
const stats = piSession?.getSessionStats();
|
||||||
|
const contextUsage = piSession?.getContextUsage();
|
||||||
|
|
||||||
|
client.emit('agent:end', {
|
||||||
|
conversationId,
|
||||||
|
usage: stats
|
||||||
|
? {
|
||||||
|
provider: agentSession?.provider ?? 'unknown',
|
||||||
|
modelId: agentSession?.modelId ?? 'unknown',
|
||||||
|
thinkingLevel: piSession?.thinkingLevel ?? 'off',
|
||||||
|
tokens: stats.tokens,
|
||||||
|
cost: stats.cost,
|
||||||
|
context: {
|
||||||
|
percent: contextUsage?.percent ?? null,
|
||||||
|
window: contextUsage?.contextWindow ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
|||||||
70
docs/PRD-TUI_Improvements.md
Normal file
70
docs/PRD-TUI_Improvements.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# PRD: TUI Improvements — Phase 7
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**Package:** `packages/cli`
|
||||||
|
**Status:** In Progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current Mosaic CLI TUI (`packages/cli/src/tui/app.tsx`) is a minimal single-file Ink application with:
|
||||||
|
|
||||||
|
- Flat message list with no visual hierarchy
|
||||||
|
- No system context visibility (cwd, branch, model, tokens)
|
||||||
|
- Noisy error messages when gateway is disconnected
|
||||||
|
- No conversation management (list, switch, rename, delete)
|
||||||
|
- No multi-panel layout or navigation
|
||||||
|
- No tool call visibility during agent execution
|
||||||
|
- No thinking/reasoning display
|
||||||
|
|
||||||
|
The TUI should be the power-user interface to Mosaic — informative, responsive, and visually clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
### Wave 1 — Status Bar & Polish (MVP)
|
||||||
|
|
||||||
|
Provide essential context at a glance and reduce noise.
|
||||||
|
|
||||||
|
1. **Top status bar** — shows: connection indicator (●/○), gateway URL, agent model name
|
||||||
|
2. **Bottom status bar** — shows: cwd, git branch, token usage (input/output/total)
|
||||||
|
3. **Better message formatting** — distinct visual treatment for user vs assistant messages, timestamps, word wrap
|
||||||
|
4. **Quiet disconnect** — single-line indicator when gateway is offline instead of flooding error messages; auto-reconnect silently
|
||||||
|
5. **Tool call display** — inline indicators when agent uses tools (spinner + tool name during execution, ✓/✗ on completion)
|
||||||
|
6. **Thinking/reasoning display** — collapsible dimmed block for `agent:thinking` events
|
||||||
|
|
||||||
|
### Wave 2 — Layout & Navigation
|
||||||
|
|
||||||
|
Multi-panel layout with keyboard navigation.
|
||||||
|
|
||||||
|
1. **Conversation sidebar** — list conversations, create new, switch between them
|
||||||
|
2. **Keybinding system** — Ctrl+N (new conversation), Ctrl+L (conversation list toggle), Ctrl+K (command palette concept)
|
||||||
|
3. **Scrollable message history** — viewport with PgUp/PgDn/arrow key scrolling
|
||||||
|
4. **Message search** — find in current conversation
|
||||||
|
|
||||||
|
### Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
1. **Project/mission views** — show active projects, missions, tasks
|
||||||
|
2. **Agent status monitoring** — real-time agent state, queue depth
|
||||||
|
3. **Settings/config screen** — view/edit connection settings, model preferences
|
||||||
|
4. **Multiple agent sessions** — split view or tab-based multi-agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
- **Ink 5** (React for CLI) — already in deps
|
||||||
|
- **Component architecture** — break monolithic `app.tsx` into composable components
|
||||||
|
- **Typed Socket.IO events** — leverage `@mosaic/types` `ServerToClientEvents` / `ClientToServerEvents`
|
||||||
|
- **Local state only** (Wave 1) — cwd/branch read from `process.cwd()` and `git` at startup
|
||||||
|
- **Gateway metadata** (future) — extend socket handshake or add REST endpoint for model info, token usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals (for now)
|
||||||
|
|
||||||
|
- Image rendering in terminal
|
||||||
|
- File editor integration
|
||||||
|
- SSH/remote gateway auto-discovery
|
||||||
105
docs/TASKS-TUI_Improvements.md
Normal file
105
docs/TASKS-TUI_Improvements.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Tasks: TUI Improvements
|
||||||
|
|
||||||
|
**Branch:** `feat/p7-tui-improvements`
|
||||||
|
**Worktree:** `/home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements`
|
||||||
|
**PRD:** [PRD-TUI_Improvements.md](./PRD-TUI_Improvements.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 1 — Status Bar & Polish ✅
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| -------- | ----------------------------------------------------------------------------------------------------- | ------- | ------- |
|
||||||
|
| TUI-001 | Component architecture — split `app.tsx` into `TopBar`, `BottomBar`, `MessageList`, `InputBar`, hooks | ✅ done | 79ff308 |
|
||||||
|
| TUI-002 | Top status bar — branded mosaic icon, version, model, connection indicator | ✅ done | 6c2b01e |
|
||||||
|
| TUI-003 | Bottom status bar — cwd, git branch, token usage, session ID, gateway status | ✅ done | e8d7ab8 |
|
||||||
|
| TUI-004 | Message formatting — timestamps, role colors (❯ you / ◆ assistant), word wrap | ✅ done | 79ff308 |
|
||||||
|
| TUI-005 | Quiet disconnect — single indicator, auto-reconnect, no error flood | ✅ done | 79ff308 |
|
||||||
|
| TUI-006 | Tool call display — inline spinner + tool name during execution, ✓/✗ on completion | ✅ done | 79ff308 |
|
||||||
|
| TUI-007 | Thinking/reasoning display — dimmed 💭 block for `agent:thinking` events | ✅ done | 79ff308 |
|
||||||
|
| TUI-007b | Wire token usage, model info, thinking levels end-to-end (gateway → types → TUI) | ✅ done | a061a64 |
|
||||||
|
| TUI-007c | Ctrl+T to cycle thinking levels via `set:thinking` socket event | ✅ done | a061a64 |
|
||||||
|
|
||||||
|
## Wave 2 — Layout & Navigation ✅
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | --------------------------------------------------------- | ------- | ------- |
|
||||||
|
| TUI-010 | Scrollable message history — viewport with PgUp/PgDn | ✅ done | 4d4ad38 |
|
||||||
|
| TUI-008 | Conversation sidebar — list, create, switch conversations | ✅ done | 9ef578c |
|
||||||
|
| TUI-009 | Keybinding system — Ctrl+L, Ctrl+N, Ctrl+K, Escape | ✅ done | 9f38f5a |
|
||||||
|
| TUI-011 | Message search — find in current conversation | ✅ done | 8627827 |
|
||||||
|
|
||||||
|
## Wave 3 — Advanced Features
|
||||||
|
|
||||||
|
| ID | Task | Status | Notes |
|
||||||
|
| ------- | ----------------------- | ----------- | ----- |
|
||||||
|
| TUI-012 | Project/mission views | not-started | |
|
||||||
|
| TUI-013 | Agent status monitoring | not-started | |
|
||||||
|
| TUI-014 | Settings/config screen | not-started | |
|
||||||
|
| TUI-015 | Multiple agent sessions | not-started | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handoff Notes
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/cli/src/tui/
|
||||||
|
├── app.tsx ← Shell composing all components + global keybindings
|
||||||
|
├── components/
|
||||||
|
│ ├── top-bar.tsx ← Mosaic icon + version + model + connection
|
||||||
|
│ ├── bottom-bar.tsx ← Keybinding hints + 3-line footer: gateway, cwd, tokens
|
||||||
|
│ ├── message-list.tsx ← Messages, tool calls, thinking, streaming, search highlights
|
||||||
|
│ ├── input-bar.tsx ← Bordered prompt with context-aware placeholder
|
||||||
|
│ ├── sidebar.tsx ← Conversation list with keyboard navigation
|
||||||
|
│ └── search-bar.tsx ← Message search input with match count + navigation
|
||||||
|
└── hooks/
|
||||||
|
├── use-socket.ts ← Typed Socket.IO + switchConversation/clearMessages
|
||||||
|
├── use-git-info.ts ← Reads cwd + git branch at startup
|
||||||
|
├── use-viewport.ts ← Scrollable viewport with auto-follow + PgUp/PgDn
|
||||||
|
├── use-app-mode.ts ← Panel focus state machine (chat/sidebar/search)
|
||||||
|
├── use-conversations.ts ← REST client for conversation CRUD
|
||||||
|
└── use-search.ts ← Message search with match cycling
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Package Changes
|
||||||
|
|
||||||
|
- **`packages/types/src/chat/events.ts`** — Added `SessionUsagePayload`, `SessionInfoPayload`, `SetThinkingPayload`, `session:info` event, `set:thinking` event
|
||||||
|
- **`apps/gateway/src/chat/chat.gateway.ts`** — Emits `session:info` on session creation, includes `usage` in `agent:end`, handles `set:thinking`
|
||||||
|
|
||||||
|
### Key Design Decisions
|
||||||
|
|
||||||
|
#### Wave 1
|
||||||
|
|
||||||
|
- Footer is 3 lines: (1) gateway status right-aligned, (2) cwd+branch left / session right, (3) tokens left / provider+model+thinking right
|
||||||
|
- Mosaic icon uses brand colors in windmill cross pattern with `GAP` const to prevent prettier collapsing spaces
|
||||||
|
- `flexGrow={1}` on header text column prevents re-render artifacts
|
||||||
|
- Token/model data comes from gateway via `agent:end` payload and `session:info` events
|
||||||
|
- Thinking level cycling via Ctrl+T sends `set:thinking` to gateway, which validates and responds with `session:info`
|
||||||
|
|
||||||
|
#### Wave 2
|
||||||
|
|
||||||
|
- `useViewport` calculates scroll offset from terminal rows; auto-follow snaps to bottom on new messages
|
||||||
|
- `useAppMode` state machine manages focus: only the active panel handles keyboard input via `useInput({ isActive })`
|
||||||
|
- Sidebar fetches conversations via REST (`GET /api/conversations`), not socket events
|
||||||
|
- `switchConversation` in `useSocket` clears all local state (messages, streaming, tool calls)
|
||||||
|
- Search uses `useMemo` for reactive match computation; viewport auto-scrolls to current match
|
||||||
|
- Keybinding hints shown in bottom bar: `^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll`
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/jwoltje/src/mosaic-mono-v1-worktrees/tui-improvements
|
||||||
|
pnpm --filter @mosaic/cli exec tsx src/cli.ts tui
|
||||||
|
# or after build:
|
||||||
|
node packages/cli/dist/cli.js tui --gateway http://localhost:4000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quality Gates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mosaic/cli typecheck && pnpm --filter @mosaic/cli lint
|
||||||
|
pnpm --filter @mosaic/gateway typecheck && pnpm --filter @mosaic/gateway lint
|
||||||
|
pnpm --filter @mosaic/types typecheck
|
||||||
|
```
|
||||||
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
1000
docs/plans/2026-03-15-wave2-tui-layout-navigation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,12 +24,13 @@
|
|||||||
"@mosaic/mosaic": "workspace:^",
|
"@mosaic/mosaic": "workspace:^",
|
||||||
"@mosaic/prdy": "workspace:^",
|
"@mosaic/prdy": "workspace:^",
|
||||||
"@mosaic/quality-rails": "workspace:^",
|
"@mosaic/quality-rails": "workspace:^",
|
||||||
|
"@mosaic/types": "workspace:^",
|
||||||
|
"commander": "^13.0.0",
|
||||||
"ink": "^5.0.0",
|
"ink": "^5.0.0",
|
||||||
"ink-text-input": "^6.0.0",
|
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
|
"ink-text-input": "^6.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"socket.io-client": "^4.8.0",
|
"socket.io-client": "^4.8.0"
|
||||||
"commander": "^13.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Box, Text, useInput, useApp } from 'ink';
|
import { Box, useApp, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import { TopBar } from './components/top-bar.js';
|
||||||
import Spinner from 'ink-spinner';
|
import { BottomBar } from './components/bottom-bar.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { MessageList } from './components/message-list.js';
|
||||||
import { fetchAvailableModels, type ModelInfo } from './gateway-api.js';
|
import { InputBar } from './components/input-bar.js';
|
||||||
|
import { Sidebar } from './components/sidebar.js';
|
||||||
|
import { SearchBar } from './components/search-bar.js';
|
||||||
|
import { useSocket } from './hooks/use-socket.js';
|
||||||
|
import { useGitInfo } from './hooks/use-git-info.js';
|
||||||
|
import { useViewport } from './hooks/use-viewport.js';
|
||||||
|
import { useAppMode } from './hooks/use-app-mode.js';
|
||||||
|
import { useConversations } from './hooks/use-conversations.js';
|
||||||
|
import { useSearch } from './hooks/use-search.js';
|
||||||
|
|
||||||
interface Message {
|
export interface TuiAppProps {
|
||||||
role: 'user' | 'assistant' | 'system';
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TuiAppProps {
|
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
sessionCookie?: string;
|
sessionCookie?: string;
|
||||||
@@ -18,375 +21,230 @@ interface TuiAppProps {
|
|||||||
initialProvider?: string;
|
initialProvider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a slash command from user input.
|
|
||||||
* Returns null if the input is not a slash command.
|
|
||||||
*/
|
|
||||||
function parseSlashCommand(value: string): { command: string; args: string[] } | null {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed.startsWith('/')) return null;
|
|
||||||
const parts = trimmed.slice(1).split(/\s+/);
|
|
||||||
const command = parts[0]?.toLowerCase() ?? '';
|
|
||||||
const args = parts.slice(1);
|
|
||||||
return { command, args };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TuiApp({
|
export function TuiApp({
|
||||||
gatewayUrl,
|
gatewayUrl,
|
||||||
conversationId: initialConversationId,
|
conversationId,
|
||||||
sessionCookie,
|
sessionCookie,
|
||||||
initialModel,
|
initialModel,
|
||||||
initialProvider,
|
initialProvider,
|
||||||
}: TuiAppProps) {
|
}: TuiAppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const gitInfo = useGitInfo();
|
||||||
const [input, setInput] = useState('');
|
const appMode = useAppMode();
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const [conversationId, setConversationId] = useState(initialConversationId);
|
|
||||||
const [currentStreamText, setCurrentStreamText] = useState('');
|
|
||||||
|
|
||||||
// Model/provider state
|
const socket = useSocket({
|
||||||
const [currentModel, setCurrentModel] = useState<string | undefined>(initialModel);
|
gatewayUrl,
|
||||||
const [currentProvider, setCurrentProvider] = useState<string | undefined>(initialProvider);
|
sessionCookie,
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
initialConversationId: conversationId,
|
||||||
|
initialModel,
|
||||||
|
initialProvider,
|
||||||
|
});
|
||||||
|
|
||||||
const socketRef = useRef<Socket | null>(null);
|
const conversations = useConversations({ gatewayUrl, sessionCookie });
|
||||||
const currentStreamTextRef = useRef('');
|
|
||||||
|
|
||||||
// Fetch available models on mount
|
const viewport = useViewport({ totalItems: socket.messages.length });
|
||||||
|
|
||||||
|
const search = useSearch(socket.messages);
|
||||||
|
|
||||||
|
// Scroll to current match when it changes
|
||||||
|
const currentMatch = search.matches[search.currentMatchIndex];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAvailableModels(gatewayUrl, sessionCookie)
|
if (currentMatch && appMode.mode === 'search') {
|
||||||
.then((models) => {
|
viewport.scrollTo(currentMatch.messageIndex);
|
||||||
setAvailableModels(models);
|
}
|
||||||
// If no model/provider specified and models are available, show the default
|
}, [currentMatch, appMode.mode, viewport]);
|
||||||
if (!initialModel && !initialProvider && models.length > 0) {
|
|
||||||
const first = models[0];
|
|
||||||
if (first) {
|
|
||||||
setCurrentModel(first.id);
|
|
||||||
setCurrentProvider(first.provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Non-fatal: TUI works without model list
|
|
||||||
});
|
|
||||||
}, [gatewayUrl, sessionCookie, initialModel, initialProvider]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Compute highlighted message indices for MessageList
|
||||||
const socket = io(`${gatewayUrl}/chat`, {
|
const highlightedMessageIndices = useMemo(() => {
|
||||||
transports: ['websocket'],
|
if (search.matches.length === 0) return undefined;
|
||||||
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
return new Set(search.matches.map((m) => m.messageIndex));
|
||||||
});
|
}, [search.matches]);
|
||||||
|
|
||||||
socketRef.current = socket;
|
const currentHighlightIndex = currentMatch?.messageIndex;
|
||||||
|
|
||||||
socket.on('connect', () => setConnected(true));
|
const [sidebarSelectedIndex, setSidebarSelectedIndex] = useState(0);
|
||||||
socket.on('disconnect', () => {
|
|
||||||
setConnected(false);
|
|
||||||
setIsStreaming(false);
|
|
||||||
setCurrentStreamText('');
|
|
||||||
});
|
|
||||||
socket.on('connect_error', (err: Error) => {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content: `Connection failed: ${err.message}. Check that the gateway is running at ${gatewayUrl}.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('message:ack', (data: { conversationId: string }) => {
|
const handleSwitchConversation = useCallback(
|
||||||
setConversationId(data.conversationId);
|
(id: string) => {
|
||||||
});
|
socket.switchConversation(id);
|
||||||
|
appMode.setMode('chat');
|
||||||
socket.on('agent:start', () => {
|
|
||||||
setIsStreaming(true);
|
|
||||||
currentStreamTextRef.current = '';
|
|
||||||
setCurrentStreamText('');
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:text', (data: { text: string }) => {
|
|
||||||
currentStreamTextRef.current += data.text;
|
|
||||||
setCurrentStreamText(currentStreamTextRef.current);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('agent:end', () => {
|
|
||||||
const finalText = currentStreamTextRef.current;
|
|
||||||
currentStreamTextRef.current = '';
|
|
||||||
setCurrentStreamText('');
|
|
||||||
if (finalText) {
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: finalText }]);
|
|
||||||
}
|
|
||||||
setIsStreaming(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('error', (data: { error: string }) => {
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'assistant', content: `Error: ${data.error}` }]);
|
|
||||||
setIsStreaming(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
socket.disconnect();
|
|
||||||
};
|
|
||||||
}, [gatewayUrl]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle /model and /provider slash commands.
|
|
||||||
* Returns true if the input was a handled slash command (should not be sent to gateway).
|
|
||||||
*/
|
|
||||||
const handleSlashCommand = useCallback(
|
|
||||||
(value: string): boolean => {
|
|
||||||
const parsed = parseSlashCommand(value);
|
|
||||||
if (!parsed) return false;
|
|
||||||
|
|
||||||
const { command, args } = parsed;
|
|
||||||
|
|
||||||
if (command === 'model') {
|
|
||||||
if (args.length === 0) {
|
|
||||||
// List available models
|
|
||||||
if (availableModels.length === 0) {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'No models available (could not reach gateway). Use /model <modelId> to set one manually.',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const lines = availableModels.map(
|
|
||||||
(m) =>
|
|
||||||
` ${m.provider}/${m.id}${m.id === currentModel && m.provider === currentProvider ? ' (active)' : ''}`,
|
|
||||||
);
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Available models:\n${lines.join('\n')}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Switch model: /model <modelId> or /model <provider>/<modelId>
|
|
||||||
const arg = args[0]!;
|
|
||||||
const slashIdx = arg.indexOf('/');
|
|
||||||
let newProvider: string | undefined;
|
|
||||||
let newModelId: string;
|
|
||||||
|
|
||||||
if (slashIdx !== -1) {
|
|
||||||
newProvider = arg.slice(0, slashIdx);
|
|
||||||
newModelId = arg.slice(slashIdx + 1);
|
|
||||||
} else {
|
|
||||||
newModelId = arg;
|
|
||||||
// Try to find provider from available models list
|
|
||||||
const match = availableModels.find((m) => m.id === newModelId);
|
|
||||||
newProvider = match?.provider ?? currentProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentModel(newModelId);
|
|
||||||
if (newProvider) setCurrentProvider(newProvider);
|
|
||||||
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to model: ${newProvider ? `${newProvider}/` : ''}${newModelId}. Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === 'provider') {
|
|
||||||
if (args.length === 0) {
|
|
||||||
// List providers from available models
|
|
||||||
const providers = [...new Set(availableModels.map((m) => m.provider))];
|
|
||||||
if (providers.length === 0) {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content:
|
|
||||||
'No providers available (could not reach gateway). Use /provider <name> to set one manually.',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
const lines = providers.map((p) => ` ${p}${p === currentProvider ? ' (active)' : ''}`);
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Available providers:\n${lines.join('\n')}`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const newProvider = args[0]!;
|
|
||||||
setCurrentProvider(newProvider);
|
|
||||||
// If switching provider, auto-select first model for that provider
|
|
||||||
const providerModels = availableModels.filter((m) => m.provider === newProvider);
|
|
||||||
if (providerModels.length > 0 && providerModels[0]) {
|
|
||||||
setCurrentModel(providerModels[0].id);
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to provider: ${newProvider} (model: ${providerModels[0]!.id}). Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Switched to provider: ${newProvider}. Takes effect on next message.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (command === 'help') {
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: [
|
|
||||||
'Available commands:',
|
|
||||||
' /model — list available models',
|
|
||||||
' /model <id> — switch model (e.g. /model gpt-4o)',
|
|
||||||
' /model <p>/<id> — switch model with provider (e.g. /model ollama/llama3.2)',
|
|
||||||
' /provider — list available providers',
|
|
||||||
' /provider <name> — switch provider (e.g. /provider ollama)',
|
|
||||||
' /help — show this help',
|
|
||||||
].join('\n'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown slash command — let the user know
|
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{
|
|
||||||
role: 'system',
|
|
||||||
content: `Unknown command: /${command}. Type /help for available commands.`,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
[availableModels, currentModel, currentProvider],
|
[socket, appMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleDeleteConversation = useCallback(
|
||||||
(value: string) => {
|
(id: string) => {
|
||||||
if (!value.trim() || isStreaming) return;
|
void conversations
|
||||||
|
.deleteConversation(id)
|
||||||
setInput('');
|
.then((ok) => {
|
||||||
|
if (ok && id === socket.conversationId) {
|
||||||
// Handle slash commands first
|
socket.clearMessages();
|
||||||
if (handleSlashCommand(value)) return;
|
}
|
||||||
|
})
|
||||||
if (!socketRef.current?.connected) {
|
.catch(() => {});
|
||||||
setMessages((msgs) => [
|
|
||||||
...msgs,
|
|
||||||
{ role: 'assistant', content: 'Not connected to gateway. Message not sent.' },
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages((msgs) => [...msgs, { role: 'user', content: value }]);
|
|
||||||
|
|
||||||
socketRef.current.emit('message', {
|
|
||||||
conversationId,
|
|
||||||
content: value,
|
|
||||||
provider: currentProvider,
|
|
||||||
modelId: currentModel,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[conversationId, isStreaming, currentModel, currentProvider, handleSlashCommand],
|
[conversations, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
useInput((ch, key) => {
|
useInput((ch, key) => {
|
||||||
if (key.ctrl && ch === 'c') {
|
if (key.ctrl && ch === 'c') {
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
// Ctrl+L: toggle sidebar (refresh on open)
|
||||||
|
if (key.ctrl && ch === 'l') {
|
||||||
|
const willOpen = !appMode.sidebarOpen;
|
||||||
|
appMode.toggleSidebar();
|
||||||
|
if (willOpen) {
|
||||||
|
void conversations.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+N: create new conversation and switch to it
|
||||||
|
if (key.ctrl && ch === 'n') {
|
||||||
|
void conversations
|
||||||
|
.createConversation()
|
||||||
|
.then((conv) => {
|
||||||
|
if (conv) {
|
||||||
|
socket.switchConversation(conv.id);
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
// Ctrl+K: toggle search mode
|
||||||
|
if (key.ctrl && ch === 'k') {
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else {
|
||||||
|
appMode.setMode('search');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Page Up / Page Down: scroll message history (only in chat mode)
|
||||||
|
if (appMode.mode === 'chat') {
|
||||||
|
if (key.pageUp) {
|
||||||
|
viewport.scrollBy(-viewport.viewportSize);
|
||||||
|
}
|
||||||
|
if (key.pageDown) {
|
||||||
|
viewport.scrollBy(viewport.viewportSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ctrl+T: cycle thinking level
|
||||||
|
if (key.ctrl && ch === 't') {
|
||||||
|
const levels = socket.availableThinkingLevels;
|
||||||
|
if (levels.length > 0) {
|
||||||
|
const currentIdx = levels.indexOf(socket.thinkingLevel);
|
||||||
|
const nextIdx = (currentIdx + 1) % levels.length;
|
||||||
|
const next = levels[nextIdx];
|
||||||
|
if (next) {
|
||||||
|
socket.setThinkingLevel(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Escape: return to chat from sidebar/search; in chat, scroll to bottom
|
||||||
|
if (key.escape) {
|
||||||
|
if (appMode.mode === 'search') {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'sidebar') {
|
||||||
|
appMode.setMode('chat');
|
||||||
|
} else if (appMode.mode === 'chat') {
|
||||||
|
viewport.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const modelLabel = currentModel
|
const inputPlaceholder =
|
||||||
? currentProvider
|
appMode.mode === 'sidebar'
|
||||||
? `${currentProvider}/${currentModel}`
|
? 'focus is on sidebar… press Esc to return'
|
||||||
: currentModel
|
: appMode.mode === 'search'
|
||||||
: null;
|
? 'search mode… press Esc to return'
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const isSearchMode = appMode.mode === 'search';
|
||||||
|
|
||||||
|
const messageArea = (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<MessageList
|
||||||
|
messages={socket.messages}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
currentStreamText={socket.currentStreamText}
|
||||||
|
currentThinkingText={socket.currentThinkingText}
|
||||||
|
activeToolCalls={socket.activeToolCalls}
|
||||||
|
scrollOffset={viewport.scrollOffset}
|
||||||
|
viewportSize={viewport.viewportSize}
|
||||||
|
isScrolledUp={viewport.isScrolledUp}
|
||||||
|
highlightedMessageIndices={highlightedMessageIndices}
|
||||||
|
currentHighlightIndex={currentHighlightIndex}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSearchMode && (
|
||||||
|
<SearchBar
|
||||||
|
query={search.query}
|
||||||
|
onQueryChange={search.setQuery}
|
||||||
|
totalMatches={search.totalMatches}
|
||||||
|
currentMatch={search.currentMatchIndex}
|
||||||
|
onNext={search.nextMatch}
|
||||||
|
onPrev={search.prevMatch}
|
||||||
|
onClose={() => {
|
||||||
|
search.clear();
|
||||||
|
appMode.setMode('chat');
|
||||||
|
}}
|
||||||
|
focused={isSearchMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InputBar
|
||||||
|
onSubmit={socket.sendMessage}
|
||||||
|
isStreaming={socket.isStreaming}
|
||||||
|
connected={socket.connected}
|
||||||
|
placeholder={inputPlaceholder}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" height="100%">
|
||||||
<Box marginBottom={1}>
|
<Box marginTop={1} />
|
||||||
<Text bold color="blue">
|
<TopBar
|
||||||
Mosaic
|
gatewayUrl={gatewayUrl}
|
||||||
</Text>
|
version="0.0.0"
|
||||||
<Text> </Text>
|
modelName={socket.modelName}
|
||||||
<Text dimColor>{connected ? `connected` : 'connecting...'}</Text>
|
thinkingLevel={socket.thinkingLevel}
|
||||||
{conversationId && <Text dimColor> | {conversationId.slice(0, 8)}</Text>}
|
contextWindow={socket.tokenUsage.contextWindow}
|
||||||
{modelLabel && (
|
agentName="default"
|
||||||
<>
|
connected={socket.connected}
|
||||||
<Text dimColor> | </Text>
|
connecting={socket.connecting}
|
||||||
<Text color="yellow">{modelLabel}</Text>
|
/>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
{appMode.sidebarOpen ? (
|
||||||
{messages.map((msg, i) => (
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
<Box key={i} marginBottom={1}>
|
<Sidebar
|
||||||
{msg.role === 'system' ? (
|
conversations={conversations.conversations}
|
||||||
<Text dimColor italic>
|
activeConversationId={socket.conversationId}
|
||||||
{msg.content}
|
selectedIndex={sidebarSelectedIndex}
|
||||||
</Text>
|
onSelectIndex={setSidebarSelectedIndex}
|
||||||
) : (
|
onSwitchConversation={handleSwitchConversation}
|
||||||
<>
|
onDeleteConversation={handleDeleteConversation}
|
||||||
<Text bold color={msg.role === 'user' ? 'green' : 'cyan'}>
|
loading={conversations.loading}
|
||||||
{msg.role === 'user' ? '> ' : ' '}
|
focused={appMode.mode === 'sidebar'}
|
||||||
</Text>
|
width={30}
|
||||||
<Text wrap="wrap">{msg.content}</Text>
|
/>
|
||||||
</>
|
{messageArea}
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
))}
|
<Box flexGrow={1}>{messageArea}</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{isStreaming && currentStreamText && (
|
<BottomBar
|
||||||
<Box marginBottom={1}>
|
gitInfo={gitInfo}
|
||||||
<Text bold color="cyan">
|
tokenUsage={socket.tokenUsage}
|
||||||
{' '}
|
connected={socket.connected}
|
||||||
</Text>
|
connecting={socket.connecting}
|
||||||
<Text wrap="wrap">{currentStreamText}</Text>
|
modelName={socket.modelName}
|
||||||
</Box>
|
providerName={socket.providerName}
|
||||||
)}
|
thinkingLevel={socket.thinkingLevel}
|
||||||
|
conversationId={socket.conversationId}
|
||||||
{isStreaming && !currentStreamText && (
|
/>
|
||||||
<Box>
|
|
||||||
<Text color="cyan">
|
|
||||||
<Spinner type="dots" />
|
|
||||||
</Text>
|
|
||||||
<Text dimColor> thinking...</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Text bold color="green">
|
|
||||||
{'> '}
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
placeholder={isStreaming ? 'waiting...' : 'type a message or /help'}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
packages/cli/src/tui/components/bottom-bar.tsx
Normal file
125
packages/cli/src/tui/components/bottom-bar.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import type { TokenUsage } from '../hooks/use-socket.js';
|
||||||
|
import type { GitInfo } from '../hooks/use-git-info.js';
|
||||||
|
|
||||||
|
export interface BottomBarProps {
|
||||||
|
gitInfo: GitInfo;
|
||||||
|
tokenUsage: TokenUsage;
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
modelName: string | null;
|
||||||
|
providerName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
conversationId: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact the cwd — replace home with ~ */
|
||||||
|
function compactCwd(cwd: string): string {
|
||||||
|
const home = process.env['HOME'] ?? '';
|
||||||
|
if (home && cwd.startsWith(home)) {
|
||||||
|
return '~' + cwd.slice(home.length);
|
||||||
|
}
|
||||||
|
return cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomBar({
|
||||||
|
gitInfo,
|
||||||
|
tokenUsage,
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
modelName,
|
||||||
|
providerName,
|
||||||
|
thinkingLevel,
|
||||||
|
conversationId,
|
||||||
|
}: BottomBarProps) {
|
||||||
|
const gatewayStatus = connected ? 'Connected' : connecting ? 'Connecting…' : 'Disconnected';
|
||||||
|
const gatewayColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
const hasTokens = tokenUsage.total > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" paddingX={0} marginTop={0}>
|
||||||
|
{/* Line 0: keybinding hints */}
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>^L sidebar · ^N new · ^K search · ^T thinking · PgUp/Dn scroll</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 1: blank ····· Gateway: Status */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box />
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>Gateway: </Text>
|
||||||
|
<Text color={gatewayColor}>{gatewayStatus}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 2: cwd (branch) ····· Session: id */}
|
||||||
|
<Box justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>{compactCwd(gitInfo.cwd)}</Text>
|
||||||
|
{gitInfo.branch && <Text dimColor> ({gitInfo.branch})</Text>}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
{conversationId ? `Session: ${conversationId.slice(0, 8)}` : 'No session'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Line 3: token stats ····· (provider) model */}
|
||||||
|
<Box justifyContent="space-between" minHeight={1}>
|
||||||
|
<Box>
|
||||||
|
{hasTokens ? (
|
||||||
|
<>
|
||||||
|
<Text dimColor>↑{formatTokens(tokenUsage.input)}</Text>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>↓{formatTokens(tokenUsage.output)}</Text>
|
||||||
|
{tokenUsage.cacheRead > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>R{formatTokens(tokenUsage.cacheRead)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.cacheWrite > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>W{formatTokens(tokenUsage.cacheWrite)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.cost > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>${tokenUsage.cost.toFixed(3)}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tokenUsage.contextPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Text dimColor>{' '}</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
{tokenUsage.contextPercent.toFixed(1)}%/{formatTokens(tokenUsage.contextWindow)}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>↑0 ↓0 $0.000</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text dimColor>
|
||||||
|
{providerName ? `(${providerName}) ` : ''}
|
||||||
|
{modelName ?? 'awaiting model'}
|
||||||
|
{thinkingLevel !== 'off' ? ` • ${thinkingLevel}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
packages/cli/src/tui/components/input-bar.tsx
Normal file
50
packages/cli/src/tui/components/input-bar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
|
||||||
|
export interface InputBarProps {
|
||||||
|
onSubmit: (value: string) => void;
|
||||||
|
isStreaming: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InputBar({
|
||||||
|
onSubmit,
|
||||||
|
isStreaming,
|
||||||
|
connected,
|
||||||
|
placeholder: placeholderOverride,
|
||||||
|
}: InputBarProps) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (!value.trim() || isStreaming || !connected) return;
|
||||||
|
onSubmit(value);
|
||||||
|
setInput('');
|
||||||
|
},
|
||||||
|
[onSubmit, isStreaming, connected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const placeholder =
|
||||||
|
placeholderOverride ??
|
||||||
|
(!connected
|
||||||
|
? 'disconnected — waiting for gateway…'
|
||||||
|
: isStreaming
|
||||||
|
? 'waiting for response…'
|
||||||
|
: 'message mosaic…');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box paddingX={1} borderStyle="single" borderColor="gray">
|
||||||
|
<Text bold color="green">
|
||||||
|
{'❯ '}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
packages/cli/src/tui/components/message-list.tsx
Normal file
177
packages/cli/src/tui/components/message-list.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import Spinner from 'ink-spinner';
|
||||||
|
import type { Message, ToolCall } from '../hooks/use-socket.js';
|
||||||
|
|
||||||
|
export interface MessageListProps {
|
||||||
|
messages: Message[];
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentStreamText: string;
|
||||||
|
currentThinkingText: string;
|
||||||
|
activeToolCalls: ToolCall[];
|
||||||
|
scrollOffset?: number;
|
||||||
|
viewportSize?: number;
|
||||||
|
isScrolledUp?: boolean;
|
||||||
|
highlightedMessageIndices?: Set<number>;
|
||||||
|
currentHighlightIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageBubble({
|
||||||
|
msg,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
msg: Message;
|
||||||
|
highlight?: 'match' | 'current' | undefined;
|
||||||
|
}) {
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
|
const prefix = isUser ? '❯' : '◆';
|
||||||
|
const color = isUser ? 'green' : 'cyan';
|
||||||
|
|
||||||
|
const borderIndicator =
|
||||||
|
highlight === 'current' ? (
|
||||||
|
<Text color="yellowBright" bold>
|
||||||
|
▌{' '}
|
||||||
|
</Text>
|
||||||
|
) : highlight === 'match' ? (
|
||||||
|
<Text color="yellow">▌ </Text>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
{borderIndicator}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text bold color={color}>
|
||||||
|
{prefix}{' '}
|
||||||
|
</Text>
|
||||||
|
<Text bold color={color}>
|
||||||
|
{isUser ? 'you' : 'assistant'}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> {formatTime(msg.timestamp)}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text wrap="wrap">{msg.content}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallIndicator({ toolCall }: { toolCall: ToolCall }) {
|
||||||
|
const icon = toolCall.status === 'running' ? null : toolCall.status === 'success' ? '✓' : '✗';
|
||||||
|
const color =
|
||||||
|
toolCall.status === 'running' ? 'yellow' : toolCall.status === 'success' ? 'green' : 'red';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
{toolCall.status === 'running' ? (
|
||||||
|
<Text color="yellow">
|
||||||
|
<Spinner type="dots" />
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={color}>{icon}</Text>
|
||||||
|
)}
|
||||||
|
<Text dimColor> tool: </Text>
|
||||||
|
<Text color={color}>{toolCall.toolName}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageList({
|
||||||
|
messages,
|
||||||
|
isStreaming,
|
||||||
|
currentStreamText,
|
||||||
|
currentThinkingText,
|
||||||
|
activeToolCalls,
|
||||||
|
scrollOffset,
|
||||||
|
viewportSize,
|
||||||
|
isScrolledUp,
|
||||||
|
highlightedMessageIndices,
|
||||||
|
currentHighlightIndex,
|
||||||
|
}: MessageListProps) {
|
||||||
|
const useSlicing = scrollOffset != null && viewportSize != null;
|
||||||
|
const visibleMessages = useSlicing
|
||||||
|
? messages.slice(scrollOffset, scrollOffset + viewportSize)
|
||||||
|
: messages;
|
||||||
|
const hiddenAbove = useSlicing ? scrollOffset : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1} paddingX={1}>
|
||||||
|
{isScrolledUp && hiddenAbove > 0 && (
|
||||||
|
<Box justifyContent="center">
|
||||||
|
<Text dimColor>↑ {hiddenAbove} more messages ↑</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.length === 0 && !isStreaming && (
|
||||||
|
<Box justifyContent="center" marginY={1}>
|
||||||
|
<Text dimColor>No messages yet. Type below to start a conversation.</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleMessages.map((msg, i) => {
|
||||||
|
const globalIndex = hiddenAbove + i;
|
||||||
|
const highlight =
|
||||||
|
globalIndex === currentHighlightIndex
|
||||||
|
? ('current' as const)
|
||||||
|
: highlightedMessageIndices?.has(globalIndex)
|
||||||
|
? ('match' as const)
|
||||||
|
: undefined;
|
||||||
|
return <MessageBubble key={globalIndex} msg={msg} highlight={highlight} />;
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Active thinking */}
|
||||||
|
{isStreaming && currentThinkingText && (
|
||||||
|
<Box flexDirection="column" marginBottom={1} marginLeft={2}>
|
||||||
|
<Text dimColor italic>
|
||||||
|
💭 {currentThinkingText}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active tool calls */}
|
||||||
|
{activeToolCalls.length > 0 && (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
{activeToolCalls.map((tc) => (
|
||||||
|
<ToolCallIndicator key={tc.toolCallId} toolCall={tc} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Streaming response */}
|
||||||
|
{isStreaming && currentStreamText && (
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
◆{' '}
|
||||||
|
</Text>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
assistant
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text wrap="wrap">{currentStreamText}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting spinner */}
|
||||||
|
{isStreaming && !currentStreamText && activeToolCalls.length === 0 && (
|
||||||
|
<Box marginLeft={2}>
|
||||||
|
<Text color="cyan">
|
||||||
|
<Spinner type="dots" />
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> thinking…</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
packages/cli/src/tui/components/search-bar.tsx
Normal file
60
packages/cli/src/tui/components/search-bar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
|
|
||||||
|
export interface SearchBarProps {
|
||||||
|
query: string;
|
||||||
|
onQueryChange: (q: string) => void;
|
||||||
|
totalMatches: number;
|
||||||
|
currentMatch: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
totalMatches,
|
||||||
|
currentMatch,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onClose,
|
||||||
|
focused,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.upArrow) {
|
||||||
|
onPrev();
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
if (key.escape) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = focused ? 'yellow' : 'gray';
|
||||||
|
|
||||||
|
const matchDisplay =
|
||||||
|
query.length >= 2
|
||||||
|
? totalMatches > 0
|
||||||
|
? `${String(currentMatch + 1)}/${String(totalMatches)}`
|
||||||
|
: 'no matches'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box borderStyle="round" borderColor={borderColor} paddingX={1} flexDirection="row" gap={1}>
|
||||||
|
<Text>🔍</Text>
|
||||||
|
<Box flexGrow={1}>
|
||||||
|
<TextInput value={query} onChange={onQueryChange} focus={focused} />
|
||||||
|
</Box>
|
||||||
|
{matchDisplay && <Text dimColor>{matchDisplay}</Text>}
|
||||||
|
<Text dimColor>↑↓ navigate · Esc close</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
143
packages/cli/src/tui/components/sidebar.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import type { ConversationSummary } from '../hooks/use-conversations.js';
|
||||||
|
|
||||||
|
export interface SidebarProps {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
activeConversationId: string | undefined;
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelectIndex: (index: number) => void;
|
||||||
|
onSwitchConversation: (id: string) => void;
|
||||||
|
onDeleteConversation: (id: string) => void;
|
||||||
|
loading: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) {
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
if (diffDays < 7) {
|
||||||
|
return `${diffDays}d ago`;
|
||||||
|
}
|
||||||
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
];
|
||||||
|
const mon = months[date.getMonth()];
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${mon} ${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, maxLen: number): string {
|
||||||
|
if (text.length <= maxLen) return text;
|
||||||
|
return text.slice(0, maxLen - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
conversations,
|
||||||
|
activeConversationId,
|
||||||
|
selectedIndex,
|
||||||
|
onSelectIndex,
|
||||||
|
onSwitchConversation,
|
||||||
|
onDeleteConversation,
|
||||||
|
loading,
|
||||||
|
focused,
|
||||||
|
width,
|
||||||
|
}: SidebarProps) {
|
||||||
|
useInput(
|
||||||
|
(_input, key) => {
|
||||||
|
if (key.upArrow) {
|
||||||
|
onSelectIndex(Math.max(0, selectedIndex - 1));
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
onSelectIndex(Math.min(conversations.length - 1, selectedIndex + 1));
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
const conv = conversations[selectedIndex];
|
||||||
|
if (conv) {
|
||||||
|
onSwitchConversation(conv.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_input === 'd') {
|
||||||
|
const conv = conversations[selectedIndex];
|
||||||
|
if (conv) {
|
||||||
|
onDeleteConversation(conv.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: focused },
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = focused ? 'cyan' : 'gray';
|
||||||
|
// Available width for content inside border + padding
|
||||||
|
const innerWidth = width - 4; // 2 border + 2 padding
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
width={width}
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
Conversations
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={0} flexDirection="column" flexGrow={1}>
|
||||||
|
{loading && conversations.length === 0 ? (
|
||||||
|
<Text dimColor>Loading…</Text>
|
||||||
|
) : conversations.length === 0 ? (
|
||||||
|
<Text dimColor>No conversations</Text>
|
||||||
|
) : (
|
||||||
|
conversations.map((conv, idx) => {
|
||||||
|
const isActive = conv.id === activeConversationId;
|
||||||
|
const isSelected = idx === selectedIndex && focused;
|
||||||
|
const marker = isActive ? '● ' : ' ';
|
||||||
|
const time = formatRelativeTime(conv.updatedAt);
|
||||||
|
const title = conv.title ?? 'Untitled';
|
||||||
|
// marker(2) + title + space(1) + time
|
||||||
|
const maxTitleLen = Math.max(4, innerWidth - marker.length - time.length - 1);
|
||||||
|
const displayTitle = truncate(title, maxTitleLen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={conv.id}>
|
||||||
|
<Text
|
||||||
|
inverse={isSelected}
|
||||||
|
color={isActive ? 'cyan' : undefined}
|
||||||
|
dimColor={!isActive && !isSelected}
|
||||||
|
>
|
||||||
|
{marker}
|
||||||
|
{displayTitle}
|
||||||
|
{' '.repeat(
|
||||||
|
Math.max(0, innerWidth - marker.length - displayTitle.length - time.length),
|
||||||
|
)}
|
||||||
|
{time}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{focused && <Text dimColor>↑↓ navigate • enter switch • d delete</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
packages/cli/src/tui/components/top-bar.tsx
Normal file
99
packages/cli/src/tui/components/top-bar.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
|
||||||
|
export interface TopBarProps {
|
||||||
|
gatewayUrl: string;
|
||||||
|
version: string;
|
||||||
|
modelName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
contextWindow: number;
|
||||||
|
agentName: string;
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact the URL — strip protocol */
|
||||||
|
function compactHost(url: string): string {
|
||||||
|
return url.replace(/^https?:\/\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatContextWindow(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mosaic 3×3 icon — brand tiles with black gaps (windmill cross pattern)
|
||||||
|
*
|
||||||
|
* Layout:
|
||||||
|
* blue ·· purple
|
||||||
|
* ·· pink ··
|
||||||
|
* amber ·· teal
|
||||||
|
*/
|
||||||
|
// Two-space gap between tiles (extracted to avoid prettier collapse)
|
||||||
|
const GAP = ' ';
|
||||||
|
|
||||||
|
function MosaicIcon() {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" marginRight={2}>
|
||||||
|
<Text>
|
||||||
|
<Text color="#2f80ff">██</Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#8b5cf6">██</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#ec4899">██</Text>
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color="#f59e0b">██</Text>
|
||||||
|
<Text>{GAP}</Text>
|
||||||
|
<Text color="#14b8a6">██</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({
|
||||||
|
gatewayUrl,
|
||||||
|
version,
|
||||||
|
modelName,
|
||||||
|
thinkingLevel,
|
||||||
|
contextWindow,
|
||||||
|
agentName,
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
}: TopBarProps) {
|
||||||
|
const host = compactHost(gatewayUrl);
|
||||||
|
const connectionIndicator = connected ? '●' : '○';
|
||||||
|
const connectionColor = connected ? 'green' : connecting ? 'yellow' : 'red';
|
||||||
|
|
||||||
|
// Build model description line like: "claude-opus-4-6 (1M context) · default"
|
||||||
|
const modelDisplay = modelName ?? 'awaiting model';
|
||||||
|
const contextStr = contextWindow > 0 ? ` (${formatContextWindow(contextWindow)} context)` : '';
|
||||||
|
const thinkingStr = thinkingLevel !== 'off' ? ` · ${thinkingLevel}` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box paddingX={1} paddingY={0} marginBottom={1}>
|
||||||
|
<MosaicIcon />
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
<Text>
|
||||||
|
<Text bold color="#56a0ff">
|
||||||
|
Mosaic Stack
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> v{version}</Text>
|
||||||
|
</Text>
|
||||||
|
<Text dimColor>
|
||||||
|
{modelDisplay}
|
||||||
|
{contextStr}
|
||||||
|
{thinkingStr} · {agentName}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<Text color={connectionColor}>{connectionIndicator}</Text>
|
||||||
|
<Text dimColor> {host}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
37
packages/cli/src/tui/hooks/use-app-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type AppMode = 'chat' | 'sidebar' | 'search';
|
||||||
|
|
||||||
|
export interface UseAppModeReturn {
|
||||||
|
mode: AppMode;
|
||||||
|
setMode: (mode: AppMode) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppMode(): UseAppModeReturn {
|
||||||
|
const [mode, setModeState] = useState<AppMode>('chat');
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const setMode = useCallback((next: AppMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
if (next === 'sidebar') {
|
||||||
|
setSidebarOpen(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setSidebarOpen((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
// Closing sidebar — return to chat
|
||||||
|
setModeState('chat');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opening sidebar — set mode to sidebar
|
||||||
|
setModeState('sidebar');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { mode, setMode, toggleSidebar, sidebarOpen };
|
||||||
|
}
|
||||||
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
139
packages/cli/src/tui/hooks/use-conversations.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
archived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationsOptions {
|
||||||
|
gatewayUrl: string;
|
||||||
|
sessionCookie?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseConversationsReturn {
|
||||||
|
conversations: ConversationSummary[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
createConversation: (title?: string) => Promise<ConversationSummary | null>;
|
||||||
|
deleteConversation: (id: string) => Promise<boolean>;
|
||||||
|
renameConversation: (id: string, title: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConversations(opts: UseConversationsOptions): UseConversationsReturn {
|
||||||
|
const { gatewayUrl, sessionCookie } = opts;
|
||||||
|
|
||||||
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
const headers = useCallback((): Record<string, string> => {
|
||||||
|
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (sessionCookie) h['Cookie'] = sessionCookie;
|
||||||
|
return h;
|
||||||
|
}, [sessionCookie]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, { headers: headers() });
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as ConversationSummary[];
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [gatewayUrl, headers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
void refresh();
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const createConversation = useCallback(
|
||||||
|
async (title?: string): Promise<ConversationSummary | null> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ title: title ?? null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
const data = (await res.json()) as ConversationSummary;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => [data, ...prev]);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteConversation = useCallback(
|
||||||
|
async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: headers(),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => prev.filter((c) => c.id !== id));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renameConversation = useCallback(
|
||||||
|
async (id: string, title: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${gatewayUrl}/api/conversations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: headers(),
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, title } : c)));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[gatewayUrl, headers],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversations,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
createConversation,
|
||||||
|
deleteConversation,
|
||||||
|
renameConversation,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal file
29
packages/cli/src/tui/hooks/use-git-info.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
|
||||||
|
export interface GitInfo {
|
||||||
|
branch: string | null;
|
||||||
|
cwd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGitInfo(): GitInfo {
|
||||||
|
const [info, setInfo] = useState<GitInfo>({
|
||||||
|
branch: null,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: 3000,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
setInfo({ branch, cwd: process.cwd() });
|
||||||
|
} catch {
|
||||||
|
setInfo({ branch: null, cwd: process.cwd() });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
76
packages/cli/src/tui/hooks/use-search.ts
Normal file
76
packages/cli/src/tui/hooks/use-search.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import type { Message } from './use-socket.js';
|
||||||
|
|
||||||
|
export interface SearchMatch {
|
||||||
|
messageIndex: number;
|
||||||
|
charOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSearchReturn {
|
||||||
|
query: string;
|
||||||
|
setQuery: (q: string) => void;
|
||||||
|
matches: SearchMatch[];
|
||||||
|
currentMatchIndex: number;
|
||||||
|
nextMatch: () => void;
|
||||||
|
prevMatch: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
totalMatches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearch(messages: Message[]): UseSearchReturn {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
|
||||||
|
|
||||||
|
const matches = useMemo<SearchMatch[]>(() => {
|
||||||
|
if (query.length < 2) return [];
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
const result: SearchMatch[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
if (!msg) continue;
|
||||||
|
const content = msg.content.toLowerCase();
|
||||||
|
let offset = 0;
|
||||||
|
while (true) {
|
||||||
|
const idx = content.indexOf(lowerQuery, offset);
|
||||||
|
if (idx === -1) break;
|
||||||
|
result.push({ messageIndex: i, charOffset: idx });
|
||||||
|
offset = idx + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [query, messages]);
|
||||||
|
|
||||||
|
// Reset match index when matches change
|
||||||
|
useMemo(() => {
|
||||||
|
setCurrentMatchIndex(0);
|
||||||
|
}, [matches]);
|
||||||
|
|
||||||
|
const nextMatch = useCallback(() => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev + 1) % matches.length);
|
||||||
|
}, [matches.length]);
|
||||||
|
|
||||||
|
const prevMatch = useCallback(() => {
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
setCurrentMatchIndex((prev) => (prev - 1 + matches.length) % matches.length);
|
||||||
|
}, [matches.length]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setQuery('');
|
||||||
|
setCurrentMatchIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
matches,
|
||||||
|
currentMatchIndex,
|
||||||
|
nextMatch,
|
||||||
|
prevMatch,
|
||||||
|
clear,
|
||||||
|
totalMatches: matches.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
284
packages/cli/src/tui/hooks/use-socket.ts
Normal file
284
packages/cli/src/tui/hooks/use-socket.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
MessageAckPayload,
|
||||||
|
AgentEndPayload,
|
||||||
|
AgentTextPayload,
|
||||||
|
AgentThinkingPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
SessionInfoPayload,
|
||||||
|
ErrorPayload,
|
||||||
|
} from '@mosaic/types';
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
status: 'running' | 'success' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
role: 'user' | 'assistant' | 'thinking' | 'tool';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
total: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
cost: number;
|
||||||
|
contextPercent: number;
|
||||||
|
contextWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSocketOptions {
|
||||||
|
gatewayUrl: string;
|
||||||
|
sessionCookie?: string;
|
||||||
|
initialConversationId?: string;
|
||||||
|
initialModel?: string;
|
||||||
|
initialProvider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSocketReturn {
|
||||||
|
connected: boolean;
|
||||||
|
connecting: boolean;
|
||||||
|
messages: Message[];
|
||||||
|
conversationId: string | undefined;
|
||||||
|
isStreaming: boolean;
|
||||||
|
currentStreamText: string;
|
||||||
|
currentThinkingText: string;
|
||||||
|
activeToolCalls: ToolCall[];
|
||||||
|
tokenUsage: TokenUsage;
|
||||||
|
modelName: string | null;
|
||||||
|
providerName: string | null;
|
||||||
|
thinkingLevel: string;
|
||||||
|
availableThinkingLevels: string[];
|
||||||
|
sendMessage: (content: string) => void;
|
||||||
|
setThinkingLevel: (level: string) => void;
|
||||||
|
switchConversation: (id: string) => void;
|
||||||
|
clearMessages: () => void;
|
||||||
|
connectionError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
|
const EMPTY_USAGE: TokenUsage = {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
total: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
cost: 0,
|
||||||
|
contextPercent: 0,
|
||||||
|
contextWindow: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSocket(opts: UseSocketOptions): UseSocketReturn {
|
||||||
|
const { gatewayUrl, sessionCookie, initialConversationId, initialModel, initialProvider } = opts;
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [connecting, setConnecting] = useState(true);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [conversationId, setConversationId] = useState(initialConversationId);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [currentStreamText, setCurrentStreamText] = useState('');
|
||||||
|
const [currentThinkingText, setCurrentThinkingText] = useState('');
|
||||||
|
const [activeToolCalls, setActiveToolCalls] = useState<ToolCall[]>([]);
|
||||||
|
const [tokenUsage, setTokenUsage] = useState<TokenUsage>(EMPTY_USAGE);
|
||||||
|
const [modelName, setModelName] = useState<string | null>(null);
|
||||||
|
const [providerName, setProviderName] = useState<string | null>(null);
|
||||||
|
const [thinkingLevel, setThinkingLevelState] = useState<string>('off');
|
||||||
|
const [availableThinkingLevels, setAvailableThinkingLevels] = useState<string[]>([]);
|
||||||
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const socketRef = useRef<TypedSocket | null>(null);
|
||||||
|
const conversationIdRef = useRef(conversationId);
|
||||||
|
conversationIdRef.current = conversationId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const socket = io(`${gatewayUrl}/chat`, {
|
||||||
|
transports: ['websocket'],
|
||||||
|
extraHeaders: sessionCookie ? { Cookie: sessionCookie } : undefined,
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 2000,
|
||||||
|
reconnectionAttempts: Infinity,
|
||||||
|
}) as TypedSocket;
|
||||||
|
|
||||||
|
socketRef.current = socket;
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setConnected(true);
|
||||||
|
setConnecting(false);
|
||||||
|
setConnectionError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setConnected(false);
|
||||||
|
setIsStreaming(false);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.io.on('error', (err: Error) => {
|
||||||
|
setConnecting(false);
|
||||||
|
setConnectionError(err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message:ack', (data: MessageAckPayload) => {
|
||||||
|
setConversationId(data.conversationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('session:info', (data: SessionInfoPayload) => {
|
||||||
|
setProviderName(data.provider);
|
||||||
|
setModelName(data.modelId);
|
||||||
|
setThinkingLevelState(data.thinkingLevel);
|
||||||
|
setAvailableThinkingLevels(data.availableThinkingLevels);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:start', () => {
|
||||||
|
setIsStreaming(true);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:text', (data: AgentTextPayload) => {
|
||||||
|
setCurrentStreamText((prev) => prev + data.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:thinking', (data: AgentThinkingPayload) => {
|
||||||
|
setCurrentThinkingText((prev) => prev + data.text);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:tool:start', (data: ToolStartPayload) => {
|
||||||
|
setActiveToolCalls((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ toolCallId: data.toolCallId, toolName: data.toolName, status: 'running' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:tool:end', (data: ToolEndPayload) => {
|
||||||
|
setActiveToolCalls((prev) =>
|
||||||
|
prev.map((tc) =>
|
||||||
|
tc.toolCallId === data.toolCallId
|
||||||
|
? { ...tc, status: data.isError ? 'error' : 'success' }
|
||||||
|
: tc,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('agent:end', (data: AgentEndPayload) => {
|
||||||
|
setCurrentStreamText((prev) => {
|
||||||
|
if (prev) {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'assistant', content: prev, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
|
||||||
|
// Update usage from the payload
|
||||||
|
if (data.usage) {
|
||||||
|
setProviderName(data.usage.provider);
|
||||||
|
setModelName(data.usage.modelId);
|
||||||
|
setThinkingLevelState(data.usage.thinkingLevel);
|
||||||
|
setTokenUsage({
|
||||||
|
input: data.usage.tokens.input,
|
||||||
|
output: data.usage.tokens.output,
|
||||||
|
total: data.usage.tokens.total,
|
||||||
|
cacheRead: data.usage.tokens.cacheRead,
|
||||||
|
cacheWrite: data.usage.tokens.cacheWrite,
|
||||||
|
cost: data.usage.cost,
|
||||||
|
contextPercent: data.usage.context.percent ?? 0,
|
||||||
|
contextWindow: data.usage.context.window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (data: ErrorPayload) => {
|
||||||
|
setMessages((msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{ role: 'assistant', content: `Error: ${data.error}`, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.disconnect();
|
||||||
|
};
|
||||||
|
}, [gatewayUrl, sessionCookie]);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
if (!content.trim() || isStreaming) return;
|
||||||
|
if (!socketRef.current?.connected) return;
|
||||||
|
|
||||||
|
setMessages((msgs) => [...msgs, { role: 'user', content, timestamp: new Date() }]);
|
||||||
|
|
||||||
|
socketRef.current.emit('message', {
|
||||||
|
conversationId,
|
||||||
|
content,
|
||||||
|
...(initialProvider ? { provider: initialProvider } : {}),
|
||||||
|
...(initialModel ? { modelId: initialModel } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[conversationId, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setThinkingLevel = useCallback((level: string) => {
|
||||||
|
const cid = conversationIdRef.current;
|
||||||
|
if (!socketRef.current?.connected || !cid) return;
|
||||||
|
socketRef.current.emit('set:thinking', {
|
||||||
|
conversationId: cid,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearMessages = useCallback(() => {
|
||||||
|
setMessages([]);
|
||||||
|
setCurrentStreamText('');
|
||||||
|
setCurrentThinkingText('');
|
||||||
|
setActiveToolCalls([]);
|
||||||
|
setIsStreaming(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const switchConversation = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
clearMessages();
|
||||||
|
setConversationId(id);
|
||||||
|
},
|
||||||
|
[clearMessages],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
connecting,
|
||||||
|
messages,
|
||||||
|
conversationId,
|
||||||
|
isStreaming,
|
||||||
|
currentStreamText,
|
||||||
|
currentThinkingText,
|
||||||
|
activeToolCalls,
|
||||||
|
tokenUsage,
|
||||||
|
modelName,
|
||||||
|
providerName,
|
||||||
|
thinkingLevel,
|
||||||
|
availableThinkingLevels,
|
||||||
|
sendMessage,
|
||||||
|
setThinkingLevel,
|
||||||
|
switchConversation,
|
||||||
|
clearMessages,
|
||||||
|
connectionError,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal file
80
packages/cli/src/tui/hooks/use-viewport.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useStdout } from 'ink';
|
||||||
|
|
||||||
|
export interface UseViewportOptions {
|
||||||
|
totalItems: number;
|
||||||
|
reservedLines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseViewportReturn {
|
||||||
|
scrollOffset: number;
|
||||||
|
viewportSize: number;
|
||||||
|
isScrolledUp: boolean;
|
||||||
|
scrollToBottom: () => void;
|
||||||
|
scrollBy: (delta: number) => void;
|
||||||
|
scrollTo: (offset: number) => void;
|
||||||
|
canScrollUp: boolean;
|
||||||
|
canScrollDown: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewport({
|
||||||
|
totalItems,
|
||||||
|
reservedLines = 10,
|
||||||
|
}: UseViewportOptions): UseViewportReturn {
|
||||||
|
const { stdout } = useStdout();
|
||||||
|
const rows = stdout?.rows ?? 24;
|
||||||
|
const viewportSize = Math.max(1, rows - reservedLines);
|
||||||
|
|
||||||
|
const [scrollOffset, setScrollOffset] = useState(0);
|
||||||
|
const [autoFollow, setAutoFollow] = useState(true);
|
||||||
|
|
||||||
|
// Compute the maximum valid scroll offset
|
||||||
|
const maxOffset = Math.max(0, totalItems - viewportSize);
|
||||||
|
|
||||||
|
// Auto-follow: when new items arrive and auto-follow is on, snap to bottom
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFollow) {
|
||||||
|
setScrollOffset(maxOffset);
|
||||||
|
}
|
||||||
|
}, [autoFollow, maxOffset]);
|
||||||
|
|
||||||
|
const scrollTo = useCallback(
|
||||||
|
(offset: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(offset, maxOffset));
|
||||||
|
setScrollOffset(clamped);
|
||||||
|
setAutoFollow(clamped >= maxOffset);
|
||||||
|
},
|
||||||
|
[maxOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollBy = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
setScrollOffset((prev) => {
|
||||||
|
const next = Math.max(0, Math.min(prev + delta, maxOffset));
|
||||||
|
setAutoFollow(next >= maxOffset);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[maxOffset],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
setScrollOffset(maxOffset);
|
||||||
|
setAutoFollow(true);
|
||||||
|
}, [maxOffset]);
|
||||||
|
|
||||||
|
const isScrolledUp = scrollOffset < maxOffset;
|
||||||
|
const canScrollUp = scrollOffset > 0;
|
||||||
|
const canScrollDown = scrollOffset < maxOffset;
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollOffset,
|
||||||
|
viewportSize,
|
||||||
|
isScrolledUp,
|
||||||
|
scrollToBottom,
|
||||||
|
scrollBy,
|
||||||
|
scrollTo,
|
||||||
|
canScrollUp,
|
||||||
|
canScrollDown,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,26 @@ export interface AgentStartPayload {
|
|||||||
|
|
||||||
export interface AgentEndPayload {
|
export interface AgentEndPayload {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
usage?: SessionUsagePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session metadata emitted with agent:end and on session:info */
|
||||||
|
export interface SessionUsagePayload {
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
tokens: {
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
cacheRead: number;
|
||||||
|
cacheWrite: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
cost: number;
|
||||||
|
context: {
|
||||||
|
percent: number | null;
|
||||||
|
window: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentTextPayload {
|
export interface AgentTextPayload {
|
||||||
@@ -42,6 +62,23 @@ export interface ErrorPayload {
|
|||||||
export interface ChatMessagePayload {
|
export interface ChatMessagePayload {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
provider?: string;
|
||||||
|
modelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session info pushed when session is created or model changes */
|
||||||
|
export interface SessionInfoPayload {
|
||||||
|
conversationId: string;
|
||||||
|
provider: string;
|
||||||
|
modelId: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
availableThinkingLevels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client request to change thinking level */
|
||||||
|
export interface SetThinkingPayload {
|
||||||
|
conversationId: string;
|
||||||
|
level: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Socket.IO typed event map: server → client */
|
/** Socket.IO typed event map: server → client */
|
||||||
@@ -53,10 +90,12 @@ export interface ServerToClientEvents {
|
|||||||
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
'agent:thinking': (payload: AgentThinkingPayload) => void;
|
||||||
'agent:tool:start': (payload: ToolStartPayload) => void;
|
'agent:tool:start': (payload: ToolStartPayload) => void;
|
||||||
'agent:tool:end': (payload: ToolEndPayload) => void;
|
'agent:tool:end': (payload: ToolEndPayload) => void;
|
||||||
|
'session:info': (payload: SessionInfoPayload) => void;
|
||||||
error: (payload: ErrorPayload) => void;
|
error: (payload: ErrorPayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Socket.IO typed event map: client → server */
|
/** Socket.IO typed event map: client → server */
|
||||||
export interface ClientToServerEvents {
|
export interface ClientToServerEvents {
|
||||||
message: (data: ChatMessagePayload) => void;
|
message: (data: ChatMessagePayload) => void;
|
||||||
|
'set:thinking': (data: SetThinkingPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export type {
|
|||||||
AgentThinkingPayload,
|
AgentThinkingPayload,
|
||||||
ToolStartPayload,
|
ToolStartPayload,
|
||||||
ToolEndPayload,
|
ToolEndPayload,
|
||||||
|
SessionUsagePayload,
|
||||||
|
SessionInfoPayload,
|
||||||
|
SetThinkingPayload,
|
||||||
ErrorPayload,
|
ErrorPayload,
|
||||||
ChatMessagePayload,
|
ChatMessagePayload,
|
||||||
ServerToClientEvents,
|
ServerToClientEvents,
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -295,6 +295,9 @@ importers:
|
|||||||
'@mosaic/quality-rails':
|
'@mosaic/quality-rails':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../quality-rails
|
version: link:../quality-rails
|
||||||
|
'@mosaic/types':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../types
|
||||||
commander:
|
commander:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.1.0
|
version: 13.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user