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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug> (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 <cmd>` and `8sync ccc <verb>` / `ccc <verb>` 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 <token>] --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 <bash|zsh|fish|powershell|elvish>`**: 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
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions assets/claude/skills/8sync-hints/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
147 changes: 147 additions & 0 deletions assets/claude/skills/8sync-hints/hooks/hint.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | 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')
}
})
66 changes: 66 additions & 0 deletions assets/claude/skills/8sync-hints/hooks/hint.ts
Original file line number Diff line number Diff line change
@@ -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 <arg> --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<string, unknown>
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<string, unknown> | 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<string, unknown>) : null
} catch {
return null
}
}
1 change: 1 addition & 0 deletions assets/claude/skills/8sync-hints/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
115 changes: 115 additions & 0 deletions assets/claude/skills/8sync-hints/hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { at: number; rows: ReturnType<typeof parseComplete> }>()

async function hint($: Engine, args: readonly string[]): Promise<string | null> {
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<void> {
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 (
<Box>
<Text dimColor>{lines.join(' · ')} </Text>
<Button key="hide" label="Ẩn" onPress={() => update($, isHidden, () => true)} />
</Box>
)
})
}
5 changes: 5 additions & 0 deletions assets/claude/skills/8sync-hints/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module 'claude-code' {
interface PluginState {
'8sync-hints': { band: string[]; isHidden: boolean }
}
}
2 changes: 2 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ web = ["dep:axum", "dep:tokio", "dep:tower-http", "dep:scraper"]

[dependencies]
clap = { version = "4.5", features = ["derive", "wrap_help"] }
# `8sync completions <shell>`: tab-completion scripts generated from the same clap tree.
clap_complete = "4.5"
anyhow = "1.0"
ccc-core = { path = "../ccc-core" }
owo-colors = "4.1"
Expand Down
Loading
Loading