feat(cli): TUI grid threshold, narrow agents view, new-task popup, browser launch
- Lower the multi-panel grid threshold from 100×24 to 80×20 so standard terminals render the full layout instead of falling back to single-pane. - Agents view stacks list above detail when cols < 80; Tab focus model preserved. - Board view: press [n] to open a centered create-task popup. Title is edited via ink-text-input; submit calls TaskStore.createTask, refresh the task list on success. - System panel: shows the tokenized auth URL alongside the token, says "no auth" plainly when --no-auth was used, and prompts with "Press [Enter] to open in browser". Enter (while System is focused) spawns the platform's URL opener (open / start / xdg-open). - Tighten log-row layout: each row is now a single Text with inline spans + fixed-width prefix slot so columns align and the highlight bar spans the full row. - Default status section is "logs" so the live feed is what users see when they run `fn`. - Drop the ASCII circle brand mark from the splash; FUSION block letters + tagline + spinner only, pinned to the top-left. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,12 @@ function makeInteractiveData(opts: {
|
||||
return {
|
||||
listProjects: async () => projects,
|
||||
listTasks: async () => tasks,
|
||||
createTask: async (_path: string, input: { title: string; description?: string }) => ({
|
||||
id: "new",
|
||||
title: input.title,
|
||||
description: input.description ?? input.title,
|
||||
column: "todo",
|
||||
}) as TaskItem,
|
||||
listAgents: async () => agents,
|
||||
getAgentDetail: async (_id: string) => detail,
|
||||
updateAgentState: async (_id: string, _state: string) => {},
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import React, { useState, useSyncExternalStore, useCallback, useEffect } from "react";
|
||||
import { Box, Text, useInput, useApp, useStdout } from "ink";
|
||||
import Spinner from "ink-spinner";
|
||||
import TextInput from "ink-text-input";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
// Open a URL in the user's default browser. Uses the platform-native opener
|
||||
// (macOS `open`, Windows `start`, Linux `xdg-open`). Detached + ignored stdio
|
||||
// so the spawned process doesn't block the TUI's input loop.
|
||||
function openInBrowser(url: string): void {
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
if (process.platform === "darwin") {
|
||||
cmd = "open";
|
||||
args = [url];
|
||||
} else if (process.platform === "win32") {
|
||||
cmd = "cmd";
|
||||
args = ["/c", "start", "", url];
|
||||
} else {
|
||||
cmd = "xdg-open";
|
||||
args = [url];
|
||||
}
|
||||
try {
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
} catch {
|
||||
// Best-effort — silently ignore if the platform tool isn't available.
|
||||
}
|
||||
}
|
||||
import type { DashboardTUI } from "./controller.js";
|
||||
import type {
|
||||
DashboardState,
|
||||
@@ -170,20 +196,38 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
||||
<>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Auth:</Text>
|
||||
<Text color="yellow">bearer token</Text>
|
||||
<Text color="yellow">bearer token required</Text>
|
||||
</Box>
|
||||
{info.authToken && (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Token:</Text>
|
||||
<Text wrap="truncate">{info.authToken}</Text>
|
||||
<Text wrap="truncate" color="yellow">{info.authToken}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{info.tokenizedUrl && (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Open:</Text>
|
||||
<Text wrap="truncate" color="cyanBright">{info.tokenizedUrl}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Press</Text>
|
||||
<Text color="cyanBright" bold>[Enter]</Text>
|
||||
<Text dimColor>to open in browser</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Auth:</Text>
|
||||
<Text dimColor>disabled</Text>
|
||||
</Box>
|
||||
<>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Auth:</Text>
|
||||
<Text color="yellow">no auth</Text>
|
||||
</Box>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Press</Text>
|
||||
<Text color="cyanBright" bold>[Enter]</Text>
|
||||
<Text dimColor>to open in browser</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Engine:</Text>
|
||||
@@ -833,7 +877,7 @@ function TaskDetailScreen({ task }: { task: TaskItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
type BoardSubView = "board" | "detail" | "picker";
|
||||
type BoardSubView = "board" | "detail" | "picker" | "create";
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
@@ -870,6 +914,9 @@ function BoardView({ state }: { state: DashboardState }) {
|
||||
done: 0,
|
||||
});
|
||||
const [pickerOriginal, setPickerOriginal] = useState(0);
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const projectsState = useProjects(state.interactiveData);
|
||||
const selectedProject = projectsState.projects[projectIndex] ?? null;
|
||||
@@ -910,11 +957,29 @@ function BoardView({ state }: { state: DashboardState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subView === "create") {
|
||||
// TextInput owns most key handling; only Esc cancels here. Submit is
|
||||
// wired through the TextInput's onSubmit prop.
|
||||
if (key.escape) {
|
||||
setSubView("board");
|
||||
setNewTaskTitle("");
|
||||
setCreateError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === "p" || input === "P") {
|
||||
setPickerOriginal(projectIndex);
|
||||
setSubView("picker");
|
||||
return;
|
||||
}
|
||||
if (input === "n" || input === "N") {
|
||||
if (!selectedProject) return;
|
||||
setNewTaskTitle("");
|
||||
setCreateError(null);
|
||||
setSubView("create");
|
||||
return;
|
||||
}
|
||||
if (key.return) {
|
||||
if (selectedTask) setSubView("detail");
|
||||
return;
|
||||
@@ -947,7 +1012,33 @@ function BoardView({ state }: { state: DashboardState }) {
|
||||
? "↑↓ pick · Enter confirm · Esc cancel"
|
||||
: subView === "detail"
|
||||
? "Esc back · q quit"
|
||||
: "←→ column · ↑↓ task · Enter open · p project";
|
||||
: subView === "create"
|
||||
? "type a task title · Enter create · Esc cancel"
|
||||
: "←→ column · ↑↓ task · Enter open · n new · p project";
|
||||
|
||||
const submitNewTask = async () => {
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title) {
|
||||
setCreateError("Title cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (!state.interactiveData || !selectedProject) {
|
||||
setCreateError("No project selected");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await state.interactiveData.createTask(selectedProject.path, { title });
|
||||
setNewTaskTitle("");
|
||||
setSubView("board");
|
||||
tasksState.refresh();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
@@ -964,7 +1055,44 @@ function BoardView({ state }: { state: DashboardState }) {
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{subView === "detail" && selectedTask ? (
|
||||
{subView === "create" ? (
|
||||
<Box justifyContent="center" alignItems="center" flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor="cyan"
|
||||
flexDirection="column"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={Math.min(80, Math.max(40, cols - 8))}
|
||||
>
|
||||
<Text bold color="cyanBright">New Task</Text>
|
||||
<Text dimColor>Project: {selectedProject?.name ?? "(none)"}</Text>
|
||||
<Box height={1} />
|
||||
<Text dimColor>Title</Text>
|
||||
<Box>
|
||||
<Text color="cyanBright">▸ </Text>
|
||||
<TextInput
|
||||
value={newTaskTitle}
|
||||
onChange={setNewTaskTitle}
|
||||
onSubmit={() => void submitNewTask()}
|
||||
placeholder="What needs doing?"
|
||||
/>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{createError && (
|
||||
<Text color="red">{createError}</Text>
|
||||
)}
|
||||
{creating ? (
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text color="cyanBright"><Spinner type="dots" /></Text>
|
||||
<Text dimColor>Creating…</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Text dimColor>Enter to create · Esc to cancel</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : subView === "detail" && selectedTask ? (
|
||||
<Box flexGrow={1} paddingX={1}>
|
||||
<TaskDetailScreen task={selectedTask} />
|
||||
</Box>
|
||||
@@ -1016,6 +1144,9 @@ function heartbeatFreshness(lastHeartbeatAt?: string): { fresh: boolean; label:
|
||||
type AgentSubView = "list" | "confirm-delete";
|
||||
|
||||
function AgentsView({ state }: { state: DashboardState }) {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const isNarrow = cols < 80;
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [agents, setAgents] = useState<AgentItem[]>([]);
|
||||
const [detail, setDetail] = useState<AgentDetailItem | null>(null);
|
||||
@@ -1146,13 +1277,15 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
<Text color="yellow">{statusMsg}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexDirection="row" flexGrow={1} overflow="hidden">
|
||||
{/* Left: agent list */}
|
||||
<Box flexDirection={isNarrow ? "column" : "row"} flexGrow={1} overflow="hidden">
|
||||
{/* List panel */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={detailFocused ? "gray" : "cyan"}
|
||||
flexDirection="column"
|
||||
width="30%"
|
||||
width={isNarrow ? undefined : "30%"}
|
||||
flexGrow={isNarrow ? 1 : 0}
|
||||
flexShrink={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box paddingX={1}>
|
||||
@@ -1270,7 +1403,7 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
|
||||
{/* Footer */}
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>[s] start [x] stop [D] delete [r] refresh [Tab] switch panel focus ↑↓ select</Text>
|
||||
<Text dimColor>[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -1622,6 +1755,15 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Status mode: Enter from the System panel opens the dashboard URL in
|
||||
// the user's browser. Logs panel handles Enter itself (expand log entry)
|
||||
// so we gate by activeSection.
|
||||
if (key.return && state.activeSection === "system" && state.systemInfo) {
|
||||
const url = state.systemInfo.tokenizedUrl ?? state.systemInfo.baseUrl;
|
||||
openInBrowser(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys switch panels (status mode)
|
||||
if (input >= "1" && input <= "5") {
|
||||
const section = SECTION_ORDER[parseInt(input, 10) - 1];
|
||||
@@ -1729,7 +1871,7 @@ export function DashboardApp({ controller }: DashboardAppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isNarrow = cols < 100 || rows < 24;
|
||||
const isNarrow = cols < 80 || rows < 20;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={rows}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { ProjectItem, TaskItem, InteractiveData } from "../state.js";
|
||||
|
||||
export interface ProjectsState {
|
||||
@@ -11,44 +11,60 @@ export interface TasksState {
|
||||
tasks: TaskItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useProjects(interactiveData: InteractiveData | null): ProjectsState {
|
||||
const [state, setState] = useState<ProjectsState>({ projects: [], loading: false, error: null });
|
||||
const [projects, setProjects] = useState<ProjectItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactiveData) return;
|
||||
setState({ projects: [], loading: true, error: null });
|
||||
interactiveData.listProjects().then((projects) => {
|
||||
setState({ projects, loading: false, error: null });
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
interactiveData.listProjects().then((p) => {
|
||||
setProjects(p);
|
||||
setLoading(false);
|
||||
}).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setState({ projects: [], loading: false, error: message });
|
||||
setProjects([]);
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [interactiveData]);
|
||||
|
||||
return state;
|
||||
return { projects, loading, error };
|
||||
}
|
||||
|
||||
export function useTasks(
|
||||
interactiveData: InteractiveData | null,
|
||||
selectedProject: ProjectItem | null,
|
||||
): TasksState {
|
||||
const [state, setState] = useState<TasksState>({ tasks: [], loading: false, error: null });
|
||||
const [tasks, setTasks] = useState<TaskItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setReloadTick((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactiveData || !selectedProject) {
|
||||
setState({ tasks: [], loading: false, error: null });
|
||||
setTasks([]);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setState({ tasks: [], loading: true, error: null });
|
||||
interactiveData.listTasks(selectedProject.path).then((tasks) => {
|
||||
setState({ tasks, loading: false, error: null });
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
interactiveData.listTasks(selectedProject.path).then((t) => {
|
||||
setTasks(t);
|
||||
setLoading(false);
|
||||
}).catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setState({ tasks: [], loading: false, error: message });
|
||||
setTasks([]);
|
||||
setLoading(false);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}, [interactiveData, selectedProject]);
|
||||
}, [interactiveData, selectedProject, reloadTick]);
|
||||
|
||||
return state;
|
||||
return { tasks, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export interface ModelItem {
|
||||
export interface InteractiveData {
|
||||
listProjects: () => Promise<ProjectItem[]>;
|
||||
listTasks: (projectPath: string) => Promise<TaskItem[]>;
|
||||
createTask: (projectPath: string, input: { title: string; description?: string }) => Promise<TaskItem>;
|
||||
listAgents: () => Promise<AgentItem[]>;
|
||||
getAgentDetail: (id: string) => Promise<AgentDetailItem | null>;
|
||||
updateAgentState: (id: string, state: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user