@whatasoda/agent-tools 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/agents/codex-review/body.md +98 -0
  2. package/dist/agents/team-reviewer/body.md +78 -0
  3. package/dist/agents/team-worker/body.md +46 -0
  4. package/dist/scripts/codex-review.js +237 -0
  5. package/dist/scripts/detect-base-branch.js +185 -0
  6. package/dist/scripts/resolve-session.js +76 -0
  7. package/dist/skills/soda-brief/body.md +73 -0
  8. package/dist/skills/soda-discuss/README.md +25 -0
  9. package/dist/skills/soda-discuss/body.md +216 -0
  10. package/dist/skills/soda-fix/body.md +137 -0
  11. package/dist/skills/soda-plan/body.md +333 -0
  12. package/dist/skills/soda-research/body.md +127 -0
  13. package/dist/skills/soda-review/body.md +165 -0
  14. package/dist/skills/soda-review-todos/body.md +19 -0
  15. package/dist/skills/soda-team-init/body.md +313 -0
  16. package/dist/skills/soda-team-init/references/coordination-files.md +188 -0
  17. package/dist/skills/soda-team-run/body.md +329 -0
  18. package/dist/skills/soda-todo/body.md +86 -0
  19. package/dist/src/cli/commands/agent.js +29 -0
  20. package/dist/src/cli/commands/codex-review.js +14 -0
  21. package/dist/src/cli/commands/decision.js +103 -0
  22. package/dist/src/cli/commands/import.js +174 -0
  23. package/dist/src/cli/commands/link.js +52 -0
  24. package/dist/src/cli/commands/list.js +12 -0
  25. package/dist/src/cli/commands/node.js +118 -0
  26. package/dist/src/cli/commands/review.js +23 -0
  27. package/dist/src/cli/commands/session.js +23 -0
  28. package/dist/src/cli/commands/skill.js +29 -0
  29. package/dist/src/cli/commands/tag.js +31 -0
  30. package/dist/src/cli/helpers.js +51 -0
  31. package/dist/src/cli/index.js +48 -0
  32. package/dist/src/cli.js +59 -0
  33. package/dist/src/core/database.js +209 -0
  34. package/dist/src/core/ensure-dirs.js +8 -0
  35. package/dist/src/core/index.js +1 -0
  36. package/dist/src/core/kinds.js +46 -0
  37. package/dist/src/core/schema.sql +36 -0
  38. package/dist/src/core/schemas.js +41 -0
  39. package/dist/src/core/search.js +80 -0
  40. package/dist/src/core/types.js +0 -0
  41. package/dist/src/tui/App.js +130 -0
  42. package/dist/src/tui/actions.js +9 -0
  43. package/dist/src/tui/components/FilterBar.js +46 -0
  44. package/dist/src/tui/components/LinkList.js +53 -0
  45. package/dist/src/tui/components/NodeDetail.js +111 -0
  46. package/dist/src/tui/components/NodeList.js +62 -0
  47. package/dist/src/tui/components/StatusBar.js +90 -0
  48. package/dist/src/tui/hooks/useNavigation.js +57 -0
  49. package/dist/src/tui/hooks/useNodes.js +44 -0
  50. package/dist/src/tui/index.js +4 -0
  51. package/package.json +29 -0
