miii-cli 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.
- package/.claude/settings.local.json +18 -0
- package/Makefile +13 -0
- package/README.md +182 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +24 -0
- package/dist/config.js.map +1 -0
- package/dist/files/ops.d.ts +11 -0
- package/dist/files/ops.js +66 -0
- package/dist/files/ops.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +1 -0
- package/dist/init.js +32 -0
- package/dist/init.js.map +1 -0
- package/dist/llm/ollama.d.ts +9 -0
- package/dist/llm/ollama.js +51 -0
- package/dist/llm/ollama.js.map +1 -0
- package/dist/llm/stream.d.ts +12 -0
- package/dist/llm/stream.js +129 -0
- package/dist/llm/stream.js.map +1 -0
- package/dist/parser/stream-parser.d.ts +17 -0
- package/dist/parser/stream-parser.js +54 -0
- package/dist/parser/stream-parser.js.map +1 -0
- package/dist/sessions.d.ts +9 -0
- package/dist/sessions.js +48 -0
- package/dist/sessions.js.map +1 -0
- package/dist/skills/loader.d.ts +23 -0
- package/dist/skills/loader.js +91 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +79 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tui/App.d.ts +9 -0
- package/dist/tui/App.js +259 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/InputBar.d.ts +10 -0
- package/dist/tui/InputBar.js +289 -0
- package/dist/tui/InputBar.js.map +1 -0
- package/dist/tui/components/AtPicker.d.ts +8 -0
- package/dist/tui/components/AtPicker.js +19 -0
- package/dist/tui/components/AtPicker.js.map +1 -0
- package/dist/tui/components/CommandPalette.d.ts +8 -0
- package/dist/tui/components/CommandPalette.js +25 -0
- package/dist/tui/components/CommandPalette.js.map +1 -0
- package/dist/tui/components/InputArea.d.ts +11 -0
- package/dist/tui/components/InputArea.js +268 -0
- package/dist/tui/components/InputArea.js.map +1 -0
- package/dist/tui/components/MessageList.d.ts +10 -0
- package/dist/tui/components/MessageList.js +98 -0
- package/dist/tui/components/MessageList.js.map +1 -0
- package/dist/tui/components/ModelPicker.d.ts +18 -0
- package/dist/tui/components/ModelPicker.js +74 -0
- package/dist/tui/components/ModelPicker.js.map +1 -0
- package/dist/tui/components/StatusBar.d.ts +12 -0
- package/dist/tui/components/StatusBar.js +15 -0
- package/dist/tui/components/StatusBar.js.map +1 -0
- package/dist/tui/printer.d.ts +7 -0
- package/dist/tui/printer.js +106 -0
- package/dist/tui/printer.js.map +1 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/workers/context.worker.d.ts +1 -0
- package/dist/workers/context.worker.js +69 -0
- package/dist/workers/context.worker.js.map +1 -0
- package/dist/workers/diff.worker.d.ts +1 -0
- package/dist/workers/diff.worker.js +12 -0
- package/dist/workers/diff.worker.js.map +1 -0
- package/dist/workers/spawn.d.ts +1 -0
- package/dist/workers/spawn.js +18 -0
- package/dist/workers/spawn.js.map +1 -0
- package/install.sh +6 -0
- package/package.json +29 -0
- package/src/config.ts +25 -0
- package/src/files/ops.ts +71 -0
- package/src/index.ts +11 -0
- package/src/init.ts +39 -0
- package/src/llm/ollama.ts +58 -0
- package/src/llm/stream.ts +118 -0
- package/src/parser/stream-parser.ts +54 -0
- package/src/sessions.ts +46 -0
- package/src/skills/loader.ts +109 -0
- package/src/tools/index.ts +83 -0
- package/src/tui/App.tsx +308 -0
- package/src/tui/InputBar.tsx +347 -0
- package/src/tui/components/AtPicker.tsx +49 -0
- package/src/tui/components/CommandPalette.tsx +50 -0
- package/src/tui/components/InputArea.tsx +285 -0
- package/src/tui/components/MessageList.tsx +192 -0
- package/src/tui/components/ModelPicker.tsx +134 -0
- package/src/tui/components/StatusBar.tsx +36 -0
- package/src/tui/printer.ts +121 -0
- package/src/types.ts +25 -0
- package/src/workers/context.worker.ts +62 -0
- package/src/workers/diff.worker.ts +20 -0
- package/src/workers/spawn.ts +19 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ParsedText { type: 'text'; content: string }
|
|
2
|
+
export interface ParsedTool { type: 'tool_call'; content: string; toolName: string; toolArgs: Record<string, unknown> }
|
|
3
|
+
export type ParsedItem = ParsedText | ParsedTool
|
|
4
|
+
|
|
5
|
+
const OPEN = '<tool_call>'
|
|
6
|
+
const CLOSE = '</tool_call>'
|
|
7
|
+
|
|
8
|
+
export class StreamParser {
|
|
9
|
+
private buf = ''
|
|
10
|
+
private inTool = false
|
|
11
|
+
|
|
12
|
+
feed(token: string): ParsedItem[] {
|
|
13
|
+
this.buf += token
|
|
14
|
+
const out: ParsedItem[] = []
|
|
15
|
+
|
|
16
|
+
while (true) {
|
|
17
|
+
if (this.inTool) {
|
|
18
|
+
const end = this.buf.indexOf(CLOSE)
|
|
19
|
+
if (end === -1) break
|
|
20
|
+
const raw = this.buf.slice(0, end).trim()
|
|
21
|
+
this.buf = this.buf.slice(end + CLOSE.length)
|
|
22
|
+
this.inTool = false
|
|
23
|
+
try {
|
|
24
|
+
const obj = JSON.parse(raw) as { name: string; args?: Record<string, unknown> }
|
|
25
|
+
out.push({ type: 'tool_call', content: raw, toolName: obj.name, toolArgs: obj.args ?? {} })
|
|
26
|
+
} catch {
|
|
27
|
+
out.push({ type: 'text', content: `${OPEN}${raw}${CLOSE}` })
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
const start = this.buf.indexOf(OPEN)
|
|
31
|
+
if (start === -1) {
|
|
32
|
+
const safe = this.buf.length > OPEN.length ? this.buf.slice(0, -OPEN.length) : ''
|
|
33
|
+
if (safe) { out.push({ type: 'text', content: safe }); this.buf = this.buf.slice(safe.length) }
|
|
34
|
+
break
|
|
35
|
+
}
|
|
36
|
+
if (start > 0) {
|
|
37
|
+
out.push({ type: 'text', content: this.buf.slice(0, start) })
|
|
38
|
+
this.buf = this.buf.slice(start)
|
|
39
|
+
}
|
|
40
|
+
this.buf = this.buf.slice(OPEN.length)
|
|
41
|
+
this.inTool = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
flush(): ParsedItem[] {
|
|
48
|
+
const out: ParsedItem[] = []
|
|
49
|
+
if (this.buf.trim()) out.push({ type: 'text', content: this.buf })
|
|
50
|
+
this.buf = ''
|
|
51
|
+
this.inTool = false
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync, unlinkSync } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import type { ChatMessage } from './types.js'
|
|
5
|
+
|
|
6
|
+
const SESSIONS_DIR = join(homedir(), '.config', 'miii', 'sessions')
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
mkdirSync(SESSIONS_DIR, { recursive: true })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function listSessions(): Array<{ name: string; messageCount: number; updatedAt: number }> {
|
|
13
|
+
ensureDir()
|
|
14
|
+
return readdirSync(SESSIONS_DIR)
|
|
15
|
+
.filter(f => f.endsWith('.json'))
|
|
16
|
+
.map(f => {
|
|
17
|
+
const name = f.replace('.json', '')
|
|
18
|
+
const p = join(SESSIONS_DIR, f)
|
|
19
|
+
let messageCount = 0
|
|
20
|
+
let updatedAt = 0
|
|
21
|
+
try {
|
|
22
|
+
updatedAt = statSync(p).mtimeMs
|
|
23
|
+
const msgs = JSON.parse(readFileSync(p, 'utf-8'))
|
|
24
|
+
messageCount = Array.isArray(msgs) ? msgs.length : 0
|
|
25
|
+
} catch {}
|
|
26
|
+
return { name, messageCount, updatedAt }
|
|
27
|
+
})
|
|
28
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadSession(name: string): ChatMessage[] {
|
|
32
|
+
ensureDir()
|
|
33
|
+
const p = join(SESSIONS_DIR, `${name}.json`)
|
|
34
|
+
if (!existsSync(p)) return []
|
|
35
|
+
try { return JSON.parse(readFileSync(p, 'utf-8')) } catch { return [] }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveSession(name: string, messages: ChatMessage[]) {
|
|
39
|
+
ensureDir()
|
|
40
|
+
writeFileSync(join(SESSIONS_DIR, `${name}.json`), JSON.stringify(messages))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function deleteSession(name: string) {
|
|
44
|
+
const p = join(SESSIONS_DIR, `${name}.json`)
|
|
45
|
+
if (existsSync(p)) unlinkSync(p)
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'fs'
|
|
2
|
+
import { join, basename } from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
|
|
5
|
+
export interface SkillContext {
|
|
6
|
+
messages: Array<{ role: string; content: string }>
|
|
7
|
+
appendMessage: (role: string, content: string) => void
|
|
8
|
+
setSystemPrompt: (p: string) => void
|
|
9
|
+
getSystemPrompt: () => string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Skill {
|
|
13
|
+
name: string
|
|
14
|
+
ns: string
|
|
15
|
+
description: string
|
|
16
|
+
prompt?: string
|
|
17
|
+
execute?: (args: string, ctx: SkillContext) => string | Promise<string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const builtin: Skill[] = [
|
|
21
|
+
{
|
|
22
|
+
name: 'caveman',
|
|
23
|
+
ns: 'caveman',
|
|
24
|
+
description: 'Ultra-compressed terse mode',
|
|
25
|
+
execute: (_, ctx) => {
|
|
26
|
+
const cur = ctx.getSystemPrompt()
|
|
27
|
+
ctx.setSystemPrompt(cur + '\n\nRespond ultra-compressed. Drop articles, filler, pleasantries, hedging. Fragments OK. Technical terms exact.')
|
|
28
|
+
return 'Caveman mode active.'
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'normal',
|
|
33
|
+
ns: 'caveman',
|
|
34
|
+
description: 'Revert to normal tone',
|
|
35
|
+
execute: (_, ctx) => {
|
|
36
|
+
const cur = ctx.getSystemPrompt()
|
|
37
|
+
ctx.setSystemPrompt(cur.replace(/\n\nRespond ultra-compressed.*$/s, ''))
|
|
38
|
+
return 'Normal mode.'
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'review',
|
|
43
|
+
ns: 'default',
|
|
44
|
+
description: 'Review codebase for bugs/security/quality',
|
|
45
|
+
prompt: 'Review the code in this project. Look for bugs, security issues, performance problems, and quality issues. Be specific and actionable. List findings grouped by severity.',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'help',
|
|
49
|
+
ns: 'default',
|
|
50
|
+
description: 'Show available commands',
|
|
51
|
+
execute: (_, ctx) => {
|
|
52
|
+
return 'Built-in skills: /caveman:caveman /caveman:normal /review /help\nType /list for all loaded skills.'
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'list',
|
|
57
|
+
ns: 'default',
|
|
58
|
+
description: 'List all skills',
|
|
59
|
+
execute: () => '', // handled dynamically in loader.list()
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'models',
|
|
63
|
+
ns: 'default',
|
|
64
|
+
description: 'Choose or pull Ollama models',
|
|
65
|
+
// execute handled specially in App.tsx before skill lookup
|
|
66
|
+
},
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
export class SkillLoader {
|
|
70
|
+
private map = new Map<string, Skill>()
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
for (const s of builtin) {
|
|
74
|
+
this.map.set(`${s.ns}:${s.name}`, s)
|
|
75
|
+
this.map.set(s.name, s)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async loadAll(): Promise<void> {
|
|
80
|
+
const dirs = [
|
|
81
|
+
join(homedir(), '.config', 'miii', 'skills'),
|
|
82
|
+
join(process.cwd(), '.miii', 'skills'),
|
|
83
|
+
]
|
|
84
|
+
for (const dir of dirs) {
|
|
85
|
+
if (!existsSync(dir)) continue
|
|
86
|
+
for (const entry of readdirSync(dir)) {
|
|
87
|
+
if (!entry.endsWith('.md')) continue
|
|
88
|
+
const name = basename(entry, '.md')
|
|
89
|
+
const content = readFileSync(join(dir, entry), 'utf-8')
|
|
90
|
+
const skill: Skill = {
|
|
91
|
+
name,
|
|
92
|
+
ns: 'custom',
|
|
93
|
+
description: content.split('\n')[0].replace(/^#+\s*/, '').trim(),
|
|
94
|
+
prompt: content,
|
|
95
|
+
}
|
|
96
|
+
this.map.set(name, skill)
|
|
97
|
+
this.map.set(`custom:${name}`, skill)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get(ref: string): Skill | undefined {
|
|
103
|
+
return this.map.get(ref)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
list(): Skill[] {
|
|
107
|
+
return [...new Set(this.map.values())]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile, writeFile, deleteFile, listFiles } from '../files/ops.js'
|
|
2
|
+
import { exec } from 'child_process'
|
|
3
|
+
import { promisify } from 'util'
|
|
4
|
+
|
|
5
|
+
const run = promisify(exec)
|
|
6
|
+
|
|
7
|
+
export interface Tool {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
params: string
|
|
11
|
+
execute: (args: Record<string, unknown>) => Promise<string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const tools: Tool[] = [
|
|
15
|
+
{
|
|
16
|
+
name: 'read_file',
|
|
17
|
+
description: 'Read file contents',
|
|
18
|
+
params: '{"path": "string"}',
|
|
19
|
+
execute: async ({ path }) => {
|
|
20
|
+
try { return readFile(path as string) }
|
|
21
|
+
catch (e) { throw new Error(`read_file: ${e}`) }
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'list_files',
|
|
26
|
+
description: 'List directory contents',
|
|
27
|
+
params: '{"path": "string", "recursive": "boolean (optional)"}',
|
|
28
|
+
execute: async ({ path, recursive = false }) => {
|
|
29
|
+
const entries = listFiles(path as string, recursive as boolean)
|
|
30
|
+
if (!entries.length) return '(empty)'
|
|
31
|
+
return entries.map(e => `${e.type === 'dir' ? 'd' : 'f'} ${e.rel}`).join('\n')
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'edit_file',
|
|
36
|
+
description: 'Write/overwrite file content',
|
|
37
|
+
params: '{"path": "string", "content": "string"}',
|
|
38
|
+
execute: async ({ path, content }) => {
|
|
39
|
+
writeFile(path as string, content as string)
|
|
40
|
+
return `written: ${path}`
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'delete_file',
|
|
45
|
+
description: 'Delete a file',
|
|
46
|
+
params: '{"path": "string"}',
|
|
47
|
+
execute: async ({ path }) => {
|
|
48
|
+
deleteFile(path as string)
|
|
49
|
+
return `deleted: ${path}`
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'run_command',
|
|
54
|
+
description: 'Run a shell command in cwd',
|
|
55
|
+
params: '{"command": "string"}',
|
|
56
|
+
execute: async ({ command }) => {
|
|
57
|
+
const { stdout, stderr } = await run(command as string, { cwd: process.cwd() })
|
|
58
|
+
return [stdout, stderr ? `stderr: ${stderr}` : ''].filter(Boolean).join('\n').trim()
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export function getSystemPrompt(extra = ''): string {
|
|
64
|
+
const toolDocs = tools.map(t => `- ${t.name}(${t.params}): ${t.description}`).join('\n')
|
|
65
|
+
return `You are Miii — a fast, local AI coding assistant.
|
|
66
|
+
|
|
67
|
+
Use tools by emitting:
|
|
68
|
+
<tool_call>
|
|
69
|
+
{"name": "tool_name", "args": {...}}
|
|
70
|
+
</tool_call>
|
|
71
|
+
|
|
72
|
+
Tools:
|
|
73
|
+
${toolDocs}
|
|
74
|
+
|
|
75
|
+
Rules:
|
|
76
|
+
- Read existing files before editing them
|
|
77
|
+
- For new files that do not exist yet, call edit_file directly — do not read first
|
|
78
|
+
- read_file returns empty string for missing files, so a blank result means the file is new
|
|
79
|
+
- Show the full content when creating or editing
|
|
80
|
+
- Never delete without confirming
|
|
81
|
+
- Be concise
|
|
82
|
+
- Output plain text only — no markdown, no headers, no bold/italic, no bullet points with *, no fenced code blocks with backticks. Use indentation and plain labels instead. This is a CLI terminal, not a chat UI${extra}`
|
|
83
|
+
}
|
package/src/tui/App.tsx
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
|
2
|
+
import { Box, useStdout, useInput } from 'ink'
|
|
3
|
+
import { StatusBar, Divider } from './components/StatusBar.js'
|
|
4
|
+
import { MessageList } from './components/MessageList.js'
|
|
5
|
+
import { InputArea } from './components/InputArea.js'
|
|
6
|
+
import { ModelPicker } from './components/ModelPicker.js'
|
|
7
|
+
import { stream } from '../llm/stream.js'
|
|
8
|
+
import { listModels, pullModel } from '../llm/ollama.js'
|
|
9
|
+
import type { OllamaModel } from '../llm/ollama.js'
|
|
10
|
+
import { StreamParser } from '../parser/stream-parser.js'
|
|
11
|
+
import { tools, getSystemPrompt } from '../tools/index.js'
|
|
12
|
+
import { readFile } from '../files/ops.js'
|
|
13
|
+
import type { SkillLoader } from '../skills/loader.js'
|
|
14
|
+
import type { Message, Status, ChatMessage, Config } from '../types.js'
|
|
15
|
+
import { generateId } from '../types.js'
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
config: Config
|
|
19
|
+
skills: SkillLoader
|
|
20
|
+
cwd: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MAX_TOOL_DEPTH = 6
|
|
24
|
+
const RENDER_THROTTLE_MS = 40
|
|
25
|
+
|
|
26
|
+
function expandAtRefs(text: string): { displayText: string; contextPrefix: string } {
|
|
27
|
+
const refs = [...text.matchAll(/@([\w./\-]+)/g)]
|
|
28
|
+
if (!refs.length) return { displayText: text, contextPrefix: '' }
|
|
29
|
+
const parts: string[] = []
|
|
30
|
+
for (const m of refs) {
|
|
31
|
+
try {
|
|
32
|
+
const content = readFile(m[1])
|
|
33
|
+
parts.push(`<file path="${m[1]}">\n${content}\n</file>`)
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
return { displayText: text, contextPrefix: parts.length ? parts.join('\n\n') + '\n\n' : '' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function App({ config, skills, cwd }: Props) {
|
|
40
|
+
const { stdout } = useStdout()
|
|
41
|
+
|
|
42
|
+
const [messages, setMessages] = useState<Message[]>([{
|
|
43
|
+
id: 'welcome',
|
|
44
|
+
role: 'system',
|
|
45
|
+
content: `local AI coding assistant · ${config.provider}/${config.model} · cwd: ${cwd}`,
|
|
46
|
+
timestamp: Date.now(),
|
|
47
|
+
}])
|
|
48
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
49
|
+
const [tick, setTick] = useState(0)
|
|
50
|
+
const [currentModel, setCurrentModel] = useState(config.model)
|
|
51
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
52
|
+
|
|
53
|
+
// model picker
|
|
54
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
55
|
+
const [pickerModels, setPickerModels] = useState<OllamaModel[]>([])
|
|
56
|
+
const [pickerLoading, setPickerLoading] = useState(false)
|
|
57
|
+
const [pickerError, setPickerError] = useState<string | undefined>()
|
|
58
|
+
const [pullState, setPullState] = useState<{ name: string; status: string; pct: number | undefined } | undefined>()
|
|
59
|
+
|
|
60
|
+
const [systemPrompt, setSystemPrompt] = useState(() => getSystemPrompt(`\n- CWD: ${cwd}`))
|
|
61
|
+
const systemPromptRef = useRef(systemPrompt)
|
|
62
|
+
const currentModelRef = useRef(currentModel)
|
|
63
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
64
|
+
const pullAbortRef = useRef<AbortController | null>(null)
|
|
65
|
+
const tokenBufRef = useRef('')
|
|
66
|
+
const lastRenderRef = useRef(0)
|
|
67
|
+
const messagesRef = useRef(messages)
|
|
68
|
+
|
|
69
|
+
useEffect(() => { systemPromptRef.current = systemPrompt }, [systemPrompt])
|
|
70
|
+
useEffect(() => { currentModelRef.current = currentModel }, [currentModel])
|
|
71
|
+
useEffect(() => { messagesRef.current = messages }, [messages])
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (status === 'idle') return
|
|
75
|
+
const t = setInterval(() => setTick(n => n + 1), 80)
|
|
76
|
+
return () => clearInterval(t)
|
|
77
|
+
}, [status])
|
|
78
|
+
|
|
79
|
+
// Scroll keybindings — PageUp/PageDn scroll message history
|
|
80
|
+
const SCROLL_STEP = 5
|
|
81
|
+
useInput((_input, key) => {
|
|
82
|
+
if (pickerOpen) return
|
|
83
|
+
if (key.pageUp) {
|
|
84
|
+
setScrollOffset(n => Math.min(n + SCROLL_STEP, Math.max(0, messages.length - 1)))
|
|
85
|
+
}
|
|
86
|
+
if (key.pageDown) {
|
|
87
|
+
setScrollOffset(n => Math.max(0, n - SCROLL_STEP))
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const cols = stdout.columns ?? 80
|
|
92
|
+
const rows = stdout.rows ?? 24
|
|
93
|
+
|
|
94
|
+
function addMsg(role: Message['role'], content: string, id?: string): string {
|
|
95
|
+
const mid = id ?? generateId()
|
|
96
|
+
setMessages(prev => [...prev, { id: mid, role, content, timestamp: Date.now() }])
|
|
97
|
+
return mid
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildContext(extra?: ChatMessage): ChatMessage[] {
|
|
101
|
+
const ctx: ChatMessage[] = [{ role: 'system', content: systemPromptRef.current }]
|
|
102
|
+
for (const m of messagesRef.current) {
|
|
103
|
+
if (m.role === 'tool') ctx.push({ role: 'user', content: `[tool result]\n${m.content}` })
|
|
104
|
+
else if (m.role === 'user' || m.role === 'assistant') ctx.push({ role: m.role, content: m.content })
|
|
105
|
+
}
|
|
106
|
+
if (extra) ctx.push(extra)
|
|
107
|
+
return ctx
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const runLoop = useCallback(async (contextMsgs: ChatMessage[], depth = 0) => {
|
|
111
|
+
if (depth >= MAX_TOOL_DEPTH) { setStatus('idle'); return }
|
|
112
|
+
setStatus('streaming')
|
|
113
|
+
|
|
114
|
+
const assistantId = generateId()
|
|
115
|
+
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }])
|
|
116
|
+
|
|
117
|
+
const parser = new StreamParser()
|
|
118
|
+
const pendingTools: Array<{ name: string; args: Record<string, unknown> }> = []
|
|
119
|
+
let fullText = ''
|
|
120
|
+
|
|
121
|
+
abortRef.current = new AbortController()
|
|
122
|
+
|
|
123
|
+
await stream({
|
|
124
|
+
provider: config.provider,
|
|
125
|
+
model: currentModelRef.current,
|
|
126
|
+
baseUrl: config.baseUrl,
|
|
127
|
+
messages: contextMsgs,
|
|
128
|
+
signal: abortRef.current.signal,
|
|
129
|
+
|
|
130
|
+
onToken(token) {
|
|
131
|
+
fullText += token
|
|
132
|
+
tokenBufRef.current += token
|
|
133
|
+
const now = Date.now()
|
|
134
|
+
if (now - lastRenderRef.current >= RENDER_THROTTLE_MS) {
|
|
135
|
+
const flush = tokenBufRef.current
|
|
136
|
+
tokenBufRef.current = ''
|
|
137
|
+
lastRenderRef.current = now
|
|
138
|
+
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: m.content + flush } : m))
|
|
139
|
+
}
|
|
140
|
+
for (const item of parser.feed(token)) {
|
|
141
|
+
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async onDone() {
|
|
146
|
+
if (tokenBufRef.current) {
|
|
147
|
+
const flush = tokenBufRef.current
|
|
148
|
+
tokenBufRef.current = ''
|
|
149
|
+
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: m.content + flush } : m))
|
|
150
|
+
}
|
|
151
|
+
for (const item of parser.flush()) {
|
|
152
|
+
if (item.type === 'tool_call') pendingTools.push({ name: item.toolName, args: item.toolArgs })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!pendingTools.length) { setStatus('idle'); return }
|
|
156
|
+
|
|
157
|
+
setStatus('tool')
|
|
158
|
+
const next: ChatMessage[] = [...contextMsgs, { role: 'assistant', content: fullText }]
|
|
159
|
+
|
|
160
|
+
for (const tc of pendingTools) {
|
|
161
|
+
const tool = tools.find(t => t.name === tc.name)
|
|
162
|
+
const toolId = generateId()
|
|
163
|
+
if (tool) {
|
|
164
|
+
try {
|
|
165
|
+
const result = await tool.execute(tc.args)
|
|
166
|
+
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: `[${tc.name}]\n${result}`, timestamp: Date.now() }])
|
|
167
|
+
next.push({ role: 'user', content: `Tool ${tc.name} result:\n${result}` })
|
|
168
|
+
} catch (e) {
|
|
169
|
+
const err = `Tool ${tc.name} error: ${e}`
|
|
170
|
+
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: err, timestamp: Date.now() }])
|
|
171
|
+
next.push({ role: 'user', content: err })
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
const unk = `Unknown tool: ${tc.name}`
|
|
175
|
+
setMessages(prev => [...prev, { id: toolId, role: 'tool', content: unk, timestamp: Date.now() }])
|
|
176
|
+
next.push({ role: 'user', content: unk })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
await runLoop(next, depth + 1)
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
onError(err) {
|
|
184
|
+
addMsg('system', `error: ${err.message}`)
|
|
185
|
+
setStatus('idle')
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
}, [config])
|
|
189
|
+
|
|
190
|
+
// Model picker
|
|
191
|
+
const openPicker = useCallback(async () => {
|
|
192
|
+
setPickerOpen(true)
|
|
193
|
+
setPickerLoading(true)
|
|
194
|
+
setPickerError(undefined)
|
|
195
|
+
try {
|
|
196
|
+
setPickerModels(await listModels(config.baseUrl))
|
|
197
|
+
} catch (e) {
|
|
198
|
+
setPickerError(String(e))
|
|
199
|
+
} finally {
|
|
200
|
+
setPickerLoading(false)
|
|
201
|
+
}
|
|
202
|
+
}, [config.baseUrl])
|
|
203
|
+
|
|
204
|
+
const handleModelSelect = useCallback((name: string) => {
|
|
205
|
+
setCurrentModel(name)
|
|
206
|
+
setPickerOpen(false)
|
|
207
|
+
addMsg('system', `model → ${name}`)
|
|
208
|
+
}, [])
|
|
209
|
+
|
|
210
|
+
const handleModelPull = useCallback(async (name: string) => {
|
|
211
|
+
setPullState({ name, status: 'starting...', pct: undefined })
|
|
212
|
+
pullAbortRef.current = new AbortController()
|
|
213
|
+
try {
|
|
214
|
+
await pullModel(config.baseUrl, name, (s, p) => setPullState({ name, status: s, pct: p }), pullAbortRef.current.signal)
|
|
215
|
+
setPickerModels(await listModels(config.baseUrl))
|
|
216
|
+
setPullState(undefined)
|
|
217
|
+
setCurrentModel(name)
|
|
218
|
+
setPickerOpen(false)
|
|
219
|
+
addMsg('system', `pulled ${name} → active`)
|
|
220
|
+
} catch (e) {
|
|
221
|
+
setPullState(undefined)
|
|
222
|
+
setPickerError(`pull failed: ${e}`)
|
|
223
|
+
}
|
|
224
|
+
}, [config.baseUrl])
|
|
225
|
+
|
|
226
|
+
const handleSubmit = useCallback(async (text: string) => {
|
|
227
|
+
setScrollOffset(0) // snap to bottom on new message
|
|
228
|
+
if (text.trim() === '/models') { await openPicker(); return }
|
|
229
|
+
|
|
230
|
+
if (text.startsWith('/')) {
|
|
231
|
+
const [cmd, ...rest] = text.slice(1).split(' ')
|
|
232
|
+
const skill = skills.get(cmd)
|
|
233
|
+
if (skill) {
|
|
234
|
+
if (skill.name === 'list') {
|
|
235
|
+
addMsg('system', skills.list().map(s => `/${s.ns === 'default' ? '' : s.ns + ':'}${s.name} — ${s.description}`).join('\n'))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (skill.execute) {
|
|
239
|
+
const ctx = {
|
|
240
|
+
messages: messagesRef.current.map(m => ({ role: m.role, content: m.content })),
|
|
241
|
+
appendMessage: (role: string, content: string) => addMsg(role as Message['role'], content),
|
|
242
|
+
setSystemPrompt: (p: string) => setSystemPrompt(p),
|
|
243
|
+
getSystemPrompt: () => systemPromptRef.current,
|
|
244
|
+
}
|
|
245
|
+
const result = await skill.execute(rest.join(' '), ctx)
|
|
246
|
+
if (result) addMsg('system', result)
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
if (skill.prompt) {
|
|
250
|
+
addMsg('user', skill.prompt)
|
|
251
|
+
await runLoop(buildContext({ role: 'user', content: skill.prompt }))
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
addMsg('system', `unknown skill: /${cmd}. Try /list`)
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Expand @file references
|
|
260
|
+
const { displayText, contextPrefix } = expandAtRefs(text)
|
|
261
|
+
addMsg('user', displayText)
|
|
262
|
+
const llmContent = contextPrefix + text
|
|
263
|
+
await runLoop(buildContext({ role: 'user', content: llmContent }))
|
|
264
|
+
}, [skills, runLoop, openPicker])
|
|
265
|
+
|
|
266
|
+
const handleAbort = useCallback(() => {
|
|
267
|
+
abortRef.current?.abort()
|
|
268
|
+
setStatus('idle')
|
|
269
|
+
tokenBufRef.current = ''
|
|
270
|
+
}, [])
|
|
271
|
+
|
|
272
|
+
const skillList = skills.list()
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<Box flexDirection="column" height={rows}>
|
|
276
|
+
<StatusBar model={currentModel} provider={config.provider} status={status} tick={tick} />
|
|
277
|
+
<Divider cols={cols} />
|
|
278
|
+
{pickerOpen ? (
|
|
279
|
+
<ModelPicker
|
|
280
|
+
models={pickerModels}
|
|
281
|
+
current={currentModel}
|
|
282
|
+
loading={pickerLoading}
|
|
283
|
+
error={pickerError}
|
|
284
|
+
pull={pullState}
|
|
285
|
+
onSelect={handleModelSelect}
|
|
286
|
+
onPull={handleModelPull}
|
|
287
|
+
onClose={() => { setPickerOpen(false); setPullState(undefined) }}
|
|
288
|
+
/>
|
|
289
|
+
) : (
|
|
290
|
+
<MessageList
|
|
291
|
+
messages={messages}
|
|
292
|
+
rows={rows - 8}
|
|
293
|
+
cols={cols}
|
|
294
|
+
scrollOffset={scrollOffset}
|
|
295
|
+
streaming={status === 'streaming'}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
298
|
+
<Divider cols={cols} />
|
|
299
|
+
<InputArea
|
|
300
|
+
status={status}
|
|
301
|
+
skills={skillList}
|
|
302
|
+
cwd={cwd}
|
|
303
|
+
onSubmit={handleSubmit}
|
|
304
|
+
onAbort={handleAbort}
|
|
305
|
+
/>
|
|
306
|
+
</Box>
|
|
307
|
+
)
|
|
308
|
+
}
|