detached 73513b2

Fix zotfile startup handling

commit
73513b2e343672f68efc1f9b9bc12cad23a7f30e
author
patriceckhart · · 2026-08-02 09:15 UTC
parent
189298b
signature
unsigned
7 files · +140 -35
M docs/zotfiles.md +10 -4
@@ -523,12 +523,18 @@ Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back t
523523
524524 A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root:
525525
526+```bash
527+zot run frkr/zot-archify
528+```
529+
530+The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection:
531+
526532 ```bash
527533 zot run agents/zot-maintenance
528534 zot run agents\zot-maintenance # also accepted on Windows
529535 ```
530536
531-The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
537+Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
532538
533539 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
534540
@@ -536,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel
536542 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
537543 ```
538544
539-Both the short subdirectory form above and a standard GitHub tree URL are supported:
545+Both the legacy official-collection form above and a standard GitHub tree URL are supported:
540546
541547 ```bash
542548 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
543549 ```
544550
545-The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
551+Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
546552
547-Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
553+Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
548554
549555 ### `zot pack`
550556
@@ -523,12 +523,18 @@ Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back t
523 523
524 A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root:524 A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root:
525 525
526+```bash
527+zot run frkr/zot-archify
528+```
529+
530+The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection:
531+
526 ```bash532 ```bash
527 zot run agents/zot-maintenance533 zot run agents/zot-maintenance
528 zot run agents\zot-maintenance # also accepted on Windows534 zot run agents\zot-maintenance # also accepted on Windows
529 ```535 ```
530 536
531-The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.537+Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
532 538
533 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:539 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
534 540
@@ -536,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel
536 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot542 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
537 ```543 ```
538 544
539-Both the short subdirectory form above and a standard GitHub tree URL are supported:545+Both the legacy official-collection form above and a standard GitHub tree URL are supported:
540 546
541 ```bash547 ```bash
542 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance548 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
543 ```549 ```
544 550
545-The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.551+Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
546 552
547-Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.553+Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
548 554
549 ### `zot pack`555 ### `zot pack`
550 556
M packages/agent/cli.go +45 -20
@@ -391,35 +391,60 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
391391 }
392392
393393 start := len(ag.Messages())
394- preSink := func(ev core.AgentEvent) {
394+ preSink, finishPre := newStreamTextSink(os.Stdout)
395+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
396+ finishPre()
397+ return err
398+ }
399+ finishPre()
400+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
401+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
402+ WriteNewTranscript(ag, sess, start)
403+ return err
404+}
405+
406+func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
407+ var streamed, wroteText, lastWasNL bool
408+ writeText := func(text string) {
409+ if text == "" {
410+ return
411+ }
412+ _, _ = fmt.Fprint(out, text)
413+ if syncer, ok := out.(interface{ Sync() error }); ok {
414+ _ = syncer.Sync()
415+ }
416+ wroteText = true
417+ lastWasNL = strings.HasSuffix(text, "\n")
418+ }
419+ sink := func(ev core.AgentEvent) {
395420 switch e := ev.(type) {
421+ case core.EvAssistantStart:
422+ streamed = false
396423 case core.EvTextDelta:
397- fmt.Fprint(os.Stdout, e.Delta)
398- _ = os.Stdout.Sync()
424+ streamed = true
425+ writeText(e.Delta)
399426 case core.EvAssistantMessage:
400- // Fallback when the provider does not emit deltas.
401- var sb strings.Builder
402- for _, c := range e.Message.Content {
403- if tb, ok := c.(provider.TextBlock); ok {
404- if sb.Len() > 0 {
405- sb.WriteString("\n")
427+ if streamed {
428+ return
429+ }
430+ var text strings.Builder
431+ for _, content := range e.Message.Content {
432+ if block, ok := content.(provider.TextBlock); ok {
433+ if text.Len() > 0 {
434+ text.WriteString("\n")
406435 }
407- sb.WriteString(tb.Text)
436+ text.WriteString(block.Text)
408437 }
409438 }
410- if sb.Len() > 0 {
411- fmt.Fprint(os.Stdout, sb.String())
412- _ = os.Stdout.Sync()
413- }
439+ writeText(text.String())
414440 }
415441 }
416- if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
417- return err
442+ finish := func() {
443+ if wroteText && !lastWasNL {
444+ writeText("\n")
445+ }
418446 }
419- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
420- err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
421- WriteNewTranscript(ag, sess, start)
422- return err
447+ return sink, finish
423448 }
424449
425450 func runJSONMode(ctx context.Context, args Args, version string) error {
@@ -391,35 +391,60 @@ func runStreamMode(ctx context.Context, args Args, version string) error {
391 }391 }
392 392
393 start := len(ag.Messages())393 start := len(ag.Messages())
394- preSink := func(ev core.AgentEvent) {394+ preSink, finishPre := newStreamTextSink(os.Stdout)
395+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
396+ finishPre()
397+ return err
398+ }
399+ finishPre()
400+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
401+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
402+ WriteNewTranscript(ag, sess, start)
403+ return err
404+}
405+
406+func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
407+ var streamed, wroteText, lastWasNL bool
408+ writeText := func(text string) {
409+ if text == "" {
410+ return
411+ }
412+ _, _ = fmt.Fprint(out, text)
413+ if syncer, ok := out.(interface{ Sync() error }); ok {
414+ _ = syncer.Sync()
415+ }
416+ wroteText = true
417+ lastWasNL = strings.HasSuffix(text, "\n")
418+ }
419+ sink := func(ev core.AgentEvent) {
395 switch e := ev.(type) {420 switch e := ev.(type) {
421+ case core.EvAssistantStart:
422+ streamed = false
396 case core.EvTextDelta:423 case core.EvTextDelta:
397- fmt.Fprint(os.Stdout, e.Delta)424+ streamed = true
398- _ = os.Stdout.Sync()425+ writeText(e.Delta)
399 case core.EvAssistantMessage:426 case core.EvAssistantMessage:
400- // Fallback when the provider does not emit deltas.427+ if streamed {
401- var sb strings.Builder428+ return
402- for _, c := range e.Message.Content {429+ }
403- if tb, ok := c.(provider.TextBlock); ok {430+ var text strings.Builder
404- if sb.Len() > 0 {431+ for _, content := range e.Message.Content {
405- sb.WriteString("\n")432+ if block, ok := content.(provider.TextBlock); ok {
433+ if text.Len() > 0 {
434+ text.WriteString("\n")
406 }435 }
407- sb.WriteString(tb.Text)436+ text.WriteString(block.Text)
408 }437 }
409 }438 }
410- if sb.Len() > 0 {439+ writeText(text.String())
411- fmt.Fprint(os.Stdout, sb.String())
412- _ = os.Stdout.Sync()
413- }
414 }440 }
415 }441 }
416- if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {442+ finish := func() {
417- return err443+ if wroteText && !lastWasNL {
444+ writeText("\n")
445+ }
418 }446 }
419- reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)447+ return sink, finish
420- err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
421- WriteNewTranscript(ag, sess, start)
422- return err
423 }448 }
424 449
425 func runJSONMode(ctx context.Context, args Args, version string) error {450 func runJSONMode(ctx context.Context, args Args, version string) error {
M packages/agent/modes/interactive.go +27 -6
@@ -401,6 +401,7 @@ type Interactive struct {
401401 busy bool
402402 dirty chan struct{}
403403 modelRefresh chan modelRefreshResult
404+ startupPreDone chan startupPreResult
404405 cancelTurn context.CancelFunc
405406 scrollOffset int // rows from the bottom; 0 = pinned to latest
406407 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -584,6 +585,11 @@ const initialResumeTailLimit = 80
584585 // reveal that would feel jerky.
585586 const resumeTailExpandStep = 80
586587
588+type startupPreResult struct {
589+ deferred string
590+ autoSubmit bool
591+}
592+
587593 // NewInteractive constructs an Interactive from cfg.
588594 func NewInteractive(cfg InteractiveConfig) *Interactive {
589595 renderer := tui.NewRenderer(cfg.Terminal)
@@ -620,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
620626 toolGate: map[string]int{},
621627 dirty: make(chan struct{}, 8),
622628 modelRefresh: make(chan modelRefreshResult, 1),
629+ startupPreDone: make(chan startupPreResult, 1),
623630 dialog: newLoginDialog(),
624631 modelDialog: newModelDialog(),
625632 llamaDialog: newLlamaDialog(),
@@ -884,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error {
884891 case result := <-i.modelRefresh:
885892 i.openModelPickerAfterRefresh(result.err)
886893 i.invalidate()
894+ case result := <-i.startupPreDone:
895+ i.applyStartupPreResult(result)
896+ i.invalidate()
887897 case info, ok := <-updates:
888898 if ok && info.Available {
889899 i.mu.Lock()
@@ -3002,15 +3012,24 @@ func (i *Interactive) completeStartupPre() {
30023012 if onDone != nil {
30033013 onDone()
30043014 }
3005- if deferred == "" {
3015+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3016+ i.invalidate()
3017+}
3018+
3019+// applyStartupPreResult runs on the TUI event loop so the editor remains
3020+// single-threaded. Input entered while resources were reloading wins over the
3021+// deferred prefill rather than being overwritten.
3022+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3023+ if result.deferred == "" {
30063024 return
30073025 }
3008- if auto {
3009- i.Submit(deferred)
3026+ if result.autoSubmit {
3027+ i.Submit(result.deferred)
30103028 return
30113029 }
3012- i.ed.SetValue(deferred)
3013- i.invalidate()
3030+ if i.ed.IsEmpty() {
3031+ i.ed.SetValue(result.deferred)
3032+ }
30143033 }
30153034
30163035 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
@@ -5526,7 +5545,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
55265545 out += "\n\n[cancelled]"
55275546 }
55285547
5529- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5548+ if i.agent != nil {
5549+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5550+ }
55305551
55315552 i.mu.Lock()
55325553 i.shellRunning = false
@@ -401,6 +401,7 @@ type Interactive struct {
401 busy bool401 busy bool
402 dirty chan struct{}402 dirty chan struct{}
403 modelRefresh chan modelRefreshResult403 modelRefresh chan modelRefreshResult
404+ startupPreDone chan startupPreResult
404 cancelTurn context.CancelFunc405 cancelTurn context.CancelFunc
405 scrollOffset int // rows from the bottom; 0 = pinned to latest406 scrollOffset int // rows from the bottom; 0 = pinned to latest
406 prevScrollOffset int // last value redraw snapped against; tracks intent407 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -584,6 +585,11 @@ const initialResumeTailLimit = 80
584 // reveal that would feel jerky.585 // reveal that would feel jerky.
585 const resumeTailExpandStep = 80586 const resumeTailExpandStep = 80
586 587
588+type startupPreResult struct {
589+ deferred string
590+ autoSubmit bool
591+}
592+
587 // NewInteractive constructs an Interactive from cfg.593 // NewInteractive constructs an Interactive from cfg.
588 func NewInteractive(cfg InteractiveConfig) *Interactive {594 func NewInteractive(cfg InteractiveConfig) *Interactive {
589 renderer := tui.NewRenderer(cfg.Terminal)595 renderer := tui.NewRenderer(cfg.Terminal)
@@ -620,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
620 toolGate: map[string]int{},626 toolGate: map[string]int{},
621 dirty: make(chan struct{}, 8),627 dirty: make(chan struct{}, 8),
622 modelRefresh: make(chan modelRefreshResult, 1),628 modelRefresh: make(chan modelRefreshResult, 1),
629+ startupPreDone: make(chan startupPreResult, 1),
623 dialog: newLoginDialog(),630 dialog: newLoginDialog(),
624 modelDialog: newModelDialog(),631 modelDialog: newModelDialog(),
625 llamaDialog: newLlamaDialog(),632 llamaDialog: newLlamaDialog(),
@@ -884,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error {
884 case result := <-i.modelRefresh:891 case result := <-i.modelRefresh:
885 i.openModelPickerAfterRefresh(result.err)892 i.openModelPickerAfterRefresh(result.err)
886 i.invalidate()893 i.invalidate()
894+ case result := <-i.startupPreDone:
895+ i.applyStartupPreResult(result)
896+ i.invalidate()
887 case info, ok := <-updates:897 case info, ok := <-updates:
888 if ok && info.Available {898 if ok && info.Available {
889 i.mu.Lock()899 i.mu.Lock()
@@ -3002,15 +3012,24 @@ func (i *Interactive) completeStartupPre() {
3002 if onDone != nil {3012 if onDone != nil {
3003 onDone()3013 onDone()
3004 }3014 }
3005- if deferred == "" {3015+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3016+ i.invalidate()
3017+}
3018+
3019+// applyStartupPreResult runs on the TUI event loop so the editor remains
3020+// single-threaded. Input entered while resources were reloading wins over the
3021+// deferred prefill rather than being overwritten.
3022+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3023+ if result.deferred == "" {
3006 return3024 return
3007 }3025 }
3008- if auto {3026+ if result.autoSubmit {
3009- i.Submit(deferred)3027+ i.Submit(result.deferred)
3010 return3028 return
3011 }3029 }
3012- i.ed.SetValue(deferred)3030+ if i.ed.IsEmpty() {
3013- i.invalidate()3031+ i.ed.SetValue(result.deferred)
3032+ }
3014 }3033 }
3015 3034
3016 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3035 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
@@ -5526,7 +5545,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5526 out += "\n\n[cancelled]"5545 out += "\n\n[cancelled]"
5527 }5546 }
5528 5547
5529- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})5548+ if i.agent != nil {
5549+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5550+ }
5530 5551
5531 i.mu.Lock()5552 i.mu.Lock()
5532 i.shellRunning = false5553 i.shellRunning = false
M packages/agent/modes/startup_pre_test.go +32 -3
@@ -10,12 +10,23 @@ import (
1010 "github.com/patriceckhart/zot/packages/core"
1111 )
1212
13+func applyStartupPreResultForTest(t *testing.T, i *Interactive) {
14+ t.Helper()
15+ select {
16+ case result := <-i.startupPreDone:
17+ i.applyStartupPreResult(result)
18+ default:
19+ t.Fatal("startup pre result was not queued")
20+ }
21+}
22+
1323 func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
1424 i := NewInteractive(InteractiveConfig{})
1525 i.awaitingStartupPre = true
1626 i.deferredInitialInput = "Bom dia!"
1727 i.autoSubmitDeferred = false
1828 i.completeStartupPre()
29+ applyStartupPreResultForTest(t, i)
1930 if got := i.ed.Value(); got != "Bom dia!" {
2031 t.Fatalf("editor = %q, want Bom dia!", got)
2132 }
@@ -27,6 +38,15 @@ func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
2738 }
2839 }
2940
41+func TestStartupPrePrefillPreservesTypedInput(t *testing.T) {
42+ i := NewInteractive(InteractiveConfig{})
43+ i.ed.SetValue("typed while reloading")
44+ i.applyStartupPreResult(startupPreResult{deferred: "default prompt"})
45+ if got := i.ed.Value(); got != "typed while reloading" {
46+ t.Fatalf("editor = %q, want typed input to be preserved", got)
47+ }
48+}
49+
3050 func TestCompleteStartupPreCallsOnDone(t *testing.T) {
3151 called := false
3252 i := NewInteractive(InteractiveConfig{
@@ -39,9 +59,8 @@ func TestCompleteStartupPreCallsOnDone(t *testing.T) {
3959 }
4060 }
4161
42-func TestStartupPreShellThenPrefill(t *testing.T) {
43- agent := core.NewAgent(nil, "", "", nil)
44- i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
62+func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) {
63+ i := NewInteractive(InteractiveConfig{CWD: t.TempDir()})
4564 i.runCtx = context.Background()
4665 i.awaitingStartupPre = true
4766 i.deferredInitialInput = "after-pre"
@@ -55,6 +74,11 @@ func TestStartupPreShellThenPrefill(t *testing.T) {
5574
5675 deadline := time.Now().Add(5 * time.Second)
5776 for time.Now().Before(deadline) {
77+ select {
78+ case result := <-i.startupPreDone:
79+ i.applyStartupPreResult(result)
80+ default:
81+ }
5882 i.mu.Lock()
5983 running := i.shellRunning
6084 awaiting := i.awaitingStartupPre
@@ -89,6 +113,11 @@ func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
89113
90114 deadline := time.Now().Add(5 * time.Second)
91115 for time.Now().Before(deadline) {
116+ select {
117+ case result := <-i.startupPreDone:
118+ i.applyStartupPreResult(result)
119+ default:
120+ }
92121 messages := agent.Messages()
93122 i.mu.Lock()
94123 running := i.shellRunning || i.busy
@@ -10,12 +10,23 @@ import (
10 "github.com/patriceckhart/zot/packages/core"10 "github.com/patriceckhart/zot/packages/core"
11 )11 )
12 12
13+func applyStartupPreResultForTest(t *testing.T, i *Interactive) {
14+ t.Helper()
15+ select {
16+ case result := <-i.startupPreDone:
17+ i.applyStartupPreResult(result)
18+ default:
19+ t.Fatal("startup pre result was not queued")
20+ }
21+}
22+
13 func TestCompleteStartupPrePrefillsEditor(t *testing.T) {23 func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
14 i := NewInteractive(InteractiveConfig{})24 i := NewInteractive(InteractiveConfig{})
15 i.awaitingStartupPre = true25 i.awaitingStartupPre = true
16 i.deferredInitialInput = "Bom dia!"26 i.deferredInitialInput = "Bom dia!"
17 i.autoSubmitDeferred = false27 i.autoSubmitDeferred = false
18 i.completeStartupPre()28 i.completeStartupPre()
29+ applyStartupPreResultForTest(t, i)
19 if got := i.ed.Value(); got != "Bom dia!" {30 if got := i.ed.Value(); got != "Bom dia!" {
20 t.Fatalf("editor = %q, want Bom dia!", got)31 t.Fatalf("editor = %q, want Bom dia!", got)
21 }32 }
@@ -27,6 +38,15 @@ func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
27 }38 }
28 }39 }
29 40
41+func TestStartupPrePrefillPreservesTypedInput(t *testing.T) {
42+ i := NewInteractive(InteractiveConfig{})
43+ i.ed.SetValue("typed while reloading")
44+ i.applyStartupPreResult(startupPreResult{deferred: "default prompt"})
45+ if got := i.ed.Value(); got != "typed while reloading" {
46+ t.Fatalf("editor = %q, want typed input to be preserved", got)
47+ }
48+}
49+
30 func TestCompleteStartupPreCallsOnDone(t *testing.T) {50 func TestCompleteStartupPreCallsOnDone(t *testing.T) {
31 called := false51 called := false
32 i := NewInteractive(InteractiveConfig{52 i := NewInteractive(InteractiveConfig{
@@ -39,9 +59,8 @@ func TestCompleteStartupPreCallsOnDone(t *testing.T) {
39 }59 }
40 }60 }
41 61
42-func TestStartupPreShellThenPrefill(t *testing.T) {62+func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) {
43- agent := core.NewAgent(nil, "", "", nil)63+ i := NewInteractive(InteractiveConfig{CWD: t.TempDir()})
44- i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
45 i.runCtx = context.Background()64 i.runCtx = context.Background()
46 i.awaitingStartupPre = true65 i.awaitingStartupPre = true
47 i.deferredInitialInput = "after-pre"66 i.deferredInitialInput = "after-pre"
@@ -55,6 +74,11 @@ func TestStartupPreShellThenPrefill(t *testing.T) {
55 74
56 deadline := time.Now().Add(5 * time.Second)75 deadline := time.Now().Add(5 * time.Second)
57 for time.Now().Before(deadline) {76 for time.Now().Before(deadline) {
77+ select {
78+ case result := <-i.startupPreDone:
79+ i.applyStartupPreResult(result)
80+ default:
81+ }
58 i.mu.Lock()82 i.mu.Lock()
59 running := i.shellRunning83 running := i.shellRunning
60 awaiting := i.awaitingStartupPre84 awaiting := i.awaitingStartupPre
@@ -89,6 +113,11 @@ func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
89 113
90 deadline := time.Now().Add(5 * time.Second)114 deadline := time.Now().Add(5 * time.Second)
91 for time.Now().Before(deadline) {115 for time.Now().Before(deadline) {
116+ select {
117+ case result := <-i.startupPreDone:
118+ i.applyStartupPreResult(result)
119+ default:
120+ }
92 messages := agent.Messages()121 messages := agent.Messages()
93 i.mu.Lock()122 i.mu.Lock()
94 running := i.shellRunning || i.busy123 running := i.shellRunning || i.busy
M packages/agent/zotfile.go +5 -0
@@ -411,6 +411,11 @@ func resolveZotfileRef(ref string) (string, error) {
411411 return officialZotfileCollection + "/" + parts[0], nil
412412 }
413413 if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) {
414+ // Preserve the original official-collection shorthand, where
415+ // agents/<name> selects a subdirectory of patriceckhart/agents.
416+ if parts[0] == "agents" {
417+ return officialZotfileCollection + "/" + parts[1], nil
418+ }
414419 return "https://github.com/" + parts[0] + "/" + parts[1], nil
415420 }
416421 return ref, nil
@@ -411,6 +411,11 @@ func resolveZotfileRef(ref string) (string, error) {
411 return officialZotfileCollection + "/" + parts[0], nil411 return officialZotfileCollection + "/" + parts[0], nil
412 }412 }
413 if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) {413 if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) {
414+ // Preserve the original official-collection shorthand, where
415+ // agents/<name> selects a subdirectory of patriceckhart/agents.
416+ if parts[0] == "agents" {
417+ return officialZotfileCollection + "/" + parts[1], nil
418+ }
414 return "https://github.com/" + parts[0] + "/" + parts[1], nil419 return "https://github.com/" + parts[0] + "/" + parts[1], nil
415 }420 }
416 return ref, nil421 return ref, nil
M packages/agent/zotfile_pre_test.go +19 -0
@@ -1,12 +1,31 @@
11 package agent
22
33 import (
4+ "bytes"
45 "context"
56 "runtime"
67 "strings"
78 "testing"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
812 )
913
14+func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) {
15+ var out bytes.Buffer
16+ sink, finish := newStreamTextSink(&out)
17+ sink(core.EvAssistantStart{})
18+ sink(core.EvTextDelta{Delta: "streamed"})
19+ sink(core.EvAssistantMessage{Message: provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.TextBlock{Text: "streamed"}},
22+ }})
23+ finish()
24+ if got := out.String(); got != "streamed\n" {
25+ t.Fatalf("output = %q, want streamed\\n", got)
26+ }
27+}
28+
1029 func TestRunZotfileStartupPreShellFailure(t *testing.T) {
1130 pre := "!false"
1231 if runtime.GOOS == "windows" {
@@ -1,12 +1,31 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "bytes"
4 "context"5 "context"
5 "runtime"6 "runtime"
6 "strings"7 "strings"
7 "testing"8 "testing"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
8 )12 )
9 13
14+func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) {
15+ var out bytes.Buffer
16+ sink, finish := newStreamTextSink(&out)
17+ sink(core.EvAssistantStart{})
18+ sink(core.EvTextDelta{Delta: "streamed"})
19+ sink(core.EvAssistantMessage{Message: provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.TextBlock{Text: "streamed"}},
22+ }})
23+ finish()
24+ if got := out.String(); got != "streamed\n" {
25+ t.Fatalf("output = %q, want streamed\\n", got)
26+ }
27+}
28+
10 func TestRunZotfileStartupPreShellFailure(t *testing.T) {29 func TestRunZotfileStartupPreShellFailure(t *testing.T) {
11 pre := "!false"30 pre := "!false"
12 if runtime.GOOS == "windows" {31 if runtime.GOOS == "windows" {
M packages/agent/zotfile_test.go +2 -2
@@ -180,8 +180,8 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) {
180180 {"packed-agent", "packed-agent.zot"},
181181 {"remote-agent", officialZotfileCollection + "/remote-agent"},
182182 {"missing.zot", "missing.zot"},
183- {"agents/remote-agent", "https://github.com/agents/remote-agent"},
184- {`agents\remote-agent`, "https://github.com/agents/remote-agent"},
183+ {"agents/remote-agent", officialZotfileCollection + "/remote-agent"},
184+ {`agents\remote-agent`, officialZotfileCollection + "/remote-agent"},
185185 {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
186186 {"./missing-agent", "./missing-agent"},
187187 {"Remote-Agent", "Remote-Agent"},
@@ -180,8 +180,8 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) {
180 {"packed-agent", "packed-agent.zot"},180 {"packed-agent", "packed-agent.zot"},
181 {"remote-agent", officialZotfileCollection + "/remote-agent"},181 {"remote-agent", officialZotfileCollection + "/remote-agent"},
182 {"missing.zot", "missing.zot"},182 {"missing.zot", "missing.zot"},
183- {"agents/remote-agent", "https://github.com/agents/remote-agent"},183+ {"agents/remote-agent", officialZotfileCollection + "/remote-agent"},
184- {`agents\remote-agent`, "https://github.com/agents/remote-agent"},184+ {`agents\remote-agent`, officialZotfileCollection + "/remote-agent"},
185 {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},185 {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
186 {"./missing-agent", "./missing-agent"},186 {"./missing-agent", "./missing-agent"},
187 {"Remote-Agent", "Remote-Agent"},187 {"Remote-Agent", "Remote-Agent"},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close