@@ -0,0 +1,209 @@
1
+ import { Database as BunDatabase } from "bun:sqlite";
2
+ import { ulid } from "ulid";
3
+ import path from "path";
4
+ import { readFileSync } from "fs";
5
+ import { validateProperties } from "./kinds.js";
6
+ import { SearchIndex } from "./search.js";
7
+ function rowToNode(row) {
8
+ return {
9
+ body: row.body,
10
+ created_at: row.created_at,
11
+ id: row.id,
12
+ kind: row.kind,
13
+ properties: JSON.parse(row.properties),
14
+ updated_at: row.updated_at
15
+ };
16
+ }
17
+
18
+ export class Database {
19
+ db;
20
+ searchIndex;
21
+ constructor(dbPath = ":memory:") {
22
+ this.db = new BunDatabase(dbPath, { create: true, strict: true });
23
+ this.db.exec("PRAGMA journal_mode = WAL");
24
+ this.db.exec("PRAGMA foreign_keys = ON");
25
+ this.initSchema();
26
+ this.searchIndex = new SearchIndex(this.db);
27
+ }
28
+ initSchema() {
29
+ const schemaPath = path.join(import.meta.dir, "schema.sql");
30
+ const sql = readFileSync(schemaPath, "utf-8");
31
+ this.db.exec(sql);
32
+ }
33
+ getNodeWithRelations(id) {
34
+ const nodeRow = this.db.query("SELECT id, kind, body, properties, created_at, updated_at FROM nodes WHERE id = ?").get(id);
35
+ if (!nodeRow) {
36
+ return null;
37
+ }
38
+ const node = rowToNode(nodeRow);
39
+ const tagRows = this.db.query("SELECT tag FROM tags WHERE node_id = ?").all(id);
40
+ const tags = tagRows.map((r) => r.tag);
41
+ const linksFromRows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE from_id = ?").all(id);
42
+ const links_from = linksFromRows.map((r) => ({
43
+ created_at: r.created_at,
44
+ from_id: r.from_id,
45
+ link_type: r.link_type,
46
+ to_id: r.to_id
47
+ }));
48
+ const linksToRows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE to_id = ?").all(id);
49
+ const links_to = linksToRows.map((r) => ({
50
+ created_at: r.created_at,
51
+ from_id: r.from_id,
52
+ link_type: r.link_type,
53
+ to_id: r.to_id
54
+ }));
55
+ return { ...node, links_from, links_to, tags };
56
+ }
57
+ createNode(input) {
58
+ const { kind, body = "", properties = {}, tags = [] } = input;
59
+ const validatedProps = validateProperties(kind, properties);
60
+ const id = ulid();
61
+ const now = new Date().toISOString();
62
+ const insertTx = this.db.transaction(() => {
63
+ this.db.query("INSERT INTO nodes (id, kind, body, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(id, kind, body, JSON.stringify(validatedProps), now, now);
64
+ for (const tag of tags) {
65
+ this.db.query("INSERT INTO tags (node_id, tag) VALUES (?, ?)").run(id, tag);
66
+ }
67
+ });
68
+ insertTx();
69
+ const result = this.getNodeWithRelations(id);
70
+ if (!result) {
71
+ throw new Error(`Failed to retrieve node after creation: ${id}`);
72
+ }
73
+ this.searchIndex.indexNode(result);
74
+ return result;
75
+ }
76
+ getNode(id) {
77
+ return this.getNodeWithRelations(id);
78
+ }
79
+ updateNode(input) {
80
+ const { id, body, kind, properties } = input;
81
+ const existing = this.getNodeWithRelations(id);
82
+ if (!existing) {
83
+ throw new Error(`Node not found: ${id}`);
84
+ }
85
+ this.searchIndex.removeNode(id, existing);
86
+ const newKind = kind === undefined ? existing.kind : kind;
87
+ const newProperties = properties === undefined ? existing.properties : properties;
88
+ let validatedProps = existing.properties;
89
+ if (kind !== undefined || properties !== undefined) {
90
+ validatedProps = validateProperties(newKind, newProperties);
91
+ }
92
+ const now = new Date().toISOString();
93
+ const setClauses = [];
94
+ const params = [];
95
+ if (body !== undefined) {
96
+ setClauses.push("body = ?");
97
+ params.push(body);
98
+ }
99
+ if (kind !== undefined) {
100
+ setClauses.push("kind = ?");
101
+ params.push(kind);
102
+ }
103
+ if (kind !== undefined || properties !== undefined) {
104
+ setClauses.push("properties = ?");
105
+ params.push(JSON.stringify(validatedProps));
106
+ }
107
+ setClauses.push("updated_at = ?");
108
+ params.push(now);
109
+ params.push(id);
110
+ const sql = `UPDATE nodes SET ${setClauses.join(", ")} WHERE id = ?`;
111
+ this.db.query(sql).run(...params);
112
+ const result = this.getNodeWithRelations(id);
113
+ if (!result) {
114
+ throw new Error(`Failed to retrieve node after update: ${id}`);
115
+ }
116
+ this.searchIndex.indexNode(result);
117
+ return result;
118
+ }
119
+ deleteNode(id) {
120
+ const existing = this.getNodeWithRelations(id);
121
+ if (!existing) {
122
+ throw new Error(`Node not found: ${id}`);
123
+ }
124
+ this.searchIndex.removeNode(id, existing);
125
+ this.db.query("DELETE FROM nodes WHERE id = ?").run(id);
126
+ }
127
+ addTags(nodeId, tags) {
128
+ const exists = this.db.query("SELECT id FROM nodes WHERE id = ?").get(nodeId);
129
+ if (!exists) {
130
+ throw new Error(`Node not found: ${nodeId}`);
131
+ }
132
+ const insertTx = this.db.transaction(() => {
133
+ for (const tag of tags) {
134
+ this.db.query("INSERT OR IGNORE INTO tags (node_id, tag) VALUES (?, ?)").run(nodeId, tag);
135
+ }
136
+ });
137
+ insertTx();
138
+ }
139
+ removeTags(nodeId, tags) {
140
+ const exists = this.db.query("SELECT id FROM nodes WHERE id = ?").get(nodeId);
141
+ if (!exists) {
142
+ throw new Error(`Node not found: ${nodeId}`);
143
+ }
144
+ const deleteTx = this.db.transaction(() => {
145
+ for (const tag of tags) {
146
+ this.db.query("DELETE FROM tags WHERE node_id = ? AND tag = ?").run(nodeId, tag);
147
+ }
148
+ });
149
+ deleteTx();
150
+ }
151
+ createLink(fromId, toId, linkType) {
152
+ const fromNode = this.db.query("SELECT id FROM nodes WHERE id = ?").get(fromId);
153
+ if (!fromNode) {
154
+ throw new Error(`Node not found: ${fromId}`);
155
+ }
156
+ const toNode = this.db.query("SELECT id FROM nodes WHERE id = ?").get(toId);
157
+ if (!toNode) {
158
+ throw new Error(`Node not found: ${toId}`);
159
+ }
160
+ const now = new Date().toISOString();
161
+ this.db.query("INSERT OR IGNORE INTO links (from_id, to_id, link_type, created_at) VALUES (?, ?, ?, ?)").run(fromId, toId, linkType, now);
162
+ const row = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE from_id = ? AND to_id = ? AND link_type = ?").get(fromId, toId, linkType);
163
+ if (!row) {
164
+ throw new Error(`Failed to retrieve link after creation`);
165
+ }
166
+ return {
167
+ created_at: row.created_at,
168
+ from_id: row.from_id,
169
+ link_type: row.link_type,
170
+ to_id: row.to_id
171
+ };
172
+ }
173
+ deleteLink(fromId, toId, linkType) {
174
+ this.db.query("DELETE FROM links WHERE from_id = ? AND to_id = ? AND link_type = ?").run(fromId, toId, linkType);
175
+ }
176
+ getLinks(nodeId, direction) {
177
+ const rowToLink = (r) => ({
178
+ created_at: r.created_at,
179
+ from_id: r.from_id,
180
+ link_type: r.link_type,
181
+ to_id: r.to_id
182
+ });
183
+ if (direction === "from") {
184
+ const rows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE from_id = ?").all(nodeId);
185
+ return rows.map(rowToLink);
186
+ }
187
+ if (direction === "to") {
188
+ const rows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE to_id = ?").all(nodeId);
189
+ return rows.map(rowToLink);
190
+ }
191
+ const fromRows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE from_id = ?").all(nodeId);
192
+ const toRows = this.db.query("SELECT from_id, to_id, link_type, created_at FROM links WHERE to_id = ?").all(nodeId);
193
+ return [...fromRows.map(rowToLink), ...toRows.map(rowToLink)];
194
+ }
195
+ search(params) {
196
+ return this.searchIndex.search(params, (id) => this.getNodeWithRelations(id));
197
+ }
198
+ listKinds() {
199
+ const rows = this.db.query("SELECT kind, COUNT(*) as count FROM nodes GROUP BY kind ORDER BY count DESC").all();
200
+ return rows;
201
+ }
202
+ listTags() {
203
+ const rows = this.db.query("SELECT tag, COUNT(*) as count FROM tags GROUP BY tag ORDER BY count DESC").all();
204
+ return rows;
205
+ }
206
+ close() {
207
+ this.db.close();
208
+ }
209
+ }
@@ -0,0 +1,8 @@
1
+ import { mkdirSync } from "fs";
2
+ import { dirname } from "path";
3
+ export function ensureDbDir(dbPath) {
4
+ if (dbPath === ":memory:") {
5
+ return;
6
+ }
7
+ mkdirSync(dirname(dbPath), { recursive: true });
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ const kindSchemas = new Map;
3
+ export function registerKind(kind, schema) {
4
+ kindSchemas.set(kind, schema);
5
+ }
6
+ export function validateProperties(kind, props) {
7
+ const schema = kindSchemas.get(kind);
8
+ if (!schema) {
9
+ return props;
10
+ }
11
+ return schema.parse(props);
12
+ }
13
+ export function listRegisteredKinds() {
14
+ return Array.from(kindSchemas.keys());
15
+ }
16
+ registerKind("memo", z.object({}).passthrough());
17
+ registerKind("todo", z.object({
18
+ deadline: z.string().datetime().optional(),
19
+ priority: z.enum(["low", "medium", "high"]).optional(),
20
+ status: z.enum(["pending", "in_progress", "done", "cancelled"]).default("pending")
21
+ }));
22
+ registerKind("conversation", z.object({
23
+ context: z.string(),
24
+ key_points: z.array(z.string()),
25
+ keywords_en: z.array(z.string()).optional(),
26
+ open_questions: z.array(z.string()),
27
+ session_ref: z.string().optional(),
28
+ summary_en: z.string().optional()
29
+ }));
30
+ registerKind("idea", z.object({
31
+ keywords_en: z.array(z.string()).optional(),
32
+ summary_en: z.string().optional()
33
+ }));
34
+ registerKind("decision", z.object({
35
+ constraint: z.string().describe("Specific design constraint established"),
36
+ why: z.string().describe("Reasoning behind the constraint"),
37
+ scope: z.string().describe("Where this constraint applies"),
38
+ rejected_alternatives: z.array(z.object({
39
+ what: z.string().describe("What was considered"),
40
+ why_rejected: z.string().describe("Why it was rejected")
41
+ })).optional().default([]),
42
+ repo_owner: z.string().optional().describe("Repository owner for scoping"),
43
+ repo_name: z.string().optional().describe("Repository name for scoping"),
44
+ summary_en: z.string().optional(),
45
+ keywords_en: z.array(z.string()).optional()
46
+ }));
@@ -0,0 +1,36 @@
1
+ CREATE TABLE IF NOT EXISTS nodes (
2
+ id TEXT PRIMARY KEY,
3
+ kind TEXT NOT NULL,
4
+ body TEXT NOT NULL DEFAULT '',
5
+ properties TEXT NOT NULL DEFAULT '{}',
6
+ created_at TEXT NOT NULL,
7
+ updated_at TEXT NOT NULL
8
+ );
9
+
10
+ CREATE TABLE IF NOT EXISTS tags (
11
+ node_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
12
+ tag TEXT NOT NULL,
13
+ PRIMARY KEY (node_id, tag)
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS links (
17
+ from_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
18
+ to_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
19
+ link_type TEXT NOT NULL,
20
+ created_at TEXT NOT NULL,
21
+ PRIMARY KEY (from_id, to_id, link_type)
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
25
+ CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(updated_at);
26
+ CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag);
27
+ CREATE INDEX IF NOT EXISTS idx_links_to ON links(to_id);
28
+
29
+ CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
30
+ node_id UNINDEXED,
31
+ title,
32
+ summary,
33
+ keywords,
34
+ content='',
35
+ tokenize='unicode61'
36
+ );
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ export const CreateNodeInput = z.object({
3
+ body: z.string().default("").describe("The text body of the node"),
4
+ kind: z.string().min(1).describe("The kind/type of the node"),
5
+ properties: z.record(z.string(), z.unknown()).default({}).describe("Structured properties for the node"),
6
+ tags: z.array(z.string()).optional().describe("Tags to apply to the node")
7
+ });
8
+ export const UpdateNodeInput = z.object({
9
+ body: z.string().optional().describe("New body text"),
10
+ id: z.string().length(26).describe("The node ID to update"),
11
+ kind: z.string().optional().describe("New kind for the node"),
12
+ properties: z.record(z.string(), z.unknown()).optional().describe("Updated properties")
13
+ });
14
+ export const SearchNodesInput = z.object({
15
+ kind: z.string().optional().describe("Filter by node kind"),
16
+ limit: z.number().int().min(1).max(100).default(20).describe("Maximum number of results (default 20)"),
17
+ offset: z.number().int().min(0).default(0).describe("Offset for pagination (default 0)"),
18
+ query: z.string().optional().describe("Full-text search query"),
19
+ tags: z.array(z.string()).optional().describe("Filter by tags")
20
+ });
21
+ export const GetLinksInput = z.object({
22
+ direction: z.enum(["from", "to", "both"]).default("both").describe("Direction of links to retrieve"),
23
+ node_id: z.string().length(26).describe("The node ID to get links for")
24
+ });
25
+ export const CreateLinkInput = z.object({
26
+ from_id: z.string().length(26).describe("The source node ID"),
27
+ link_type: z.string().min(1).describe("The type of link"),
28
+ to_id: z.string().length(26).describe("The target node ID")
29
+ });
30
+ export const TagsInput = z.object({
31
+ node_id: z.string().length(26).describe("The node ID to add/remove tags"),
32
+ tags: z.array(z.string().min(1)).min(1).describe("Tags to add or remove")
33
+ });
34
+ export const NodeIdInput = z.object({
35
+ id: z.string().length(26).describe("The node ID")
36
+ });
37
+ export const DeleteLinkInput = z.object({
38
+ from_id: z.string().length(26).describe("The source node ID"),
39
+ link_type: z.string().min(1).describe("The type of link"),
40
+ to_id: z.string().length(26).describe("The target node ID")
41
+ });
@@ -0,0 +1,80 @@
1
+ export class SearchIndex {
2
+ db;
3
+ constructor(db) {
4
+ this.db = db;
5
+ this.db.exec(`CREATE TABLE IF NOT EXISTS fts_rowid_map (
6
+ node_id TEXT PRIMARY KEY,
7
+ fts_rowid INTEGER NOT NULL
8
+ )`);
9
+ }
10
+ indexNode(node) {
11
+ const hasSummary = typeof node.properties.summary_en === "string";
12
+ const hasKeywords = Array.isArray(node.properties.keywords_en) && node.properties.keywords_en.length > 0;
13
+ if (!hasSummary && !hasKeywords) {
14
+ return;
15
+ }
16
+ const title = hasSummary ? node.properties.summary_en : "";
17
+ const summary = node.body ? node.body.slice(0, 200) : "";
18
+ const keywordsArr = hasKeywords ? node.properties.keywords_en : [];
19
+ const keywords = keywordsArr.join(" ");
20
+ this.db.query("INSERT INTO nodes_fts(node_id, title, summary, keywords) VALUES (?, ?, ?, ?)").run(node.id, title, summary, keywords);
21
+ const rowidRow = this.db.query("SELECT last_insert_rowid() as rowid").get();
22
+ if (rowidRow) {
23
+ this.db.query("INSERT OR REPLACE INTO fts_rowid_map(node_id, fts_rowid) VALUES (?, ?)").run(node.id, rowidRow.rowid);
24
+ }
25
+ }
26
+ removeNode(nodeId, node) {
27
+ const mapRow = this.db.query("SELECT fts_rowid FROM fts_rowid_map WHERE node_id = ?").get(nodeId);
28
+ if (!mapRow) {
29
+ return;
30
+ }
31
+ const title = typeof node.properties.summary_en === "string" ? node.properties.summary_en : "";
32
+ const summary = node.body ? node.body.slice(0, 200) : "";
33
+ const keywordsArr = Array.isArray(node.properties.keywords_en) ? node.properties.keywords_en : [];
34
+ const keywords = keywordsArr.join(" ");
35
+ this.db.query("INSERT INTO nodes_fts(nodes_fts, rowid, node_id, title, summary, keywords) VALUES('delete', ?, ?, ?, ?, ?)").run(mapRow.rowid, nodeId, title, summary, keywords);
36
+ this.db.query("DELETE FROM fts_rowid_map WHERE node_id = ?").run(nodeId);
37
+ }
38
+ search(params, getNodeWithRelations) {
39
+ const { query, kind, tags, limit, offset } = params;
40
+ const hasFts = query !== undefined && query.trim() !== "";
41
+ const hasKind = kind !== undefined && kind.trim() !== "";
42
+ const hasTags = tags !== undefined && tags.length > 0;
43
+ const whereConditions = [];
44
+ const queryParams = [];
45
+ let fromClause;
46
+ if (hasFts) {
47
+ fromClause = "FROM nodes JOIN fts_rowid_map ON nodes.id = fts_rowid_map.node_id " + "JOIN nodes_fts ON fts_rowid_map.fts_rowid = nodes_fts.rowid";
48
+ whereConditions.push("nodes_fts MATCH ?");
49
+ queryParams.push(query);
50
+ } else {
51
+ fromClause = "FROM nodes";
52
+ }
53
+ if (hasKind) {
54
+ whereConditions.push("nodes.kind = ?");
55
+ queryParams.push(kind);
56
+ }
57
+ if (hasTags) {
58
+ for (const tag of tags) {
59
+ whereConditions.push("nodes.id IN (SELECT node_id FROM tags WHERE tag = ?)");
60
+ queryParams.push(tag);
61
+ }
62
+ }
63
+ const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
64
+ const orderClause = hasFts ? "ORDER BY nodes_fts.rank" : "ORDER BY nodes.updated_at DESC";
65
+ const countSql = `SELECT COUNT(*) as total ${fromClause} ${whereClause}`;
66
+ const countRow = this.db.query(countSql).get(...queryParams);
67
+ const total = countRow ? countRow.total : 0;
68
+ const selectSql = `SELECT nodes.id ${fromClause} ${whereClause} ${orderClause} LIMIT ? OFFSET ?`;
69
+ const selectParams = [...queryParams, limit, offset];
70
+ const rows = this.db.query(selectSql).all(...selectParams);
71
+ const nodes = [];
72
+ for (const row of rows) {
73
+ const node = getNodeWithRelations(row.id);
74
+ if (node) {
75
+ nodes.push(node);
76
+ }
77
+ }
78
+ return { nodes, total };
79
+ }
80
+ }
File without changes
@@ -0,0 +1,130 @@
1
+ import React, { useCallback, useState } from "react";
2
+ import { Box, useApp, useInput } from "ink";
3
+ import { FilterBar, parseFilter } from "./components/FilterBar.js";
4
+ import { NodeList } from "./components/NodeList.js";
5
+ import { NodeDetail } from "./components/NodeDetail.js";
6
+ import { LinkList } from "./components/LinkList.js";
7
+ import { StatusBar } from "./components/StatusBar.js";
8
+ import { useNodes } from "./hooks/useNodes.js";
9
+ import { useNavigation } from "./hooks/useNavigation.js";
10
+ import { copyToClipboard, launchClaude } from "./actions.js";
11
+ export function App() {
12
+ const { exit } = useApp();
13
+ const [filterInput, setFilterInput] = useState("");
14
+ const filter = parseFilter(filterInput);
15
+ const { nodes, loading, error } = useNodes(filter);
16
+ const nav = useNavigation();
17
+ const selectedNode = nodes[nav.selectedListIndex] ?? null;
18
+ const handleNavigate = useCallback((input, key) => {
19
+ if (input === "q") {
20
+ exit();
21
+ return;
22
+ }
23
+ if (input === "/") {
24
+ nav.focusFilter();
25
+ return;
26
+ }
27
+ if (key.tab) {
28
+ nav.cyclePanel();
29
+ return;
30
+ }
31
+ if (input === "j" || key.downArrow) {
32
+ if (nav.activePanel === "list") {
33
+ nav.navigateDown(nodes.length);
34
+ } else if (nav.activePanel === "links") {
35
+ const allLinks = selectedNode ? [...selectedNode.links_from, ...selectedNode.links_to] : [];
36
+ nav.navigateDown(allLinks.length);
37
+ }
38
+ return;
39
+ }
40
+ if (input === "k" || key.upArrow) {
41
+ nav.navigateUp();
42
+ return;
43
+ }
44
+ if (key.return) {
45
+ if (nav.activePanel === "list" && selectedNode) {} else if (nav.activePanel === "links" && selectedNode) {
46
+ const allLinks = [...selectedNode.links_from, ...selectedNode.links_to];
47
+ const targetLink = allLinks[nav.selectedLinkIndex];
48
+ if (targetLink) {
49
+ const targetId = targetLink.from_id === selectedNode.id ? targetLink.to_id : targetLink.from_id;
50
+ const targetIndex = nodes.findIndex((n) => n.id === targetId);
51
+ if (targetIndex >= 0) {
52
+ nav.setSelectedListIndex(targetIndex);
53
+ nav.setActivePanel("list");
54
+ nav.resetLinkIndex();
55
+ }
56
+ }
57
+ }
58
+ return;
59
+ }
60
+ if (input === "b") {
61
+ const nodeInfo = selectedNode ? `Node: ${selectedNode.kind} - ${selectedNode.body.slice(0, 100)}` : "No node selected";
62
+ launchClaude(exit, `Brainstorm ideas related to: ${nodeInfo}`);
63
+ return;
64
+ }
65
+ if (input === "r") {
66
+ launchClaude(exit, "Review all todo nodes and suggest priorities.");
67
+ return;
68
+ }
69
+ if (input === "y") {
70
+ if (selectedNode) {
71
+ const text = [
72
+ `ID: ${selectedNode.id}`,
73
+ `Kind: ${selectedNode.kind}`,
74
+ `Tags: ${selectedNode.tags.join(", ")}`,
75
+ `Body: ${selectedNode.body}`
76
+ ].join(`
77
+ `);
78
+ copyToClipboard(text);
79
+ }
80
+ return;
81
+ }
82
+ }, [nav, nodes, selectedNode, exit]);
83
+ useInput(handleNavigate, { isActive: !nav.filterFocused });
84
+ useInput((_input, key) => {
85
+ if (key.escape) {
86
+ nav.blurFilter();
87
+ }
88
+ }, { isActive: nav.filterFocused });
89
+ const handleFilterChange = (value) => {
90
+ setFilterInput(value);
91
+ };
92
+ return jsxDEV_7x81h0kn(Box, {
93
+ flexDirection: "column",
94
+ flexGrow: 1,
95
+ children: [
96
+ jsxDEV_7x81h0kn(FilterBar, {
97
+ value: filterInput,
98
+ onChange: handleFilterChange,
99
+ focused: nav.filterFocused,
100
+ onBlur: nav.blurFilter
101
+ }, undefined, false, undefined, this),
102
+ jsxDEV_7x81h0kn(Box, {
103
+ flexDirection: "row",
104
+ flexGrow: 1,
105
+ children: [
106
+ jsxDEV_7x81h0kn(NodeList, {
107
+ nodes,
108
+ selectedIndex: nav.selectedListIndex,
109
+ focused: nav.activePanel === "list" && !nav.filterFocused,
110
+ loading,
111
+ error
112
+ }, undefined, false, undefined, this),
113
+ jsxDEV_7x81h0kn(NodeDetail, {
114
+ node: selectedNode,
115
+ focused: nav.activePanel === "detail" && !nav.filterFocused
116
+ }, undefined, false, undefined, this),
117
+ jsxDEV_7x81h0kn(LinkList, {
118
+ linksFrom: selectedNode?.links_from ?? [],
119
+ linksTo: selectedNode?.links_to ?? [],
120
+ selectedIndex: nav.selectedLinkIndex,
121
+ focused: nav.activePanel === "links" && !nav.filterFocused
122
+ }, undefined, false, undefined, this)
123
+ ]
124
+ }, undefined, true, undefined, this),
125
+ jsxDEV_7x81h0kn(StatusBar, {
126
+ filterFocused: nav.filterFocused
127
+ }, undefined, false, undefined, this)
128
+ ]
129
+ }, undefined, true, undefined, this);
130
+ }
@@ -0,0 +1,9 @@
1
+ import { spawn } from "node:child_process";
2
+ export function launchClaude(exit, prompt) {
3
+ exit();
4
+ spawn("claude", ["--prompt", prompt], { stdio: "inherit" });
5
+ }
6
+ export function copyToClipboard(text) {
7
+ const proc = spawn("pbcopy", [], { stdio: ["pipe"] });
8
+ proc.stdin?.end(text);
9
+ }
@@ -0,0 +1,46 @@
1
+ import { Box, Text } from "ink";
2
+ import { TextInput } from "@inkjs/ui";
3
+ export function FilterBar({ value, onChange, focused, onBlur }) {
4
+ return jsxDEV_7x81h0kn(Box, {
5
+ borderStyle: "single",
6
+ paddingX: 1,
7
+ flexShrink: 0,
8
+ children: [
9
+ jsxDEV_7x81h0kn(Text, {
10
+ bold: true,
11
+ color: focused ? "cyan" : "white",
12
+ children: [
13
+ "Filter:",
14
+ " "
15
+ ]
16
+ }, undefined, true, undefined, this),
17
+ focused ? jsxDEV_7x81h0kn(TextInput, {
18
+ defaultValue: value,
19
+ onChange,
20
+ onSubmit: () => onBlur()
21
+ }, undefined, false, undefined, this) : jsxDEV_7x81h0kn(Text, {
22
+ children: value || "(press / to filter)"
23
+ }, undefined, false, undefined, this)
24
+ ]
25
+ }, undefined, true, undefined, this);
26
+ }
27
+ export function parseFilter(input) {
28
+ const parts = input.trim().split(/\s+/);
29
+ let kind;
30
+ let tag;
31
+ const queryParts = [];
32
+ for (const part of parts) {
33
+ if (part.startsWith("kind:")) {
34
+ kind = part.slice(5);
35
+ } else if (part.startsWith("tag:")) {
36
+ tag = part.slice(4);
37
+ } else if (part.length > 0) {
38
+ queryParts.push(part);
39
+ }
40
+ }
41
+ return {
42
+ kind: kind || undefined,
43
+ query: queryParts.length > 0 ? queryParts.join(" ") : undefined,
44
+ tag: tag || undefined
45
+ };
46
+ }
@@ -0,0 +1,53 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ export function LinkList({ linksFrom, linksTo, selectedIndex, focused }) {
4
+ const borderColor = focused ? "cyan" : undefined;
5
+ const allLinks = [
6
+ ...linksFrom.map((l) => ({ ...l, direction: "from" })),
7
+ ...linksTo.map((l) => ({ ...l, direction: "to" }))
8
+ ];
9
+ return jsxDEV_7x81h0kn(Box, {
10
+ flexDirection: "column",
11
+ borderStyle: "single",
12
+ borderColor,
13
+ flexGrow: 1,
14
+ overflow: "hidden",
15
+ children: [
16
+ jsxDEV_7x81h0kn(Text, {
17
+ bold: true,
18
+ underline: true,
19
+ children: [
20
+ " ",
21
+ "Links ",
22
+ focused ? "(active)" : ""
23
+ ]
24
+ }, undefined, true, undefined, this),
25
+ allLinks.length === 0 ? jsxDEV_7x81h0kn(Text, {
26
+ color: "gray",
27
+ children: " No links."
28
+ }, undefined, false, undefined, this) : allLinks.map((link, index) => {
29
+ const isSelected = index === selectedIndex;
30
+ const prefix = isSelected ? "> " : " ";
31
+ const targetId = link.direction === "from" ? link.to_id.slice(0, 10) : link.from_id.slice(0, 10);
32
+ const dirSymbol = link.direction === "from" ? "->" : "<-";
33
+ const color = isSelected ? focused ? "cyan" : "white" : undefined;
34
+ return jsxDEV_7x81h0kn(Box, {
35
+ children: jsxDEV_7x81h0kn(Text, {
36
+ color,
37
+ bold: isSelected,
38
+ inverse: isSelected && focused,
39
+ children: [
40
+ prefix,
41
+ dirSymbol,
42
+ " ",
43
+ link.link_type,
44
+ " ",
45
+ targetId,
46
+ "..."
47
+ ]
48
+ }, undefined, true, undefined, this)
49
+ }, `${link.from_id}-${link.to_id}-${link.link_type}`, false, undefined, this);
50
+ })
51
+ ]
52
+ }, undefined, true, undefined, this);
53
+ }