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:
gsxdsm
2026-04-24 20:07:41 -07:00
parent 314f8acccc
commit 2a8ca04d95
5 changed files with 215 additions and 31 deletions

View File

@@ -45,6 +45,12 @@ function makeInteractiveData(opts: {
return { return {
listProjects: async () => projects, listProjects: async () => projects,
listTasks: async () => tasks, 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, listAgents: async () => agents,
getAgentDetail: async (_id: string) => detail, getAgentDetail: async (_id: string) => detail,
updateAgentState: async (_id: string, _state: string) => {}, updateAgentState: async (_id: string, _state: string) => {},

View File

@@ -1,6 +1,32 @@
import React, { useState, useSyncExternalStore, useCallback, useEffect } from "react"; import React, { useState, useSyncExternalStore, useCallback, useEffect } from "react";
import { Box, Text, useInput, useApp, useStdout } from "ink"; import { Box, Text, useInput, useApp, useStdout } from "ink";
import Spinner from "ink-spinner"; 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 { DashboardTUI } from "./controller.js";
import type { import type {
DashboardState, DashboardState,
@@ -170,20 +196,38 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
<> <>
<Box flexDirection="row" gap={1}> <Box flexDirection="row" gap={1}>
<Text dimColor>Auth:</Text> <Text dimColor>Auth:</Text>
<Text color="yellow">bearer token</Text> <Text color="yellow">bearer token required</Text>
</Box> </Box>
{info.authToken && ( {info.authToken && (
<Box flexDirection="row" gap={1}> <Box flexDirection="row" gap={1}>
<Text dimColor>Token:</Text> <Text dimColor>Token:</Text>
<Text wrap="truncate">{info.authToken}</Text> <Text wrap="truncate" color="yellow">{info.authToken}</Text>
</Box> </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> <Box flexDirection="row" gap={1}>
<Text dimColor>disabled</Text> <Text dimColor>Auth:</Text>
</Box> <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}> <Box flexDirection="row" gap={1}>
<Text dimColor>Engine:</Text> <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 { function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n)); return Math.max(min, Math.min(max, n));
@@ -870,6 +914,9 @@ function BoardView({ state }: { state: DashboardState }) {
done: 0, done: 0,
}); });
const [pickerOriginal, setPickerOriginal] = useState(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 projectsState = useProjects(state.interactiveData);
const selectedProject = projectsState.projects[projectIndex] ?? null; const selectedProject = projectsState.projects[projectIndex] ?? null;
@@ -910,11 +957,29 @@ function BoardView({ state }: { state: DashboardState }) {
return; 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") { if (input === "p" || input === "P") {
setPickerOriginal(projectIndex); setPickerOriginal(projectIndex);
setSubView("picker"); setSubView("picker");
return; return;
} }
if (input === "n" || input === "N") {
if (!selectedProject) return;
setNewTaskTitle("");
setCreateError(null);
setSubView("create");
return;
}
if (key.return) { if (key.return) {
if (selectedTask) setSubView("detail"); if (selectedTask) setSubView("detail");
return; return;
@@ -947,7 +1012,33 @@ function BoardView({ state }: { state: DashboardState }) {
? "↑↓ pick · Enter confirm · Esc cancel" ? "↑↓ pick · Enter confirm · Esc cancel"
: subView === "detail" : subView === "detail"
? "Esc back · q quit" ? "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 ( return (
<Box flexDirection="column" flexGrow={1}> <Box flexDirection="column" flexGrow={1}>
@@ -964,7 +1055,44 @@ function BoardView({ state }: { state: DashboardState }) {
<Box height={1} /> <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}> <Box flexGrow={1} paddingX={1}>
<TaskDetailScreen task={selectedTask} /> <TaskDetailScreen task={selectedTask} />
</Box> </Box>
@@ -1016,6 +1144,9 @@ function heartbeatFreshness(lastHeartbeatAt?: string): { fresh: boolean; label:
type AgentSubView = "list" | "confirm-delete"; type AgentSubView = "list" | "confirm-delete";
function AgentsView({ state }: { state: DashboardState }) { function AgentsView({ state }: { state: DashboardState }) {
const { stdout } = useStdout();
const cols = stdout?.columns ?? 80;
const isNarrow = cols < 80;
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const [agents, setAgents] = useState<AgentItem[]>([]); const [agents, setAgents] = useState<AgentItem[]>([]);
const [detail, setDetail] = useState<AgentDetailItem | null>(null); const [detail, setDetail] = useState<AgentDetailItem | null>(null);
@@ -1146,13 +1277,15 @@ function AgentsView({ state }: { state: DashboardState }) {
<Text color="yellow">{statusMsg}</Text> <Text color="yellow">{statusMsg}</Text>
</Box> </Box>
)} )}
<Box flexDirection="row" flexGrow={1} overflow="hidden"> <Box flexDirection={isNarrow ? "column" : "row"} flexGrow={1} overflow="hidden">
{/* Left: agent list */} {/* List panel */}
<Box <Box
borderStyle="round" borderStyle="round"
borderColor={detailFocused ? "gray" : "cyan"} borderColor={detailFocused ? "gray" : "cyan"}
flexDirection="column" flexDirection="column"
width="30%" width={isNarrow ? undefined : "30%"}
flexGrow={isNarrow ? 1 : 0}
flexShrink={0}
overflow="hidden" overflow="hidden"
> >
<Box paddingX={1}> <Box paddingX={1}>
@@ -1270,7 +1403,7 @@ function AgentsView({ state }: { state: DashboardState }) {
{/* Footer */} {/* Footer */}
<Box paddingX={1}> <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>
</Box> </Box>
); );
@@ -1622,6 +1755,15 @@ export function DashboardApp({ controller }: DashboardAppProps) {
return; 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) // Number keys switch panels (status mode)
if (input >= "1" && input <= "5") { if (input >= "1" && input <= "5") {
const section = SECTION_ORDER[parseInt(input, 10) - 1]; 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 ( return (
<Box flexDirection="column" height={rows}> <Box flexDirection="column" height={rows}>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
import type { ProjectItem, TaskItem, InteractiveData } from "../state.js"; import type { ProjectItem, TaskItem, InteractiveData } from "../state.js";
export interface ProjectsState { export interface ProjectsState {
@@ -11,44 +11,60 @@ export interface TasksState {
tasks: TaskItem[]; tasks: TaskItem[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => void;
} }
export function useProjects(interactiveData: InteractiveData | null): ProjectsState { 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(() => { useEffect(() => {
if (!interactiveData) return; if (!interactiveData) return;
setState({ projects: [], loading: true, error: null }); setLoading(true);
interactiveData.listProjects().then((projects) => { setError(null);
setState({ projects, loading: false, error: null }); interactiveData.listProjects().then((p) => {
setProjects(p);
setLoading(false);
}).catch((err: unknown) => { }).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err); setProjects([]);
setState({ projects: [], loading: false, error: message }); setLoading(false);
setError(err instanceof Error ? err.message : String(err));
}); });
}, [interactiveData]); }, [interactiveData]);
return state; return { projects, loading, error };
} }
export function useTasks( export function useTasks(
interactiveData: InteractiveData | null, interactiveData: InteractiveData | null,
selectedProject: ProjectItem | null, selectedProject: ProjectItem | null,
): TasksState { ): 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(() => { useEffect(() => {
if (!interactiveData || !selectedProject) { if (!interactiveData || !selectedProject) {
setState({ tasks: [], loading: false, error: null }); setTasks([]);
setLoading(false);
setError(null);
return; return;
} }
setState({ tasks: [], loading: true, error: null }); setLoading(true);
interactiveData.listTasks(selectedProject.path).then((tasks) => { setError(null);
setState({ tasks, loading: false, error: null }); interactiveData.listTasks(selectedProject.path).then((t) => {
setTasks(t);
setLoading(false);
}).catch((err: unknown) => { }).catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err); setTasks([]);
setState({ tasks: [], loading: false, error: message }); setLoading(false);
setError(err instanceof Error ? err.message : String(err));
}); });
}, [interactiveData, selectedProject]); }, [interactiveData, selectedProject, reloadTick]);
return state; return { tasks, loading, error, refresh };
} }

View File

@@ -110,6 +110,7 @@ export interface ModelItem {
export interface InteractiveData { export interface InteractiveData {
listProjects: () => Promise<ProjectItem[]>; listProjects: () => Promise<ProjectItem[]>;
listTasks: (projectPath: string) => Promise<TaskItem[]>; listTasks: (projectPath: string) => Promise<TaskItem[]>;
createTask: (projectPath: string, input: { title: string; description?: string }) => Promise<TaskItem>;
listAgents: () => Promise<AgentItem[]>; listAgents: () => Promise<AgentItem[]>;
getAgentDetail: (id: string) => Promise<AgentDetailItem | null>; getAgentDetail: (id: string) => Promise<AgentDetailItem | null>;
updateAgentState: (id: string, state: string) => Promise<void>; updateAgentState: (id: string, state: string) => Promise<void>;

View File

@@ -1546,6 +1546,25 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agentState: (t as { agentState?: string }).agentState, agentState: (t as { agentState?: string }).agentState,
})); }));
}, },
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
let projectStore = projectStores.get(projectPath);
if (!projectStore) {
projectStore = projectPath === cwd ? store : new TaskStore(projectPath);
if (projectPath !== cwd) await projectStore.init();
projectStores.set(projectPath, projectStore);
}
const created = await projectStore.createTask({
title: input.title,
description: input.description ?? input.title,
});
return {
id: created.id,
title: created.title,
description: created.description ?? "",
column: created.column,
agentState: (created as { agentState?: string }).agentState,
};
},
listAgents: async () => { listAgents: async () => {
const list = await agentStore!.listAgents(); const list = await agentStore!.listAgents();
return list.map((a) => ({ return list.map((a) => ({