Skip to content

Commit bd3bb74

Browse files
feat: implement full-terminal classic chat, direct queries, autocomplete, and file attachment
- Make Classic simple chat mode the default full-terminal interface - Route standard prompts directly to Orchestrator as single-turn chat - Support `.plan` and other `.` prefixes as aliases for `/` commands - Implement `/swarm` command as a layout toggle command - Add dynamic grey autocomplete suggestions with Tab/Right-Arrow trigger - Support `/add <filepath>` to interactively attach files to prompt context - Include beautiful code-block container frames in the chat log - Add custom Unicode thinking spinner during background tasks
1 parent d340832 commit bd3bb74

4 files changed

Lines changed: 220 additions & 81 deletions

File tree

src/core/harness.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,18 @@ export class Harness {
741741

742742

743743

744+
async chatDirect(line: string) {
745+
this.stopFlag = false;
746+
this.runActive = true;
747+
this.events.emit({ type: "run_start", prompt: line });
748+
this.agents.get("orchestrator")!.assign(
749+
`[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}`
750+
);
751+
await this.waitUntilIdle(8 * 60 * 1000);
752+
this.runActive = false;
753+
this.events.emit({ type: "run_end", ok: true });
754+
}
755+
744756
chat(line: string) {
745757
// During questions phase, treat as answers
746758
if (this.phase === "questions") {
@@ -781,13 +793,13 @@ export class Harness {
781793
return;
782794
}
783795

784-
// default: if idle, start plan; if executing, route to orch
796+
// default: if idle, start standard chat direct; if executing, route to orch
785797
if (
786798
this.phase === "idle" ||
787799
this.phase === "accepted" ||
788800
this.phase === "stopped"
789801
) {
790-
void this.startPlan(line);
802+
void this.chatDirect(line);
791803
return;
792804
}
793805
this.agents.get("orchestrator")!.assign(line);

src/index.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,16 @@ async function main() {
287287
process.exit(0);
288288
}
289289

290-
if (line.startsWith("/")) {
290+
let cmdLine = line;
291+
if (cmdLine.startsWith(".")) {
292+
cmdLine = "/" + cmdLine.slice(1);
293+
}
294+
295+
if (cmdLine.startsWith("/")) {
291296
// Dispatch command
292-
const [cmd, ..._rest] = line.slice(1).split(/\s+/);
297+
const [cmd, ..._rest] = cmdLine.slice(1).split(/\s+/);
293298
const { dispatchCommand } = await import("./commands/registry");
294-
const res = await dispatchCommand(line, {
299+
const res = await dispatchCommand(cmdLine, {
295300
harness,
296301
setYolo: () => {},
297302
getYolo: () => false,

src/tui/App.tsx

Lines changed: 135 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,59 @@ type Overlay =
5555

5656
type LayoutMode = "dashboard" | "classic";
5757

58+
function ThinkingSpinner() {
59+
const [frame, setFrame] = useState(0);
60+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
61+
62+
useEffect(() => {
63+
const timer = setInterval(() => {
64+
setFrame((f) => (f + 1) % frames.length);
65+
}, 80);
66+
return () => clearInterval(timer);
67+
}, []);
68+
69+
return <Text color="cyan" bold>{frames[frame]} </Text>;
70+
}
71+
72+
function renderMessageText(text: string, panelWidth: number) {
73+
if (!text) return null;
74+
const parts = text.split("```");
75+
return parts.map((part, index) => {
76+
const isCode = index % 2 === 1;
77+
if (isCode) {
78+
const lines = part.split("\n");
79+
const firstLine = lines[0] || "";
80+
const hasLang = /^[a-zA-Z0-9_-]+$/.test(firstLine.trim());
81+
const codeLines = hasLang ? lines.slice(1) : lines;
82+
const codeText = codeLines.join("\n").trim();
83+
return (
84+
<Box
85+
key={index}
86+
flexDirection="column"
87+
borderStyle="single"
88+
borderColor="gray"
89+
paddingX={1}
90+
marginY={1}
91+
width={Math.max(20, panelWidth - 8)}
92+
>
93+
{hasLang && (
94+
<Text color="cyan" bold dimColor>
95+
{`/* ${firstLine.trim()} */`}
96+
</Text>
97+
)}
98+
<Text color="yellow">{codeText}</Text>
99+
</Box>
100+
);
101+
} else {
102+
return (
103+
<Text key={index} color="white">
104+
{part}
105+
</Text>
106+
);
107+
}
108+
});
109+
}
110+
58111
function emptyAgent(id: AgentId): AgentPaneModel {
59112
return {
60113
id,
@@ -79,6 +132,7 @@ export function App(props: {
79132
const [cols, setCols] = useState(stdout?.columns || 120);
80133
const [rows, setRows] = useState(stdout?.rows || 40);
81134
const [layoutMode, setLayoutMode] = useState<LayoutMode>("classic"); // Default is simple/classic chat interface!
135+
const [attachedFiles, setAttachedFiles] = useState<{ path: string; content: string }[]>([]);
82136
const [chatLines, setChatLines] = useState<{ id: string; sender: string; text: string; color?: string }[]>([
83137
{ id: "welcome-1", sender: "system", text: "Welcome to ArrowCode Classic Mode!", color: "cyan" },
84138
{ 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: {
366420
}
367421
});
368422

369-
const onSubmit = async (line: string) => {
423+
const onSubmit = async (inputLine: string) => {
424+
let line = inputLine.trim();
425+
if (line.startsWith(".")) {
426+
line = "/" + line.slice(1);
427+
}
428+
370429
// Save to user chat log too
371430
setChatLines((prev) => [
372431
...prev.slice(-100),
373432
{
374433
id: `user-${Date.now()}-${Math.random()}`,
375434
sender: "user",
376-
text: line,
435+
text: inputLine,
377436
color: "white",
378437
},
379438
]);
380439

440+
if (line.startsWith("/add ")) {
441+
const filePath = line.slice(5).trim();
442+
try {
443+
const absolutePath = harness.workspace.resolve(filePath, { mustExist: true });
444+
const fs = require("node:fs");
445+
const content = fs.readFileSync(absolutePath, "utf8");
446+
const lineCount = content.split("\n").length;
447+
setAttachedFiles((prev) => [...prev, { path: filePath, content }]);
448+
setSystemLine(`Attached file ${filePath} (${lineCount} lines)`);
449+
setChatLines((prev) => [
450+
...prev,
451+
{
452+
id: `add-${Date.now()}`,
453+
sender: "system",
454+
text: `📎 Attached file: ${filePath} (${lineCount} lines). This will be included in your next prompt/plan.`,
455+
color: "green",
456+
},
457+
]);
458+
} catch (err: any) {
459+
setSystemLine(`Error reading file: ${err.message}`);
460+
setChatLines((prev) => [
461+
...prev,
462+
{
463+
id: `add-err-${Date.now()}`,
464+
sender: "system",
465+
text: `❌ Failed to attach file "${filePath}": ${err.message}`,
466+
color: "red",
467+
},
468+
]);
469+
}
470+
return;
471+
}
472+
381473
if (line.startsWith("/")) {
382474
// local dashboard commands
383475
const [cmd, ...rest] = line.slice(1).split(/\s+/);
384476
const c = (cmd || "").toLowerCase();
477+
if (c === "swarm") {
478+
setLayoutMode((prev) => (prev === "dashboard" ? "classic" : "dashboard"));
479+
setSystemLine(`Layout switched to ${layoutMode === "dashboard" ? "classic" : "dashboard"}`);
480+
return;
481+
}
385482
if (c === "replay") {
386483
const path = harness.exportReplay(rest[0]);
387484
setSystemLine(`Replay exported → ${path}`);
@@ -404,7 +501,16 @@ export function App(props: {
404501
return;
405502
}
406503

407-
const res = await dispatchCommand(line, {
504+
let cmdLine = line;
505+
if (c === "plan" && attachedFiles.length > 0) {
506+
const attachmentBlock = attachedFiles
507+
.map((f) => `=== ATTACHED FILE: ${f.path} ===\n${f.content}\n===============================`)
508+
.join("\n\n");
509+
cmdLine = `/plan ${attachmentBlock}\n\nUser Request:\n${line.slice(5).trim()}`;
510+
setAttachedFiles([]);
511+
}
512+
513+
const res = await dispatchCommand(cmdLine, {
408514
harness,
409515
setYolo,
410516
getYolo: () => yolo,
@@ -444,7 +550,16 @@ export function App(props: {
444550
refreshFiles();
445551
return;
446552
}
447-
harness.chat(line);
553+
554+
let payload = line;
555+
if (attachedFiles.length > 0) {
556+
const attachmentBlock = attachedFiles
557+
.map((f) => `=== ATTACHED FILE: ${f.path} ===\n${f.content}\n===============================`)
558+
.join("\n\n");
559+
payload = `${attachmentBlock}\n\nUser Prompt:\n${line}`;
560+
setAttachedFiles([]);
561+
}
562+
harness.chat(payload);
448563
};
449564

450565
const onSettingsSave = (v: SettingsValues) => {
@@ -721,74 +836,28 @@ export function App(props: {
721836
</Box>
722837
) : (
723838
/* CLASSIC SIMPLE CHAT MODE */
724-
<Box flexDirection="row" height={midH}>
725-
{/* LEFT CHAT PANEL: Beautiful classic scrollable Chat stream */}
839+
<Box flexDirection="column" height={midH}>
840+
{/* FULL SCREEN CHAT PANEL: Beautiful classic scrollable Chat stream */}
726841
<Box
727842
flexDirection="column"
728-
width={leftW}
843+
width={width}
729844
height={midH}
730845
borderStyle="single"
731846
borderColor="gray"
732847
paddingX={1}
733848
>
734849
<Box flexDirection="column" height={midH - 2}>
735-
{chatLines.slice(-Math.floor(midH / 2)).map((line) => (
736-
<Text key={line.id}>
850+
{chatLines.slice(-10).map((line) => (
851+
<Box key={line.id} flexDirection="column" marginY={0} paddingBottom={1}>
737852
<Text color={line.color || "white"} bold>
738853
{`[${line.sender}]`}
739854
</Text>
740-
<Text color="white"> {line.text.slice(0, leftW - 12)}</Text>
741-
</Text>
742-
))}
743-
</Box>
744-
</Box>
745-
746-
{/* RIGHT SIDEBAR: Sleek classic high-contrast status card */}
747-
<Box
748-
flexDirection="column"
749-
width={rightW}
750-
height={midH}
751-
borderStyle="single"
752-
borderColor="cyan"
753-
paddingX={1}
754-
>
755-
<Text color="cyan" bold>┌── STATUS PANEL ──┐</Text>
756-
<Box flexDirection="column" marginTop={1}>
757-
<Text>
758-
<Text color="gray">Workspace: </Text>
759-
<Text color="white">{config.workspace.split("/").pop() || "root"}</Text>
760-
</Text>
761-
<Text>
762-
<Text color="gray">Provider: </Text>
763-
<Text color="magenta">{config.provider}</Text>
764-
</Text>
765-
<Text>
766-
<Text color="gray">Active: </Text>
767-
<Text color="yellow">{phase.toUpperCase()}</Text>
768-
</Text>
769-
<Text>
770-
<Text color="gray">Swarm: </Text>
771-
<Text color="green">{`${swarmActive} / ${config.swarm?.maxWorkers ?? 16}`}</Text>
772-
</Text>
773-
</Box>
774-
775-
<Box flexDirection="column" marginTop={1} borderStyle="single" borderColor="gray">
776-
<Text color="cyan" bold> SQUAD STATUS </Text>
777-
{panes.map((p) => (
778-
<Text key={p.id}>
779-
<Text color="gray">{p.id.toUpperCase().slice(0, 4)}: </Text>
780-
<Text color={p.status === "thinking" ? "yellow" : "green"}>
781-
{p.status.toUpperCase()}
782-
</Text>
783-
</Text>
855+
<Box flexDirection="column" paddingLeft={2}>
856+
{renderMessageText(line.text, width)}
857+
</Box>
858+
</Box>
784859
))}
785860
</Box>
786-
787-
<Box marginTop={1}>
788-
<Text color="gray" dimColor>
789-
Toggle UI mode with: /layout
790-
</Text>
791-
</Box>
792861
</Box>
793862
</Box>
794863
)}
@@ -803,10 +872,16 @@ export function App(props: {
803872
<Timeline events={timeline} width={timeW} height={bottomH} />
804873
</Box>
805874

806-
<Box paddingX={1} width={width}>
875+
<Box paddingX={1} width={width} flexDirection="row" justifyContent="space-between">
807876
<Text color="gray" wrap="truncate">
808877
{systemLine}
809878
</Text>
879+
{runActive && (
880+
<Box flexDirection="row">
881+
<ThinkingSpinner />
882+
<Text color="cyan" bold>Thinking & executing tools...</Text>
883+
</Box>
884+
)}
810885
</Box>
811886

812887
{finalText ? (

0 commit comments

Comments
 (0)