Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions coverage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
ℹ index.js | 100.00 | 100.00 | 100.00 |
ℹ memory.js | 97.58 | 83.78 | 93.75 | 52 95-96 191-195
ℹ moa.js | 100.00 | 96.77 | 80.00 |
ℹ sampling.js | 92.51 | 90.32 | 62.50 | 24 194 197 202-215
ℹ sampling.js | 92.51 | 87.50 | 62.50 | 24 194 197 202-215
ℹ sessionSearch.js | 97.21 | 75.44 | 89.47 | 64-65 111-112 121 174-175
ℹ skills.js | 79.74 | 86.89 | 44.44 | 23-43 68-100 156-157 184-185 212-220 231-238 258-265 281-283 298-299 396-402
ℹ terminal.js | 93.62 | 81.63 | 77.78 | 38-41 74 102-103 190-191 197-199 205-206 213-214 221-222 224
Expand All @@ -66,13 +66,34 @@
ℹ tui | | | |
ℹ banner.js | 90.00 | 100.00 | 85.71 | 45-52
ℹ commandParser.js | 100.00 | 88.64 | 100.00 |
ℹ conversationPanel.js | 89.30 | 85.37 | 88.89 | 86-97 102-109 172-183 349-354 360-361
ℹ conversationPanel.js | 89.39 | 86.42 | 88.89 | 86-97 102-109 172-183 352-357 363-364
ℹ inputPanel.js | 92.06 | 80.00 | 75.00 | 59-63
ℹ markdownText.js | 100.00 | 100.00 | 100.00 |
ℹ messages.js | 100.00 | 94.44 | 100.00 |
ℹ panels.js | 100.00 | 100.00 | 100.00 |
ℹ statusBar.js | 96.55 | 85.71 | 100.00 | 14-15
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ all files | 92.56 | 86.35 | 81.50 |
ℹ all files | 92.57 | 86.35 | 81.50 |
ℹ ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
ℹ end of coverage report

✖ failing tests:

