diff --git a/CHANGELOG.md b/CHANGELOG.md index 128ddd93..422c66ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Mọi thay đổi đáng kể của `8sync` ghi vào đây. Format theo [Keep a versioning theo [SemVer](https://semver.org). **8sync rule:** mỗi PR cập nhật mục `Unreleased`. ## [Unreleased] +### Added +- **Realtime hints inside Claude Code (`8sync ccc hint` + mod `8sync-hints`).** Owner (2026-10-16): "enhance prompt in ccc can auto hint real time like omp suggestion to tab completion". The kit now installs a Claude Code function-hooks mod at `~/.claude/skills/8sync-hints` (auto-loaded; open sessions need a restart) that draws three things from one local Rust call: **ghost text** for the next prompt (Tab/→ takes it: "tiếp tục loop (iter k/N)", own urgent flag, the prompt the owner typed after this one before — a bigram over `~/.claude/history.jsonl` filtered by project, local-only — then "/sec rồi commit" or "push nhánh … rồi mở PR" from git), **typeahead** rows with descriptions for `/skill` (installed + repo skills), `8sync ` and `8sync ccc ` / `ccc ` tokens, and a dim **band above the prompt** (≤ 2 lines: loop · branch ↑ahead ✎dirty · urgent · watchdog alert < 10 min) with an "Ẩn" button. The mod only calls `8sync ccc hint [--complete ] --json` (no network, no model, no `gh`; one `git status` with a 300 ms budget, run beside the prompt; silent when `8sync` is missing) — idea ported from omp's predictive composer, code ours. Tests: `hint_*` (Rust; < 50 ms without git on a 41-skill + 512 KiB-history fixture), `claude plugin validate|test` (8 mod tests), `kit_installs_the_hints_mod`, `scripts/uat/hints-load.sh`. +- **`8sync completions `**: shell tab-completion scripts generated from the same clap tree the binary parses (`8sync completions bash > ~/.local/share/bash-completion/completions/8sync`). +- `research/omp-features.md`: scan of omp 18.11.1 features (MIT; ideas only) with what ccc already has, what this batch ports and what is queued for later. + ## [0.92.1] — 2026-10-15 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 26dd4ff5..5d3864f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,15 @@ dependencies = [ "terminal_size", ] +[[package]] +name = "clap_complete" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -1404,6 +1413,7 @@ dependencies = [ "axum", "ccc-core", "clap", + "clap_complete", "dirs", "getrandom 0.2.17", "indoc", diff --git a/assets/claude/skills/8sync-hints/.claude-plugin/plugin.json b/assets/claude/skills/8sync-hints/.claude-plugin/plugin.json new file mode 100644 index 00000000..76639ad3 --- /dev/null +++ b/assets/claude/skills/8sync-hints/.claude-plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "8sync-hints", + "version": "0.1.0", + "author": { "name": "8 Sync", "url": "https://github.com/8-Sync-Dev/su-code" }, + "description": "Gợi ý realtime trong prompt của Claude Code từ 8sync ccc hint: ghost text (Tab), typeahead /skill · 8sync · ccc, dải trạng thái trên prompt. Local-only.", + "types": "./types/index.d.ts" +} diff --git a/assets/claude/skills/8sync-hints/hooks/hint.test.ts b/assets/claude/skills/8sync-hints/hooks/hint.test.ts new file mode 100644 index 00000000..7c4e87fa --- /dev/null +++ b/assets/claude/skills/8sync-hints/hooks/hint.test.ts @@ -0,0 +1,147 @@ +import { expect, mock, test } from 'claude-code/testing' + +import { completeArg, parseComplete, parseHint } from './hint' + +const ok = (stdout: string) => ({ value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }) + +test('parseHint reads suggest + band and drops junk', async () => { + expect(parseHint('{"suggest":" tiếp tục loop x (iter 1/2) ","band":["loop x 1/2","⎇ main","three"]}')).toEqual({ + suggest: 'tiếp tục loop x (iter 1/2)', + band: ['loop x 1/2', '⎇ main'], + }) + expect(parseHint('{"suggest":null,"band":[]}')).toEqual({ suggest: null, band: [] }) + expect(parseHint('')).toBe(null) + expect(parseHint('not json')).toBe(null) + expect(parseHint(null)).toBe(null) + expect(parseHint('[1]')).toBe(null) +}) + +test('parseComplete keeps well-formed rows only, at most 8', async () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ text: `/s${i}`, label: `s${i}`, description: `d${i}` })) + const out = parseComplete(JSON.stringify({ complete: [...rows, { text: '' }, { label: 'no text' }, 7] })) + expect(out.length).toBe(8) + expect(out[0]).toEqual({ text: '/s0', label: 's0', description: 'd0' }) + expect(parseComplete('{"complete":"x"}')).toEqual([]) + expect(parseComplete(undefined)).toEqual([]) +}) + +test('completeArg: slash token (not a path, not at the box start), 8sync/ccc lines, nothing else', async () => { + expect(completeArg('/sh', 3, '/sh')).toBe('/sh') + expect(completeArg('fix it then /sh', 15, '/sh', 12)).toBe('/sh') + expect(completeArg('/sh', 3, '/sh', 0)).toBe(null) + expect(completeArg('see /home/me/x', 14, '/home/me/x', 4)).toBe(null) + expect(completeArg('x /a:b.c-d', 10, '/a:b.c-d', 2)).toBe('/a:b.c-d') + expect(completeArg('8sync ccc lo', 12, 'lo')).toBe('8sync ccc lo') + expect(completeArg('run\n ccc lo', 12, 'lo')).toBe('ccc lo') + expect(completeArg('8sync', 5, '8sync')).toBe('8sync') + expect(completeArg('please run 8sync ccc lo', 23, 'lo')).toBe(null) + expect(completeArg('hello', 5, 'hello')).toBe(null) +}) + +test('session.start asks 8sync ccc hint beside the start and proposes its suggestion', async ($, on) => { + const clock = mock.clock(on) + on('session.start', (_$, e) => ({ cwd: e.cwd })) + let argv: readonly string[] = [] + let cwd: string | undefined + let env: Record | undefined + on('session.id', () => ({ value: 'sess-1' })) + on('process.run', (_$, e) => { + argv = e.argv + cwd = e.init?.cwd + env = e.init?.env + return ok('{"suggest":"tiếp tục loop x (iter 1/2)","band":["loop x 1/2"]}') + }) + let suggested = '' + on('prompt.suggest', (_$, e) => { + suggested = e.text + return { isShown: true } + }) + const r = await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true }) + expect(r.cwd).toBe('/repo') + await clock.settle() // the refresh is never awaited on the start path + expect(argv).toEqual(['8sync', 'ccc', 'hint', '--json']) + expect(cwd).toBe('/repo') + expect(env?.CLAUDE_CODE_SESSION_ID).toBe('sess-1') + expect(suggested).toBe('tiếp tục loop x (iter 1/2)') + suggested = '' + await clock.advance(20_000) // idle poll + expect(suggested).toBe('tiếp tục loop x (iter 1/2)') +}) + +test('a subagent turn does not pause or trigger the refresh; the main turn does', async ($, on) => { + const clock = mock.clock(on) + on('session.start', (_$, e) => ({ cwd: e.cwd })) + let runs = 0 + on('process.run', () => { + runs += 1 + return ok('{"suggest":null,"band":["⎇ main"]}') + }) + on('turn.start', (_$, e) => ({ turnId: e.turnId })) + on('turn.complete', () => ({ text: 'done' })) + await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true }) + await clock.settle() + expect(runs).toBe(1) + await $.turn.start({ text: 'go', turnId: 't1' }) + await clock.advance(20_000) // the poll is skipped while the main turn runs + await $.turn.complete({ turnId: 't2', agentId: 'a1', answer: 'sub', reason: 'answer' }) + await clock.settle() + expect(runs).toBe(1) + const r = await $.turn.complete({ turnId: 't1', answer: 'main', reason: 'answer' }) + expect(r.text).toBe('done') + await clock.settle() + expect(runs).toBe(2) +}) + +test('a failing 8sync is silent: no suggestion, session still starts', async ($, on) => { + mock.clock(on) + on('session.start', (_$, e) => ({ cwd: e.cwd })) + on('process.run', () => ({ value: { exitCode: 127, stdout: '', stderr: 'not found', isStdoutTruncated: false, isStderrTruncated: false } })) + let suggested = 0 + on('prompt.suggest', () => { + suggested += 1 + return { isShown: true } + }) + const r = await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true }) + expect(r.cwd).toBe('/repo') + expect(suggested).toBe(0) +}) + +test('headless session does not poll', async ($, on) => { + mock.clock(on) + on('session.start', (_$, e) => ({ cwd: e.cwd })) + let runs = 0 + on('process.run', () => { + runs += 1 + return ok('{"suggest":null,"band":[]}') + }) + await $.session.start({ cwd: '/repo', surface: null, isInteractive: false }) + expect(runs).toBe(0) +}) + +test('AbovePrompt draws the band and Ẩn hides it', async ($, on) => { + mock.clock(on) + on('session.start', (_$, e) => ({ cwd: e.cwd })) + on('process.run', () => ok('{"suggest":null,"band":["loop x 1/2","⎇ feat ↑2 ✎"]}')) + // The engine's own band when the plugin yields (hidden): an empty line. + on('ui.render', ($, e) => { + const { Text } = $.ui.resolve(e) + return Text({ children: 'engine-band' }) + }) + await $.session.start({ cwd: '/repo', surface: 'terminal', isInteractive: true }) + const mounts = [] + for (const surface of ['terminal', 'desktop'] as const) { + const m = await $.ui.mount({ + plugin: '8sync-hints', + surface, + component: 'AbovePrompt', + props: { hasSurvey: false, isWorking: false, maxRows: 8, bodyColumns: 100, scroll: { offset: 0, bodyRows: 8 }, view: {} }, + }) + expect(JSON.stringify(await m.drawn())).toContain('loop x 1/2') + mounts.push(m) + } + // Ẩn is session state: pressed on one surface, the band is gone on every surface. + await mounts[0].press({ key: 'hide' }) + for (const m of mounts) { + expect(JSON.stringify(await m.drawn())).not.toContain('loop x 1/2') + } +}) diff --git a/assets/claude/skills/8sync-hints/hooks/hint.ts b/assets/claude/skills/8sync-hints/hooks/hint.ts new file mode 100644 index 00000000..aa995b23 --- /dev/null +++ b/assets/claude/skills/8sync-hints/hooks/hint.ts @@ -0,0 +1,66 @@ +// Pure helpers of the 8sync-hints mod: parse what `8sync ccc hint --json` prints and decide +// what to ask it for. No `$`, no I/O — so `hint.test.ts` can check them directly. + +export type Hint = { suggest: string | null; band: string[] } +export type Row = { text: string; label?: string; description?: string } + +const MAX_BAND_LINES = 2 +const MAX_ROWS = 8 + +/** `{suggest, band}` from `8sync ccc hint --json`; null when the output is not that. */ +export function parseHint(stdout: string | null | undefined): Hint | null { + const v = parseJson(stdout) + if (!v) return null + const suggest = typeof v.suggest === 'string' && v.suggest.trim() !== '' ? v.suggest.trim() : null + const band = Array.isArray(v.band) + ? v.band.filter((l): l is string => typeof l === 'string' && l.trim() !== '').slice(0, MAX_BAND_LINES) + : [] + return { suggest, band } +} + +/** Typeahead rows from `8sync ccc hint --complete --json`; [] when none or malformed. */ +export function parseComplete(stdout: string | null | undefined): Row[] { + const v = parseJson(stdout) + if (!v || !Array.isArray(v.complete)) return [] + const rows: Row[] = [] + for (const r of v.complete) { + if (!r || typeof r !== 'object') continue + const o = r as Record + if (typeof o.text !== 'string' || o.text === '') continue + const row: Row = { text: o.text } + if (typeof o.label === 'string' && o.label !== '') row.label = o.label + if (typeof o.description === 'string' && o.description !== '') row.description = o.description + rows.push(row) + if (rows.length >= MAX_ROWS) break + } + return rows +} + +/** + * What to pass to `--complete` for the prompt box as it stands, or null when the token is not + * ours. `/…` tokens: the token itself when it looks like a skill name (not a path like + * `/home/x`) and does not stand at the very start of the box (there the engine's own slash + * menu already lists every skill). A line that starts with `8sync` or `ccc`: the line up to the + * cursor (the Rust side answers with the replacement for the last word). + */ +export function completeArg(text: string, cursor: number, token: string, start = -1): string | null { + if (token.startsWith('/')) { + return start !== 0 && /^\/[\w:.-]*$/.test(token) ? token : null + } + const head = text.slice(0, cursor) + const line = head.slice(head.lastIndexOf('\n') + 1).trimStart() + if (/^(8sync|ccc)(\s|$)/.test(line)) return line + return null +} + +function parseJson(stdout: string | null | undefined): Record | null { + if (typeof stdout !== 'string') return null + const s = stdout.trim() + if (s === '' || s[0] !== '{') return null + try { + const v: unknown = JSON.parse(s) + return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null + } catch { + return null + } +} diff --git a/assets/claude/skills/8sync-hints/hooks/hooks.json b/assets/claude/skills/8sync-hints/hooks/hooks.json new file mode 100644 index 00000000..5a63f649 --- /dev/null +++ b/assets/claude/skills/8sync-hints/hooks/hooks.json @@ -0,0 +1 @@ +{ "modules": ["./register.tsx"] } diff --git a/assets/claude/skills/8sync-hints/hooks/register.tsx b/assets/claude/skills/8sync-hints/hooks/register.tsx new file mode 100644 index 00000000..140c15ba --- /dev/null +++ b/assets/claude/skills/8sync-hints/hooks/register.tsx @@ -0,0 +1,115 @@ +// 8sync-hints: a thin drawing layer over `8sync ccc hint` (Rust, local-only, < 50 ms). +// · session.start / turn.complete / every 20 s while idle → `8sync ccc hint --json` +// → ghost text in the empty prompt box (Tab takes it) + the band above the prompt. +// · prompt.autocomplete for `/skill`, `8sync …`, `ccc …` tokens → `--complete`. +// Any failure (no `8sync` on PATH, bad JSON, timeout) is silent, and `8sync` is never awaited +// on the path of a prompt or a turn: the refresh runs beside them, so the box never waits. +import { atom, read, update } from 'claude-code' +import type { Engine, Register } from 'claude-code' + +import { completeArg, parseComplete, parseHint } from './hint' + +const band = atom({ plugin: '8sync-hints', key: 'band' } as const, [] as string[]) +const isHidden = atom({ plugin: '8sync-hints', key: 'isHidden' } as const, false) + +const POLL_MS = 20_000 +const RUN_TIMEOUT_MS = 1_500 +const CACHE_MS = 10_000 + +// Module state (reset on every load/reload; `$.state` atoms above survive). +const st = { cwd: '', sessionId: '', isWorking: false, inflight: false } +const cache = new Map }>() + +async function hint($: Engine, args: readonly string[]): Promise { + try { + const r = await $.process.run(['8sync', 'ccc', 'hint', ...args, '--json'], { + cwd: st.cwd === '' ? undefined : st.cwd, + // So `hint` can find this session's own urgent flag in the ccc registry. + env: st.sessionId === '' ? undefined : { CLAUDE_CODE_SESSION_ID: st.sessionId }, + timeoutMs: RUN_TIMEOUT_MS, + }) + return r.exitCode === 0 ? r.stdout : null + } catch { + return null + } +} + +async function refresh($: Engine): Promise { + if (st.inflight || st.isWorking) return + st.inflight = true + try { + const h = parseHint(await hint($, [])) + if (h === null) return + await update($, band, () => h.band) + if (h.suggest !== null) { + // The engine answers { isShown: false } by itself while the box holds text or a turn runs. + await $.prompt.suggest({ text: h.suggest }) + } + } catch { + // silent: hints are best-effort + } finally { + st.inflight = false + } +} + +export const register: Register = on => { + on('session.start', async ($, e, next) => { + st.cwd = e.cwd + try { + st.sessionId = await $.session.id() + } catch { + st.sessionId = '' + } + if (e.isInteractive) { + void refresh($) + $.clock.every(POLL_MS, () => { + void refresh($) + }) + } + return next(e) + }) + + // Main loop only (`agentId` names a subagent's turn): no polling while the model works. + on('turn.start', ($, e, next) => { + if (e.agentId === undefined) st.isWorking = true + return next(e) + }) + + on('turn.complete', async ($, e, next) => { + const r = await next(e) + if (e.agentId === undefined) { + st.isWorking = false + void refresh($) + } + return r + }) + + on('prompt.autocomplete', async ($, e, next) => { + const arg = completeArg(e.text, e.cursor, e.token, e.start) + if (arg === null) return next(e) + const below = await next(e) + const now = await $.clock.now() + const hit = cache.get(arg) + let rows = hit !== undefined && now - hit.at < CACHE_MS ? hit.rows : undefined + if (rows === undefined) { + rows = parseComplete(await hint($, ['--complete', arg])) + cache.set(arg, { at: now, rows }) + if (cache.size > 64) cache.clear() + } + return { suggestions: [...below.suggestions, ...rows] } + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + const lines = await read($, band) + if (e.props.hasSurvey || lines.length === 0 || (await read($, isHidden))) { + return next(e) + } + const { Box, Button, Text } = $.ui.resolve(e) + return ( + + {lines.join(' · ')} +