diff --git a/src/core/harness.ts b/src/core/harness.ts index 6bcee6b..ae03e41 100644 --- a/src/core/harness.ts +++ b/src/core/harness.ts @@ -741,6 +741,18 @@ export class Harness { + async chatDirect(line: string) { + this.stopFlag = false; + this.runActive = true; + this.events.emit({ type: "run_start", prompt: line }); + this.agents.get("orchestrator")!.assign( + `[mode=chat] Answer the user's question directly. Keep your response concise, clear, and helpful. You have access to tools to read the workspace and files, but do not make any multi-agent plans unless specifically requested.\n\nUser Question:\n${line}` + ); + await this.waitUntilIdle(8 * 60 * 1000); + this.runActive = false; + this.events.emit({ type: "run_end", ok: true }); + } + chat(line: string) { // During questions phase, treat as answers if (this.phase === "questions") { @@ -781,13 +793,13 @@ export class Harness { return; } - // default: if idle, start plan; if executing, route to orch + // default: if idle, start standard chat direct; if executing, route to orch if ( this.phase === "idle" || this.phase === "accepted" || this.phase === "stopped" ) { - void this.startPlan(line); + void this.chatDirect(line); return; } this.agents.get("orchestrator")!.assign(line); diff --git a/src/index.ts b/src/index.ts index a89d8e4..f295c0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -287,11 +287,16 @@ async function main() { process.exit(0); } - if (line.startsWith("/")) { + let cmdLine = line; + if (cmdLine.startsWith(".")) { + cmdLine = "/" + cmdLine.slice(1); + } + + if (cmdLine.startsWith("/")) { // Dispatch command - const [cmd, ..._rest] = line.slice(1).split(/\s+/); + const [cmd, ..._rest] = cmdLine.slice(1).split(/\s+/); const { dispatchCommand } = await import("./commands/registry"); - const res = await dispatchCommand(line, { + const res = await dispatchCommand(cmdLine, { harness, setYolo: () => {}, getYolo: () => false, diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 9444611..6cefd2f 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -55,6 +55,59 @@ type Overlay = type LayoutMode = "dashboard" | "classic"; +function ThinkingSpinner() { + const [frame, setFrame] = useState(0); + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + useEffect(() => { + const timer = setInterval(() => { + setFrame((f) => (f + 1) % frames.length); + }, 80); + return () => clearInterval(timer); + }, []); + + return {frames[frame]} ; +} + +function renderMessageText(text: string, panelWidth: number) { + if (!text) return null; + const parts = text.split("```"); + return parts.map((part, index) => { + const isCode = index % 2 === 1; + if (isCode) { + const lines = part.split("\n"); + const firstLine = lines[0] || ""; + const hasLang = /^[a-zA-Z0-9_-]+$/.test(firstLine.trim()); + const codeLines = hasLang ? lines.slice(1) : lines; + const codeText = codeLines.join("\n").trim(); + return ( + + {hasLang && ( + + {`/* ${firstLine.trim()} */`} + + )} + {codeText} + + ); + } else { + return ( + + {part} + + ); + } + }); +} + function emptyAgent(id: AgentId): AgentPaneModel { return { id, @@ -79,6 +132,7 @@ export function App(props: { const [cols, setCols] = useState(stdout?.columns || 120); const [rows, setRows] = useState(stdout?.rows || 40); const [layoutMode, setLayoutMode] = useState("classic"); // Default is simple/classic chat interface! + const [attachedFiles, setAttachedFiles] = useState<{ path: string; content: string }[]>([]); const [chatLines, setChatLines] = useState<{ id: string; sender: string; text: string; color?: string }[]>([ { id: "welcome-1", sender: "system", text: "Welcome to ArrowCode Classic Mode!", color: "cyan" }, { id: "welcome-2", sender: "system", text: "Type any prompt or /help to explore. Toggle layout with /layout.", color: "gray" }, @@ -366,22 +420,65 @@ export function App(props: { } }); - const onSubmit = async (line: string) => { + const onSubmit = async (inputLine: string) => { + let line = inputLine.trim(); + if (line.startsWith(".")) { + line = "/" + line.slice(1); + } + // Save to user chat log too setChatLines((prev) => [ ...prev.slice(-100), { id: `user-${Date.now()}-${Math.random()}`, sender: "user", - text: line, + text: inputLine, color: "white", }, ]); + if (line.startsWith("/add ")) { + const filePath = line.slice(5).trim(); + try { + const absolutePath = harness.workspace.resolve(filePath, { mustExist: true }); + const fs = require("node:fs"); + const content = fs.readFileSync(absolutePath, "utf8"); + const lineCount = content.split("\n").length; + setAttachedFiles((prev) => [...prev, { path: filePath, content }]); + setSystemLine(`Attached file ${filePath} (${lineCount} lines)`); + setChatLines((prev) => [ + ...prev, + { + id: `add-${Date.now()}`, + sender: "system", + text: `📎 Attached file: ${filePath} (${lineCount} lines). This will be included in your next prompt/plan.`, + color: "green", + }, + ]); + } catch (err: any) { + setSystemLine(`Error reading file: ${err.message}`); + setChatLines((prev) => [ + ...prev, + { + id: `add-err-${Date.now()}`, + sender: "system", + text: `❌ Failed to attach file "${filePath}": ${err.message}`, + color: "red", + }, + ]); + } + return; + } + if (line.startsWith("/")) { // local dashboard commands const [cmd, ...rest] = line.slice(1).split(/\s+/); const c = (cmd || "").toLowerCase(); + if (c === "swarm") { + setLayoutMode((prev) => (prev === "dashboard" ? "classic" : "dashboard")); + setSystemLine(`Layout switched to ${layoutMode === "dashboard" ? "classic" : "dashboard"}`); + return; + } if (c === "replay") { const path = harness.exportReplay(rest[0]); setSystemLine(`Replay exported → ${path}`); @@ -404,7 +501,16 @@ export function App(props: { return; } - const res = await dispatchCommand(line, { + let cmdLine = line; + if (c === "plan" && attachedFiles.length > 0) { + const attachmentBlock = attachedFiles + .map((f) => `=== ATTACHED FILE: ${f.path} ===\n${f.content}\n===============================`) + .join("\n\n"); + cmdLine = `/plan ${attachmentBlock}\n\nUser Request:\n${line.slice(5).trim()}`; + setAttachedFiles([]); + } + + const res = await dispatchCommand(cmdLine, { harness, setYolo, getYolo: () => yolo, @@ -444,7 +550,16 @@ export function App(props: { refreshFiles(); return; } - harness.chat(line); + + let payload = line; + if (attachedFiles.length > 0) { + const attachmentBlock = attachedFiles + .map((f) => `=== ATTACHED FILE: ${f.path} ===\n${f.content}\n===============================`) + .join("\n\n"); + payload = `${attachmentBlock}\n\nUser Prompt:\n${line}`; + setAttachedFiles([]); + } + harness.chat(payload); }; const onSettingsSave = (v: SettingsValues) => { @@ -721,74 +836,28 @@ export function App(props: { ) : ( /* CLASSIC SIMPLE CHAT MODE */ - - {/* LEFT CHAT PANEL: Beautiful classic scrollable Chat stream */} + + {/* FULL SCREEN CHAT PANEL: Beautiful classic scrollable Chat stream */} - {chatLines.slice(-Math.floor(midH / 2)).map((line) => ( - + {chatLines.slice(-10).map((line) => ( + {`[${line.sender}]`} - {line.text.slice(0, leftW - 12)} - - ))} - - - - {/* RIGHT SIDEBAR: Sleek classic high-contrast status card */} - - ┌── STATUS PANEL ──┐ - - - Workspace: - {config.workspace.split("/").pop() || "root"} - - - Provider: - {config.provider} - - - Active: - {phase.toUpperCase()} - - - Swarm: - {`${swarmActive} / ${config.swarm?.maxWorkers ?? 16}`} - - - - - SQUAD STATUS - {panes.map((p) => ( - - {p.id.toUpperCase().slice(0, 4)}: - - {p.status.toUpperCase()} - - + + {renderMessageText(line.text, width)} + + ))} - - - - Toggle UI mode with: /layout - - )} @@ -803,10 +872,16 @@ export function App(props: { - + {systemLine} + {runActive && ( + + + Thinking & executing tools... + + )} {finalText ? ( diff --git a/src/tui/components/InputBar.tsx b/src/tui/components/InputBar.tsx index a18a55b..399350a 100644 --- a/src/tui/components/InputBar.tsx +++ b/src/tui/components/InputBar.tsx @@ -1,7 +1,35 @@ import React, { useState } from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useInput } from "ink"; import TextInput from "ink-text-input"; +const SUGGESTED_COMMANDS = [ + "/plan", + "/swarm", + "/confirm", + "/accept", + "/reject", + "/stop", + "/help", + "/exit", + "/add", + "/settings", + "/status", + "/cost", + "/clear", + "/goal", + ".plan", + ".swarm", + ".confirm", + ".accept" +]; + +function getSuggestion(val: string): string { + if (!val) return ""; + const lowercaseVal = val.toLowerCase(); + const match = SUGGESTED_COMMANDS.find((cmd) => cmd.toLowerCase().startsWith(lowercaseVal) && cmd.toLowerCase() !== lowercaseVal); + return match ? match.slice(val.length) : ""; +} + export function InputBar(props: { width: number; disabled?: boolean; @@ -13,6 +41,18 @@ export function InputBar(props: { const [value, setValue] = useState(""); const { approval } = props; + const suggestion = getSuggestion(value); + + useInput((input, key) => { + if (!props.disabled && !approval) { + if (key.tab || key.rightArrow) { + if (suggestion) { + setValue((v) => v + suggestion); + } + } + } + }); + if (approval) { return ( {"prompt> "} - { - const t = v.trim(); - if (!t) return; - props.onSubmit(t); - setValue(""); - }} - placeholder={ - props.placeholder || - "describe a task | /help /yolo /exit @fe @be @qa @orch" - } - focus={!props.disabled} - /> + + { + const t = v.trim(); + if (!t) return; + props.onSubmit(t); + setValue(""); + }} + placeholder={ + props.placeholder || + "describe a task | /help /yolo /exit @fe @be @qa @orch" + } + focus={!props.disabled} + /> + {suggestion ? ( + + {suggestion} + + ) : null} + ); }