quoxcli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatView — Full-screen chat with CommanderQ (and any org agent)
|
|
3
|
+
*
|
|
4
|
+
* Uses FullLayout (single panel, full width) instead of the standard two-panel layout.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Real input line: left/right cursor movement, Ctrl+A/Ctrl+E (Home/End),
|
|
8
|
+
* backspace/delete at the caret, and Up/Down history recall of
|
|
9
|
+
* previously-sent messages
|
|
10
|
+
* - Agent picker: Tab (with an empty input box) opens a picker of the
|
|
11
|
+
* org's agents fetched from GET /api/orgs/:orgId/agents; Tab/↑↓ move,
|
|
12
|
+
* Enter confirms. Typing `@name ` at the start of a message routes
|
|
13
|
+
* just that message to `name` without changing the sticky selection.
|
|
14
|
+
* - Scrollback: PgUp/PgDn (and Ctrl+U/Ctrl+D) scroll a viewport window
|
|
15
|
+
* over the message list, auto-sticking to the newest messages unless
|
|
16
|
+
* the user has scrolled up
|
|
17
|
+
* - Message history scrolls up
|
|
18
|
+
* - Streams from POST /chat/stream (SSE, prompt in the body) on the collector service — same
|
|
19
|
+
* wire contract as `quox chat` (see lib/commands/chat.js buildStreamQuery)
|
|
20
|
+
* - Shows the assistant reply growing token-by-token, with a live
|
|
21
|
+
* thinking/tool-call status line beneath it
|
|
22
|
+
* - Keeps conversation_id (session_id) for continuity
|
|
23
|
+
* - Enter sends message, Esc goes back to menu
|
|
24
|
+
*
|
|
25
|
+
* API:
|
|
26
|
+
* POST /chat/stream (service: collector, body: { query_id, prompt, assistant, session_id? })
|
|
27
|
+
* GET /api/orgs/:orgId/agents (service: auth) -> data.agents
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
|
31
|
+
import { Box, Text, useInput } from 'ink';
|
|
32
|
+
import { FullLayout } from '../components/Layout.jsx';
|
|
33
|
+
import { ChatInput } from '../components/ChatInput.jsx';
|
|
34
|
+
import { useTui } from '../app.jsx';
|
|
35
|
+
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
|
36
|
+
import { colors } from '../theme.js';
|
|
37
|
+
import { resolveEndpoint } from '../../api/endpoints.js';
|
|
38
|
+
|
|
39
|
+
// Batch token appends into React state on this interval instead of a
|
|
40
|
+
// setState-per-chunk, which would cause Ink to re-render (and flicker)
|
|
41
|
+
// the whole frame for every token on long replies.
|
|
42
|
+
const FLUSH_INTERVAL_MS = 50;
|
|
43
|
+
|
|
44
|
+
// Rows reserved by chrome around the scrollable message list: header (1) +
|
|
45
|
+
// separator (1) + footer (1) + input box (3, incl. borders) + a little
|
|
46
|
+
// breathing room for the streaming status line.
|
|
47
|
+
const RESERVED_ROWS = 7;
|
|
48
|
+
const MIN_VIEWPORT = 3;
|
|
49
|
+
|
|
50
|
+
function getOrgId(ctx) {
|
|
51
|
+
return ctx?.auth?.getUser?.()?.org_id || ctx?.config?.getActiveProfile?.()?.auth?.org_id || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Component ─────────────────────────────────────────────────────────
|
|
55
|
+
export function ChatView() {
|
|
56
|
+
const ctx = useTui();
|
|
57
|
+
const { client, screen, setInputMode } = ctx;
|
|
58
|
+
const isActive = screen === 'chat';
|
|
59
|
+
const orgId = getOrgId(ctx);
|
|
60
|
+
const { rows } = useTerminalSize();
|
|
61
|
+
|
|
62
|
+
const [messages, setMessages] = useState([]);
|
|
63
|
+
const [input, setInput] = useState('');
|
|
64
|
+
const [cursor, setCursor] = useState(0);
|
|
65
|
+
const [conversationId, setConversationId] = useState(null);
|
|
66
|
+
|
|
67
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
68
|
+
const [streamText, setStreamText] = useState('');
|
|
69
|
+
const [streamStatus, setStreamStatus] = useState('');
|
|
70
|
+
const [streamLabel, setStreamLabel] = useState('');
|
|
71
|
+
|
|
72
|
+
// ── Sent-message history (Up/Down recall) ───────────────────────────
|
|
73
|
+
const [history, setHistory] = useState([]);
|
|
74
|
+
const [historyIndex, setHistoryIndex] = useState(null);
|
|
75
|
+
const draftRef = useRef('');
|
|
76
|
+
|
|
77
|
+
// ── Agent picker (Tab) + `@name ` per-message routing ───────────────
|
|
78
|
+
const [agent, setAgent] = useState(null);
|
|
79
|
+
const [agents, setAgents] = useState([]);
|
|
80
|
+
const [agentsLoaded, setAgentsLoaded] = useState(false);
|
|
81
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
82
|
+
const [pickerIndex, setPickerIndex] = useState(0);
|
|
83
|
+
|
|
84
|
+
// ── Scrollback viewport ──────────────────────────────────────────────
|
|
85
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
86
|
+
const stickRef = useRef(true);
|
|
87
|
+
const prevMsgLenRef = useRef(0);
|
|
88
|
+
const viewportSize = Math.max(MIN_VIEWPORT, rows - RESERVED_ROWS);
|
|
89
|
+
|
|
90
|
+
// Refs used by the streaming loop — not React state, so they can be
|
|
91
|
+
// mutated freely between throttled flushes without triggering renders.
|
|
92
|
+
const mountedRef = useRef(true);
|
|
93
|
+
const abortRef = useRef(null);
|
|
94
|
+
const flushTimerRef = useRef(null);
|
|
95
|
+
const fullTextRef = useRef('');
|
|
96
|
+
// Mirror of conversationId so sendMessage always reads the latest session id
|
|
97
|
+
// from a ref, not a possibly-stale useCallback closure — otherwise two sends
|
|
98
|
+
// in quick succession (before React re-renders) can drop session continuity.
|
|
99
|
+
const conversationIdRef = useRef(null);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
mountedRef.current = true;
|
|
103
|
+
return () => {
|
|
104
|
+
mountedRef.current = false;
|
|
105
|
+
abortRef.current?.abort();
|
|
106
|
+
if (flushTimerRef.current) {
|
|
107
|
+
clearTimeout(flushTimerRef.current);
|
|
108
|
+
flushTimerRef.current = null;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
// ── Input-mode registry: claim 'input' mode while focused so global
|
|
114
|
+
// single-char shortcuts (like '?' for help) don't steal keystrokes
|
|
115
|
+
// out of the message box. Restore 'nav' when navigating away.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
setInputMode?.(isActive ? 'input' : 'nav');
|
|
118
|
+
}, [isActive, setInputMode]);
|
|
119
|
+
|
|
120
|
+
// Auto-stick the viewport to the newest messages unless the user has
|
|
121
|
+
// manually scrolled up (PgUp) — mirrors terminal/IRC client behaviour.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const len = messages.length;
|
|
124
|
+
const grew = len - prevMsgLenRef.current;
|
|
125
|
+
prevMsgLenRef.current = len;
|
|
126
|
+
if (grew <= 0) return;
|
|
127
|
+
if (stickRef.current) {
|
|
128
|
+
setScrollOffset(0);
|
|
129
|
+
} else {
|
|
130
|
+
setScrollOffset((o) => o + grew);
|
|
131
|
+
}
|
|
132
|
+
}, [messages.length]);
|
|
133
|
+
|
|
134
|
+
// Lazily fetch the org's agent roster the first time the picker opens.
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!pickerOpen || agentsLoaded) return;
|
|
137
|
+
let cancelled = false;
|
|
138
|
+
(async () => {
|
|
139
|
+
if (!orgId) {
|
|
140
|
+
if (!cancelled) { setAgents([]); setAgentsLoaded(true); }
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const res = await client.get(resolveEndpoint('agents.list', { orgId }).path, { service: resolveEndpoint('agents.list', { orgId }).service });
|
|
145
|
+
const list = res?.agents || res?.items || [];
|
|
146
|
+
if (!cancelled) { setAgents(Array.isArray(list) ? list : []); setAgentsLoaded(true); }
|
|
147
|
+
} catch {
|
|
148
|
+
if (!cancelled) { setAgents([]); setAgentsLoaded(true); }
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
return () => { cancelled = true; };
|
|
152
|
+
}, [pickerOpen, agentsLoaded, client, orgId]);
|
|
153
|
+
|
|
154
|
+
const scrollUp = useCallback(() => {
|
|
155
|
+
stickRef.current = false;
|
|
156
|
+
setScrollOffset((o) => {
|
|
157
|
+
const maxOffset = Math.max(0, messages.length - viewportSize);
|
|
158
|
+
return Math.min(maxOffset, o + viewportSize);
|
|
159
|
+
});
|
|
160
|
+
}, [messages.length, viewportSize]);
|
|
161
|
+
|
|
162
|
+
const scrollDown = useCallback(() => {
|
|
163
|
+
setScrollOffset((o) => {
|
|
164
|
+
const next = Math.max(0, o - viewportSize);
|
|
165
|
+
if (next === 0) stickRef.current = true;
|
|
166
|
+
return next;
|
|
167
|
+
});
|
|
168
|
+
}, [viewportSize]);
|
|
169
|
+
|
|
170
|
+
// ── Handle keyboard input for the message box ────────────────────
|
|
171
|
+
useInput((ch, key) => {
|
|
172
|
+
if (!isActive) return;
|
|
173
|
+
|
|
174
|
+
// Scrollback works even mid-stream so a long reply can be reviewed.
|
|
175
|
+
if (key.pageUp) { scrollUp(); return; }
|
|
176
|
+
if (key.pageDown) { scrollDown(); return; }
|
|
177
|
+
if (key.ctrl && ch === 'u') { scrollUp(); return; }
|
|
178
|
+
if (key.ctrl && ch === 'd') { scrollDown(); return; }
|
|
179
|
+
|
|
180
|
+
if (isStreaming) return;
|
|
181
|
+
|
|
182
|
+
// ── Agent picker owns the keyboard while open ────────────────────
|
|
183
|
+
if (pickerOpen) {
|
|
184
|
+
if (key.tab || key.downArrow) {
|
|
185
|
+
setPickerIndex((i) => (agents.length ? (i + 1) % agents.length : 0));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key.upArrow) {
|
|
189
|
+
setPickerIndex((i) => (agents.length ? (i - 1 + agents.length) % agents.length : 0));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key.return) {
|
|
193
|
+
const chosen = agents[pickerIndex];
|
|
194
|
+
if (chosen) setAgent(typeof chosen === 'string' ? chosen : (chosen.id || chosen.name));
|
|
195
|
+
setPickerOpen(false);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (key.escape) {
|
|
199
|
+
setPickerOpen(false);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (key.return) {
|
|
206
|
+
if (input.trim()) sendMessage();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Tab (with an empty box) opens the agent picker. Free otherwise.
|
|
211
|
+
if (key.tab) {
|
|
212
|
+
if (input.trim() === '') {
|
|
213
|
+
setPickerOpen(true);
|
|
214
|
+
setPickerIndex(0);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── History recall ─────────────────────────────────────────────
|
|
220
|
+
if (key.upArrow) {
|
|
221
|
+
if (history.length === 0) return;
|
|
222
|
+
if (historyIndex === null) draftRef.current = input;
|
|
223
|
+
const newIdx = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
224
|
+
setHistoryIndex(newIdx);
|
|
225
|
+
setInput(history[newIdx]);
|
|
226
|
+
setCursor(history[newIdx].length);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (key.downArrow) {
|
|
230
|
+
if (historyIndex === null) return;
|
|
231
|
+
const newIdx = historyIndex + 1;
|
|
232
|
+
if (newIdx >= history.length) {
|
|
233
|
+
setHistoryIndex(null);
|
|
234
|
+
setInput(draftRef.current);
|
|
235
|
+
setCursor(draftRef.current.length);
|
|
236
|
+
} else {
|
|
237
|
+
setHistoryIndex(newIdx);
|
|
238
|
+
setInput(history[newIdx]);
|
|
239
|
+
setCursor(history[newIdx].length);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Cursor movement ────────────────────────────────────────────
|
|
245
|
+
if (key.leftArrow) { setCursor((c) => Math.max(0, c - 1)); return; }
|
|
246
|
+
if (key.rightArrow) { setCursor((c) => Math.min(input.length, c + 1)); return; }
|
|
247
|
+
if (key.home || (key.ctrl && ch === 'a')) { setCursor(0); return; }
|
|
248
|
+
if (key.end || (key.ctrl && ch === 'e')) { setCursor(input.length); return; }
|
|
249
|
+
|
|
250
|
+
// ── Edit at cursor ─────────────────────────────────────────────
|
|
251
|
+
if (key.backspace) {
|
|
252
|
+
if (cursor === 0) return;
|
|
253
|
+
setInput((prev) => prev.slice(0, cursor - 1) + prev.slice(cursor));
|
|
254
|
+
setCursor((c) => c - 1);
|
|
255
|
+
setHistoryIndex(null);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (key.delete) {
|
|
259
|
+
setInput((prev) => (cursor >= prev.length ? prev : prev.slice(0, cursor) + prev.slice(cursor + 1)));
|
|
260
|
+
setHistoryIndex(null);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Don't capture escape (handled by parent for navigation)
|
|
265
|
+
if (key.escape) return;
|
|
266
|
+
|
|
267
|
+
if (ch && !key.ctrl && !key.meta && !key.tab && !key.return) {
|
|
268
|
+
setInput((prev) => prev.slice(0, cursor) + ch + prev.slice(cursor));
|
|
269
|
+
setCursor((c) => c + ch.length);
|
|
270
|
+
setHistoryIndex(null);
|
|
271
|
+
}
|
|
272
|
+
}, { isActive });
|
|
273
|
+
|
|
274
|
+
// ── Throttled flush of the token buffer into React state ──────────
|
|
275
|
+
const scheduleFlush = useCallback(() => {
|
|
276
|
+
if (flushTimerRef.current) return;
|
|
277
|
+
flushTimerRef.current = setTimeout(() => {
|
|
278
|
+
flushTimerRef.current = null;
|
|
279
|
+
if (!mountedRef.current) return;
|
|
280
|
+
setStreamText(fullTextRef.current);
|
|
281
|
+
}, FLUSH_INTERVAL_MS);
|
|
282
|
+
}, []);
|
|
283
|
+
|
|
284
|
+
const cancelFlush = useCallback(() => {
|
|
285
|
+
if (flushTimerRef.current) {
|
|
286
|
+
clearTimeout(flushTimerRef.current);
|
|
287
|
+
flushTimerRef.current = null;
|
|
288
|
+
}
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
// ── Send message ──────────────────────────────────────────────────
|
|
292
|
+
const sendMessage = useCallback(async () => {
|
|
293
|
+
const raw = input.trim();
|
|
294
|
+
if (!raw) return;
|
|
295
|
+
|
|
296
|
+
// `@name the rest of the message` routes just this send to `name`.
|
|
297
|
+
const atMatch = raw.match(/^@(\S+)\s+([\s\S]+)$/);
|
|
298
|
+
const routedAgent = atMatch ? atMatch[1] : null;
|
|
299
|
+
const promptBody = atMatch ? atMatch[2] : raw;
|
|
300
|
+
const effectiveAgent = routedAgent || agent || 'quox';
|
|
301
|
+
|
|
302
|
+
setHistory((prev) => [...prev, raw]);
|
|
303
|
+
setHistoryIndex(null);
|
|
304
|
+
draftRef.current = '';
|
|
305
|
+
|
|
306
|
+
setMessages((prev) => [...prev, { role: 'user', text: raw }]);
|
|
307
|
+
setInput('');
|
|
308
|
+
setCursor(0);
|
|
309
|
+
|
|
310
|
+
fullTextRef.current = '';
|
|
311
|
+
setIsStreaming(true);
|
|
312
|
+
setStreamText('');
|
|
313
|
+
setStreamStatus('');
|
|
314
|
+
setStreamLabel('');
|
|
315
|
+
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
abortRef.current = controller;
|
|
318
|
+
|
|
319
|
+
const queryId = `CLI_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
320
|
+
const body = {
|
|
321
|
+
query_id: queryId,
|
|
322
|
+
prompt: promptBody,
|
|
323
|
+
assistant: effectiveAgent,
|
|
324
|
+
};
|
|
325
|
+
if (conversationIdRef.current) body.session_id = conversationIdRef.current;
|
|
326
|
+
|
|
327
|
+
let sawChunk = false;
|
|
328
|
+
let resultSessionId = conversationIdRef.current;
|
|
329
|
+
let agentUsed = null;
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
for await (const event of client.stream(resolveEndpoint('chat.stream').path, {
|
|
333
|
+
service: resolveEndpoint('chat.stream').service,
|
|
334
|
+
body,
|
|
335
|
+
signal: controller.signal,
|
|
336
|
+
})) {
|
|
337
|
+
if (!mountedRef.current || controller.signal.aborted) return;
|
|
338
|
+
|
|
339
|
+
if (event.agent_used) {
|
|
340
|
+
agentUsed = event.agent_used;
|
|
341
|
+
setStreamLabel(agentUsed);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
switch (event.type) {
|
|
345
|
+
case 'connected':
|
|
346
|
+
break;
|
|
347
|
+
|
|
348
|
+
case 'thinking': {
|
|
349
|
+
const stage = event.stage || '';
|
|
350
|
+
if (stage === 'tool_call' || stage.includes('tool')) {
|
|
351
|
+
setStreamStatus(`⚙ using tool: ${event.message || ''}`);
|
|
352
|
+
} else if (event.message) {
|
|
353
|
+
setStreamStatus(`… ${event.message}`);
|
|
354
|
+
}
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case 'chunk':
|
|
359
|
+
sawChunk = true;
|
|
360
|
+
fullTextRef.current += event.content || '';
|
|
361
|
+
scheduleFlush();
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case 'done':
|
|
365
|
+
if (!sawChunk && event.full_response) {
|
|
366
|
+
fullTextRef.current = event.full_response;
|
|
367
|
+
}
|
|
368
|
+
if (event.session_id) resultSessionId = event.session_id;
|
|
369
|
+
break;
|
|
370
|
+
|
|
371
|
+
case 'error':
|
|
372
|
+
throw new Error(event.error || 'Stream error');
|
|
373
|
+
|
|
374
|
+
default:
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (!mountedRef.current || controller.signal.aborted) return;
|
|
380
|
+
|
|
381
|
+
cancelFlush();
|
|
382
|
+
if (resultSessionId) {
|
|
383
|
+
conversationIdRef.current = resultSessionId;
|
|
384
|
+
setConversationId(resultSessionId);
|
|
385
|
+
}
|
|
386
|
+
setMessages((prev) => [...prev, { role: 'assistant', text: fullTextRef.current, label: agentUsed }]);
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (!mountedRef.current || controller.signal.aborted) return;
|
|
389
|
+
cancelFlush();
|
|
390
|
+
setMessages((prev) => [...prev, { role: 'error', text: err.message }]);
|
|
391
|
+
} finally {
|
|
392
|
+
if (mountedRef.current) {
|
|
393
|
+
setIsStreaming(false);
|
|
394
|
+
setStreamText('');
|
|
395
|
+
setStreamStatus('');
|
|
396
|
+
setStreamLabel('');
|
|
397
|
+
}
|
|
398
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
399
|
+
}
|
|
400
|
+
}, [input, client, agent, scheduleFlush, cancelFlush]);
|
|
401
|
+
|
|
402
|
+
// ── Viewport window over the message list ──────────────────────────
|
|
403
|
+
const total = messages.length;
|
|
404
|
+
const windowEnd = Math.max(0, total - scrollOffset);
|
|
405
|
+
const windowStart = Math.max(0, windowEnd - viewportSize);
|
|
406
|
+
const visibleMessages = messages.slice(windowStart, windowEnd);
|
|
407
|
+
const hiddenAbove = windowStart;
|
|
408
|
+
const hiddenBelow = total - windowEnd;
|
|
409
|
+
|
|
410
|
+
const activeAgent = agent || 'quox';
|
|
411
|
+
const footer = `Enter: send ↑/↓: history PgUp/PgDn: scroll Tab: agent Esc: back [agent: ${activeAgent}]`;
|
|
412
|
+
|
|
413
|
+
// ── Render ─────────────────────────────────────────────────────────
|
|
414
|
+
return (
|
|
415
|
+
<FullLayout title="Chat" subtitle="CommanderQ" footer={footer}>
|
|
416
|
+
{/* Message history */}
|
|
417
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
418
|
+
{messages.length === 0 && !isStreaming && (
|
|
419
|
+
<Text color={colors.dim}>Type a message and press Enter to chat with CommanderQ</Text>
|
|
420
|
+
)}
|
|
421
|
+
{hiddenAbove > 0 && (
|
|
422
|
+
<Text color={colors.dim}>{`↑ ${hiddenAbove} earlier message${hiddenAbove === 1 ? '' : 's'} (PgUp)`}</Text>
|
|
423
|
+
)}
|
|
424
|
+
{visibleMessages.map((msg, i) => (
|
|
425
|
+
<Box key={windowStart + i} marginBottom={1}>
|
|
426
|
+
<Text
|
|
427
|
+
color={msg.role === 'user' ? colors.cyberBlue : msg.role === 'error' ? colors.error : colors.accent}
|
|
428
|
+
bold
|
|
429
|
+
>
|
|
430
|
+
{msg.role === 'user' ? 'You: ' : msg.role === 'error' ? 'Error: ' : `${msg.label || 'CommanderQ'}: `}
|
|
431
|
+
</Text>
|
|
432
|
+
<Text color={msg.role === 'error' ? colors.error : colors.text} wrap="wrap">
|
|
433
|
+
{msg.text}
|
|
434
|
+
</Text>
|
|
435
|
+
</Box>
|
|
436
|
+
))}
|
|
437
|
+
{hiddenBelow > 0 && (
|
|
438
|
+
<Text color={colors.dim}>{`↓ ${hiddenBelow} newer message${hiddenBelow === 1 ? '' : 's'} (PgDn)`}</Text>
|
|
439
|
+
)}
|
|
440
|
+
{isStreaming && (
|
|
441
|
+
<Box marginBottom={streamStatus ? 0 : 1}>
|
|
442
|
+
<Text color={colors.accent} bold>{`${streamLabel || 'CommanderQ'}: `}</Text>
|
|
443
|
+
{streamText ? (
|
|
444
|
+
<Text color={colors.text} wrap="wrap">{streamText}</Text>
|
|
445
|
+
) : (
|
|
446
|
+
<Text color={colors.warning}>is thinking...</Text>
|
|
447
|
+
)}
|
|
448
|
+
</Box>
|
|
449
|
+
)}
|
|
450
|
+
{isStreaming && streamStatus && (
|
|
451
|
+
<Box marginBottom={1}>
|
|
452
|
+
<Text color={colors.dim}>{streamStatus}</Text>
|
|
453
|
+
</Box>
|
|
454
|
+
)}
|
|
455
|
+
</Box>
|
|
456
|
+
|
|
457
|
+
{/* Input + agent picker */}
|
|
458
|
+
<ChatInput
|
|
459
|
+
input={input}
|
|
460
|
+
cursor={cursor}
|
|
461
|
+
disabled={isStreaming}
|
|
462
|
+
agents={agents}
|
|
463
|
+
pickerOpen={pickerOpen}
|
|
464
|
+
pickerIndex={pickerIndex}
|
|
465
|
+
/>
|
|
466
|
+
</FullLayout>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilesView — File management view
|
|
3
|
+
*
|
|
4
|
+
* Left panel: DataTable of files (name, size, type, created)
|
|
5
|
+
* Right panel: DetailPanel with file detail
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - `d` key deletes selected file (with y/n confirmation)
|
|
9
|
+
* - `r` key refreshes file list
|
|
10
|
+
*
|
|
11
|
+
* API:
|
|
12
|
+
* GET /list (service: files) -> data.files
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React, { useState, useCallback } from 'react';
|
|
16
|
+
import { Box, Text, useInput } from 'ink';
|
|
17
|
+
import { Layout } from '../components/Layout.jsx';
|
|
18
|
+
import { DataTable } from '../components/DataTable.jsx';
|
|
19
|
+
import { DetailPanel } from '../components/DetailPanel.jsx';
|
|
20
|
+
import { Confirm } from '../components/Confirm.jsx';
|
|
21
|
+
import { useApi } from '../hooks/useApi.js';
|
|
22
|
+
import { useTui } from '../app.jsx';
|
|
23
|
+
import { matchKey } from '../keys.js';
|
|
24
|
+
import { colors } from '../theme.js';
|
|
25
|
+
import { resolveEndpoint } from '../../api/endpoints.js';
|
|
26
|
+
|
|
27
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format bytes into a human-readable file size.
|
|
31
|
+
*/
|
|
32
|
+
function formatFileSize(bytes) {
|
|
33
|
+
if (bytes == null || isNaN(bytes)) return '--';
|
|
34
|
+
const num = Number(bytes);
|
|
35
|
+
if (num === 0) return '0 B';
|
|
36
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
37
|
+
const i = Math.floor(Math.log(num) / Math.log(1024));
|
|
38
|
+
const size = (num / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
|
39
|
+
return `${size} ${units[i]}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Format a date string for display.
|
|
44
|
+
*/
|
|
45
|
+
function formatDate(iso) {
|
|
46
|
+
if (!iso) return '--';
|
|
47
|
+
try {
|
|
48
|
+
const d = new Date(iso);
|
|
49
|
+
return d.toISOString().slice(0, 10);
|
|
50
|
+
} catch {
|
|
51
|
+
return String(iso).slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Column definitions ────────────────────────────────────────────────
|
|
56
|
+
const COLUMNS = [
|
|
57
|
+
{ key: 'name', label: 'Name', width: 24 },
|
|
58
|
+
{ key: 'size', label: 'Size', width: 10 },
|
|
59
|
+
{ key: 'type', label: 'Type', width: 12 },
|
|
60
|
+
{ key: 'created', label: 'Created', width: 14 },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format raw file data into table rows.
|
|
65
|
+
*/
|
|
66
|
+
function formatRows(files) {
|
|
67
|
+
if (!Array.isArray(files)) return [];
|
|
68
|
+
return files.map((f) => ({
|
|
69
|
+
name: f.name || f.filename || f.id,
|
|
70
|
+
size: formatFileSize(f.size || f.file_size),
|
|
71
|
+
type: f.type || f.mime_type || f.content_type || '--',
|
|
72
|
+
created: formatDate(f.created_at || f.uploaded_at),
|
|
73
|
+
_raw: f,
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a detail object for the right panel.
|
|
79
|
+
*/
|
|
80
|
+
function buildDetail(raw) {
|
|
81
|
+
if (!raw) return null;
|
|
82
|
+
return {
|
|
83
|
+
'Name': raw.name || raw.filename,
|
|
84
|
+
'ID': raw.id,
|
|
85
|
+
'Size': formatFileSize(raw.size || raw.file_size),
|
|
86
|
+
'Type': raw.type || raw.mime_type || raw.content_type,
|
|
87
|
+
'Path': raw.path || raw.file_path || '--',
|
|
88
|
+
'Created': raw.created_at || raw.uploaded_at,
|
|
89
|
+
'Modified': raw.updated_at || raw.modified_at || '--',
|
|
90
|
+
'Owner': raw.owner || raw.uploaded_by || '--',
|
|
91
|
+
'Tags': Array.isArray(raw.tags) ? raw.tags.join(', ') : '--',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Component ─────────────────────────────────────────────────────────
|
|
96
|
+
export function FilesView() {
|
|
97
|
+
const ctx = useTui();
|
|
98
|
+
const { client, screen, orgVersion } = ctx;
|
|
99
|
+
const isActive = screen === 'files';
|
|
100
|
+
|
|
101
|
+
const [selected, setSelected] = useState(0);
|
|
102
|
+
const [actionMsg, setActionMsg] = useState(null);
|
|
103
|
+
const [confirming, setConfirming] = useState(false);
|
|
104
|
+
|
|
105
|
+
// ── Data fetching (refetches when the active org changes) ───────────
|
|
106
|
+
const { data, loading, error, refresh } = useApi(
|
|
107
|
+
async () => {
|
|
108
|
+
const res = await client.get(resolveEndpoint('files.list').path, { service: resolveEndpoint('files.list').service });
|
|
109
|
+
return res?.files || [];
|
|
110
|
+
},
|
|
111
|
+
[client, orgVersion]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const rows = formatRows(data);
|
|
115
|
+
const selectedRow = rows[selected];
|
|
116
|
+
const detail = selectedRow ? buildDetail(selectedRow._raw) : null;
|
|
117
|
+
|
|
118
|
+
// ── Delete file ─────────────────────────────────────────────────────
|
|
119
|
+
const deleteFile = useCallback(async () => {
|
|
120
|
+
if (!selectedRow) return;
|
|
121
|
+
const raw = selectedRow._raw;
|
|
122
|
+
const fileId = raw.id;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
setActionMsg('Deleting...');
|
|
126
|
+
await client.delete(resolveEndpoint('file.delete', { fileId }).path, { service: resolveEndpoint('file.delete', { fileId }).service });
|
|
127
|
+
setActionMsg(`Deleted "${raw.name || raw.filename || fileId}"`);
|
|
128
|
+
refresh();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
setActionMsg(`Delete failed: ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setTimeout(() => setActionMsg(null), 3000);
|
|
134
|
+
}, [selectedRow, client, refresh]);
|
|
135
|
+
|
|
136
|
+
// ── Keyboard handling ──────────────────────────────────────────────
|
|
137
|
+
useInput((input, key) => {
|
|
138
|
+
if (!isActive || confirming) return;
|
|
139
|
+
|
|
140
|
+
// Manual refresh
|
|
141
|
+
if (matchKey('refresh', input, key)) {
|
|
142
|
+
refresh();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Delete file
|
|
147
|
+
if (matchKey('delete', input, key) && selectedRow) {
|
|
148
|
+
setConfirming(true);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}, { isActive: isActive && !confirming });
|
|
152
|
+
|
|
153
|
+
// ── Confirmation handlers ─────────────────────────────────────────
|
|
154
|
+
const handleConfirm = useCallback(() => {
|
|
155
|
+
setConfirming(false);
|
|
156
|
+
deleteFile();
|
|
157
|
+
}, [deleteFile]);
|
|
158
|
+
|
|
159
|
+
const handleCancel = useCallback(() => {
|
|
160
|
+
setConfirming(false);
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
// ── Footer hint ────────────────────────────────────────────────────
|
|
164
|
+
const footer = [
|
|
165
|
+
'\u2191/k \u2193/j Navigate',
|
|
166
|
+
'd Delete',
|
|
167
|
+
'r Refresh',
|
|
168
|
+
'Esc Back',
|
|
169
|
+
].join(' ');
|
|
170
|
+
|
|
171
|
+
// ── Render ─────────────────────────────────────────────────────────
|
|
172
|
+
return (
|
|
173
|
+
<Layout
|
|
174
|
+
title="Files"
|
|
175
|
+
subtitle={`${rows.length} files`}
|
|
176
|
+
footer={footer}
|
|
177
|
+
left={
|
|
178
|
+
<Box flexDirection="column">
|
|
179
|
+
{error && (
|
|
180
|
+
<Box marginBottom={1}>
|
|
181
|
+
<Text color={colors.error}>Error: {error.message}</Text>
|
|
182
|
+
</Box>
|
|
183
|
+
)}
|
|
184
|
+
{actionMsg && (
|
|
185
|
+
<Box marginBottom={1}>
|
|
186
|
+
<Text color={colors.info}>{actionMsg}</Text>
|
|
187
|
+
</Box>
|
|
188
|
+
)}
|
|
189
|
+
{confirming && (
|
|
190
|
+
<Box marginBottom={1}>
|
|
191
|
+
<Confirm
|
|
192
|
+
message={`Delete "${selectedRow?._raw?.name || selectedRow?._raw?.filename || 'file'}"?`}
|
|
193
|
+
onConfirm={handleConfirm}
|
|
194
|
+
onCancel={handleCancel}
|
|
195
|
+
active={confirming}
|
|
196
|
+
/>
|
|
197
|
+
</Box>
|
|
198
|
+
)}
|
|
199
|
+
<DataTable
|
|
200
|
+
columns={COLUMNS}
|
|
201
|
+
rows={rows}
|
|
202
|
+
selected={selected}
|
|
203
|
+
onSelectionChange={setSelected}
|
|
204
|
+
loading={loading}
|
|
205
|
+
emptyMessage="No files — upload via `quox file upload <path>`"
|
|
206
|
+
active={isActive && !confirming}
|
|
207
|
+
/>
|
|
208
|
+
</Box>
|
|
209
|
+
}
|
|
210
|
+
right={
|
|
211
|
+
<DetailPanel
|
|
212
|
+
title={selectedRow ? (selectedRow._raw.name || selectedRow._raw.filename || 'File') : undefined}
|
|
213
|
+
data={detail}
|
|
214
|
+
/>
|
|
215
|
+
}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
}
|