test at tests/unit/cron_sync.test.js:80:2
✖ adds a job that exists on disk but not in crontab (22.773256ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

3 !== 0

at TestContext.<anonymous> (file:///home/jason/Projects/madz/tests/unit/cron_sync.test.js:89:11)
at async Test.run (node:internal/test_runner/test:1208:7)
at async Promise.all (index 0)
at async Suite.run (node:internal/test_runner/test:1619:7)
at async startSubtestAfterBootstrap (node:internal/test_runner/harness:385:3) {
generatedMessage: true,
code: 'ERR_ASSERTION',
actual: 3,
expected: 0,
operator: 'strictEqual',
diff: 'simple'
}
19 changes: 14 additions & 5 deletions src/tui/app.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { Box, useWindowSize } from "ink";
import { Box, useWindowSize, useApp } from "ink";
import { useInput } from "ink";
import { CommandParser } from "./commandParser.js";
import { ConversationPanel } from "./conversationPanel.js";
Expand Down Expand Up @@ -35,6 +35,10 @@ export default function App({
const [inputText, setInputText] = useState("");
const [inputFocused, setInputFocused] = useState(true);
const scrollRef = useRef(null);
const isQuittingRef = useRef(false);
const { exit } = useApp();
const exitRef = useRef(exit);
exitRef.current = exit;

const skillList = registry ? registry.list() : [];

Expand Down Expand Up @@ -137,6 +141,7 @@ export default function App({
* @param {string} text - The user's message text
*/
const handleChat = async (text) => {
if (isQuittingRef.current) return;
setStatusMessage("Streaming...");
addMessage({ role: "user", content: text });

Expand All @@ -162,6 +167,7 @@ export default function App({
text,
sessionState ? sessionState.getProvider() : null,
(event) => {
if (isQuittingRef.current) return;
try {
if (event.type === "text") {
committedContent = (committedContent || "") + event.text;
Expand Down Expand Up @@ -194,7 +200,6 @@ export default function App({
return cloned;
});
} else if (event.type === "tool_end") {
activeToolCall = null;
const resultLine = event.data
? ` Result: ${JSON.stringify(event.data).slice(0, 200)}`
: "";
Expand All @@ -213,7 +218,6 @@ export default function App({
return cloned;
});
} else if (event.type === "tool_error") {
activeToolCall = null;
const errorLine = event.toolName
? `- Tool: ${event.toolName} (error: ${event.error})`
: `- Tool call failed (${event.toolCallId || "unknown"})`;
Expand All @@ -240,6 +244,8 @@ export default function App({
// originalMessage fallback from callReactAgentStreaming.
const responseContent = committedContent;

if (isQuittingRef.current) return;

setMessages((prev) => {
const cloned = [...prev];
const last = cloned[cloned.length - 1];
Expand Down Expand Up @@ -283,6 +289,9 @@ export default function App({
};

const handleQuit = () => {
isQuittingRef.current = true;
exit();
// Force process exit to break pending async streams
process.exit(0);
};

Expand Down Expand Up @@ -312,7 +321,7 @@ export default function App({
if (trimmed === "exit") {
setShowBanner(true);
setShowOnboarding(false);
process.exit(0);
exitRef.current();
return true;
}

Expand All @@ -321,7 +330,7 @@ export default function App({
if (result.action === "exit") {
setShowBanner(true);
setShowOnboarding(false);
process.exit(0);
exitRef.current();
return true;
}

Expand Down
15 changes: 9 additions & 6 deletions src/tui/conversationPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ const MessageBubble = React.memo(
{ flexDirection: "row", marginTop: 1, marginLeft: 2 },
React.createElement(
Text,
{ dim: true, color: "gray" },
{ dimColor: true, color: "gray" },
`(thinking) ` +
(msg.reasoningContent || "").slice(0, 200) +
(msg.reasoningContent && msg.reasoningContent.length > 200
Expand All @@ -103,7 +103,7 @@ const MessageBubble = React.memo(
{ flexDirection: "row", marginTop: 1, marginLeft: 2 },
React.createElement(
Text,
{ dim: true, color: "gray" },
{ dimColor: true, color: "gray" },
`- Running: ${msg.activeToolCall.name} \u00b7\u00b7\u00b7`,
),
)
Expand Down Expand Up @@ -179,7 +179,7 @@ const MessageBubble = React.memo(
p.streaming === n.streaming &&
p.toolCallDisplay === n.toolCallDisplay &&
p.activeToolCall === n.activeToolCall &&
p._index === nextProps.assistantName
p._index === n._index
);
},
);
Expand Down Expand Up @@ -210,7 +210,11 @@ export function renderMessages(messages, assistantName) {

if (messages.length === 0) {
children.push(
React.createElement(Text, { key: "empty", gray: true }, " No messages yet. Start chatting!"),
React.createElement(
Text,
{ key: "empty", color: "gray" },
" No messages yet. Start chatting!",
),
);
}

Expand Down Expand Up @@ -328,7 +332,6 @@ export function ConversationPanel({

// Handle terminal resize by remeasuring content heights
useEffect(() => {
if (!stdout) return;
const resizeHandler = () => executeResize(scrollRef.current);
stdout.on("resize", resizeHandler);
return () => {
Expand Down Expand Up @@ -369,6 +372,6 @@ export function ConversationPanel({
return React.createElement(
Box,
{ key: "panel", flexDirection: "column", flexGrow: 1 },
React.createElement(ScrollView, { ref: scrollRef, key: "scroll" }, ...children),
React.createElement(ScrollView, { ref: scrollRef, key: "scroll", focus: false }, ...children),
);
}
35 changes: 19 additions & 16 deletions src/tui/memoryPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,30 @@ import { useInput } from "ink";
* Memory panel that displays index entries with file viewer.
* Props: entries - array of { title, path, timestamp }
*/
export function MemoryPanel({ entries = [] }) {
export function MemoryPanel({ entries = [], isActive = false }) {
const [selectedEntry, setSelectedEntry] = useState(null);
const [focusIndex, setFocusIndex] = useState(0);

const visibleEntries = entries.slice(0, 30);

useInput((_, key) => {
if (key.up && focusIndex > 0) {
setFocusIndex((prev) => Math.max(0, prev - 1));
}
if (key.down && focusIndex < visibleEntries.length - 1) {
setFocusIndex((prev) => Math.min(visibleEntries.length - 1, prev + 1));
}
if (key.space) {
const entry = visibleEntries[focusIndex] || visibleEntries[0];
if (entry) setSelectedEntry(entry);
}
if (key.escape) {
setSelectedEntry(null);
}
});
useInput(
(input, key) => {
if (key.upArrow && focusIndex > 0) {
setFocusIndex((prev) => Math.max(0, prev - 1));
}
if (key.downArrow && focusIndex < visibleEntries.length - 1) {
setFocusIndex((prev) => Math.min(visibleEntries.length - 1, prev + 1));
}
if (input === " ") {
const entry = visibleEntries[focusIndex] || visibleEntries[0];
if (entry) setSelectedEntry(entry);
}
if (key.escape) {
setSelectedEntry(null);
}
},
{ isActive },
);

return (
<Box flexDirection="row">
Expand Down
1 change: 0 additions & 1 deletion src/tui/onboardingPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ export function OnboardingPanel({ onboarding, onComplete, _onExit, responseId })
key: "msg-" + i,
borderStyle: "round",
borderColor: "yellow",
growDirection: "down",
width: BOX_WIDTH,
paddingX: 1,
},
Expand Down
33 changes: 18 additions & 15 deletions src/tui/settingsPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,27 @@ import { useInput } from "ink";
* Settings panel that shows current config sections.
* Props: configSections - array of section names
*/
export function SettingsPanel({ configSections = [] }) {
export function SettingsPanel({ configSections = [], isActive = false }) {
const [focusIndex, setFocusIndex] = useState(0);
const [selectedSection, setSelectedSection] = useState(null);

useInput((_, key) => {
if (key.up && focusIndex > 0) {
setFocusIndex((prev) => Math.max(0, prev - 1));
}
if (key.down && focusIndex < configSections.length - 1) {
setFocusIndex((prev) => Math.min(configSections.length - 1, prev + 1));
}
if (key.enter) {
setSelectedSection(configSections[focusIndex] || null);
}
if (key.escape) {
setSelectedSection(null);
}
});
useInput(
(_, key) => {
if (key.upArrow && focusIndex > 0) {
setFocusIndex((prev) => Math.max(0, prev - 1));
}
if (key.downArrow && focusIndex < configSections.length - 1) {
setFocusIndex((prev) => Math.min(configSections.length - 1, prev + 1));
}
if (key.return) {
setSelectedSection(configSections[focusIndex] || null);
}
if (key.escape) {
setSelectedSection(null);
}
},
{ isActive },
);

return (
<Box flexDirection="row">
Expand Down
21 changes: 12 additions & 9 deletions src/tui/skillsPanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import { useInput } from "ink";
* Skills panel that lists registered skills with search.
* Props: skills - array of skill names
*/
export function SkillsPanel({ skills = [] }) {
export function SkillsPanel({ skills = [], isActive = false }) {
const [searchQuery, _setSearchQuery] = useState("");
const [focusedSkill, setFocusedSkill] = useState(0);

const filteredSkills = skills.filter((s) => s.toLowerCase().includes(searchQuery.toLowerCase()));

useInput((_, key) => {
if (key.up && focusedSkill > 0) {
setFocusedSkill((prev) => Math.max(0, prev - 1));
}
if (key.down && focusedSkill < filteredSkills.length - 1) {
setFocusedSkill((prev) => Math.min(filteredSkills.length - 1, prev + 1));
}
});
useInput(
(_, key) => {
if (key.upArrow && focusedSkill > 0) {
setFocusedSkill((prev) => Math.max(0, prev - 1));
}
if (key.downArrow && focusedSkill < filteredSkills.length - 1) {
setFocusedSkill((prev) => Math.min(filteredSkills.length - 1, prev + 1));
}
},
{ isActive },
);

return (
<Box flexDirection="column">
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/conversationPanel.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ describe("ConversationPanel - renderMessages", () => {
assert.ok(Array.isArray(result));
assert.strictEqual(result.length, 1);
assert.ok(React.isValidElement(result[0]));
assert.strictEqual(result[0].props.gray, true);
assert.strictEqual(result[0].props.color, "gray");
});

it("renders user message with correct alignment", () => {
Expand Down