Skip to content

Commit 046d235

Browse files
committed
WIP
1 parent c0da41e commit 046d235

File tree

6 files changed

+133
-13
lines changed

6 files changed

+133
-13
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"@vitest/coverage-v8": "^3.2.4",
4949
"eslint": "^8.57.0",
5050
"eslint-config-prettier": "^9.1.0",
51+
"ink-testing-library": "^4.0.0",
5152
"nodemon": "^3.1.4",
5253
"prettier": "^3.3.2",
5354
"tsc-alias": "^1.8.10",

src/agent/state.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,28 @@ import { create } from "zustand";
33
import type { Message } from "./types.js";
44
import { getNextAssistantResponse } from "./llm.js";
55
import { runTool } from "./tools/index.js";
6-
import { loadConfig, type Config } from "@/config.js";
6+
import { loadConfig, type Config, HISTORY_PATH, CONFIG_DIR } from "@/config.js";
77
import type { ToolCallPart } from "ai";
88
import { FatalError, ToolError, TransientError } from "./errors.js";
9+
import fs from "fs";
10+
import path from "path";
11+
12+
function loadCommandHistory(): string[] {
13+
try {
14+
if (!fs.existsSync(HISTORY_PATH)) {
15+
fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true });
16+
}
17+
const historyContent = fs.readFileSync(HISTORY_PATH, "utf-8");
18+
return historyContent.split("\n").filter(Boolean);
19+
} catch (error) {
20+
return [];
21+
}
22+
}
923

1024
type AppState = {
1125
history: Message[];
26+
commandHistory: string[];
27+
commandHistoryIndex: number;
1228
mode: "idle" | "thinking" | "running-tool";
1329
config: Config | null;
1430
helpMenuOpen: boolean;
@@ -21,6 +37,9 @@ type AppActions = {
2137
_runAgentLogic: (retryCount?: number) => Promise<void>;
2238
toggleHelpMenu: () => void;
2339
clearHistory: () => void;
40+
addCommandToHistory: (command: string) => void;
41+
getPreviousCommand: () => string | null;
42+
getNextCommand: () => string | null;
2443
};
2544
};
2645

@@ -29,6 +48,8 @@ const INITIAL_BACKOFF_MS = 1000;
2948

3049
export const useStore = create<AppState & AppActions>((set, get) => ({
3150
history: [],
51+
commandHistory: loadCommandHistory(),
52+
commandHistoryIndex: loadCommandHistory().length,
3253
mode: "idle",
3354
config: null,
3455
helpMenuOpen: false,
@@ -41,9 +62,52 @@ export const useStore = create<AppState & AppActions>((set, get) => ({
4162
set((state) => ({ helpMenuOpen: !state.helpMenuOpen }));
4263
},
4364
clearHistory: () => {
44-
set({ history: [] });
65+
set({ history: [], commandHistory: [], commandHistoryIndex: 0 });
66+
try {
67+
fs.writeFileSync(HISTORY_PATH, "");
68+
} catch (err) {
69+
// Handle error, e.g., log it
70+
console.error("Failed to clear history file:", err);
71+
}
72+
},
73+
addCommandToHistory: (command) => {
74+
const { commandHistory } = get();
75+
const newCommandHistory = [...commandHistory, command];
76+
set({
77+
commandHistory: newCommandHistory,
78+
commandHistoryIndex: newCommandHistory.length,
79+
});
80+
try {
81+
fs.appendFileSync(HISTORY_PATH, command + "\n");
82+
} catch (err) {
83+
// Handle error, e.g., log it
84+
console.error("Failed to write to history file:", err);
85+
}
86+
},
87+
getPreviousCommand: () => {
88+
const { commandHistory, commandHistoryIndex } = get();
89+
if (commandHistoryIndex > 0) {
90+
const newIndex = commandHistoryIndex - 1;
91+
set({ commandHistoryIndex: newIndex });
92+
return commandHistory[newIndex] ?? null;
93+
}
94+
return null;
95+
},
96+
getNextCommand: () => {
97+
const { commandHistory, commandHistoryIndex } = get();
98+
if (commandHistoryIndex < commandHistory.length - 1) {
99+
const newIndex = commandHistoryIndex + 1;
100+
set({ commandHistoryIndex: newIndex });
101+
return commandHistory[newIndex] ?? null;
102+
}
103+
if (commandHistoryIndex === commandHistory.length - 1) {
104+
set({ commandHistoryIndex: commandHistory.length });
105+
return "";
106+
}
107+
return null;
45108
},
46109
startAgent: async (input) => {
110+
get().actions.addCommandToHistory(input);
47111
const newHistory: Message[] = [
48112
...get().history,
49113
{ role: "user", content: input, id: crypto.randomUUID() },

src/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ const defaultConfig: Config = {
7979
mcpServers: {},
8080
};
8181

82-
const CONFIG_DIR = path.join(os.homedir(), ".config", "tobi");
82+
export const CONFIG_DIR = path.join(os.homedir(), ".config", "tobi");
83+
export const HISTORY_PATH = path.join(CONFIG_DIR, "history");
8384
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json5");
8485

8586
export async function loadConfig(): Promise<Config> {

src/ui/History.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,14 @@ function UserMessageContent({ content }: { content: CoreMessage["content"] }) {
4949
const userContent = Array.isArray(content)
5050
? content.map((part) => ("text" in part ? part.text : "")).join("")
5151
: content;
52-
return <Text>&gt; {userContent}</Text>;
52+
53+
const color = userContent.startsWith("/") ? "magenta" : "white";
54+
55+
return (
56+
<Text>
57+
<Text color={color}>&gt; {userContent}</Text>
58+
</Text>
59+
);
5360
}
5461

5562
export function History() {

src/ui/UserInput.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,43 @@
11
// src/ui/UserInput.tsx
22
import React, { useState } from "react";
3-
import { Box, Text, useApp } from "ink";
3+
import { Box, Text, useApp, useInput } from "ink";
44
import TextInput from "ink-text-input";
55
import { useStore } from "@/agent/state.js";
66
import { useShallow } from "zustand/react/shallow";
77

88
export function UserInput() {
99
const [useStateInput, setInputValue] = useState("");
10-
const { startAgent, mode, toggleHelpMenu, clearHistory } = useStore(
11-
useShallow((s) => ({
12-
startAgent: s.actions.startAgent,
13-
mode: s.mode,
14-
toggleHelpMenu: s.actions.toggleHelpMenu,
15-
clearHistory: s.actions.clearHistory,
16-
})),
17-
);
10+
const { startAgent, mode, toggleHelpMenu, clearHistory, getPreviousCommand, getNextCommand } =
11+
useStore(
12+
useShallow((s) => ({
13+
startAgent: s.actions.startAgent,
14+
mode: s.mode,
15+
toggleHelpMenu: s.actions.toggleHelpMenu,
16+
clearHistory: s.actions.clearHistory,
17+
getPreviousCommand: s.actions.getPreviousCommand,
18+
getNextCommand: s.actions.getNextCommand,
19+
})),
20+
);
1821
const { exit } = useApp();
1922

2023
const isThinking = mode !== "idle";
2124

25+
useInput((input, key) => {
26+
if (!isThinking) {
27+
if (key.upArrow) {
28+
const prevCommand = getPreviousCommand();
29+
if (prevCommand) {
30+
setInputValue(prevCommand);
31+
}
32+
} else if (key.downArrow) {
33+
const nextCommand = getNextCommand();
34+
setInputValue(nextCommand ?? "");
35+
} else if (key.ctrl && input === "l") {
36+
clearHistory();
37+
}
38+
}
39+
});
40+
2241
const handleSubmit = () => {
2342
const value = useStateInput.trim();
2443
if (value && !isThinking) {

tests/ui/History.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from "react";
2+
import { render } from "ink-testing-library";
3+
import { History } from "@/ui/History";
4+
import { useStore } from "@/agent/state";
5+
import { describe, it, expect, vi } from "vitest";
6+
import { Text } from "ink";
7+
8+
// Mock the zustand store
9+
vi.mock("@/agent/state");
10+
11+
describe("History", () => {
12+
it("should render slashed commands in magenta", () => {
13+
const history = [
14+
{ id: "1", role: "user", content: "/help" },
15+
{ id: "2", role: "user", content: "hello" },
16+
];
17+
18+
(useStore as any).mockReturnValue(history);
19+
20+
const { lastFrame } = render(<History />);
21+
22+
// The output of lastFrame() does not contain color information,
23+
// so we can't directly test for magenta.
24+
// Instead, we can check that the correct text is rendered.
25+
expect(lastFrame()).toContain("> /help");
26+
expect(lastFrame()).toContain("> hello");
27+
});
28+
});

0 commit comments

Comments
 (0)